diff --git a/backend/WizardRPG.Api/Controllers/AchievementController.cs b/backend/WizardRPG.Api/Controllers/AchievementController.cs new file mode 100644 index 0000000..7dd262f --- /dev/null +++ b/backend/WizardRPG.Api/Controllers/AchievementController.cs @@ -0,0 +1,37 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using WizardRPG.Api.DTOs.Achievement; +using WizardRPG.Api.Services; + +namespace WizardRPG.Api.Controllers; + +[ApiController] +[Route("api/achievement")] +[Authorize] +public class AchievementController : ControllerBase +{ + private readonly IAchievementService _achievementService; + + public AchievementController(IAchievementService achievementService) => _achievementService = achievementService; + + private Guid CurrentPlayerId => Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier) + ?? User.FindFirstValue("sub") + ?? throw new UnauthorizedAccessException()); + + /// Get the current player's achievements. + [HttpGet] + public async Task>> GetMyAchievements() + { + var achievements = await _achievementService.GetPlayerAchievementsAsync(CurrentPlayerId); + return Ok(achievements); + } + + /// Check and award achievements for the current player. + [HttpPost("check")] + public async Task CheckAchievements() + { + await _achievementService.CheckAndAwardAchievementsAsync(CurrentPlayerId); + return Ok(new { message = "Achievements checked." }); + } +} diff --git a/backend/WizardRPG.Api/Controllers/EquipmentController.cs b/backend/WizardRPG.Api/Controllers/EquipmentController.cs new file mode 100644 index 0000000..9b48ff9 --- /dev/null +++ b/backend/WizardRPG.Api/Controllers/EquipmentController.cs @@ -0,0 +1,45 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using WizardRPG.Api.DTOs.Equipment; +using WizardRPG.Api.Services; + +namespace WizardRPG.Api.Controllers; + +[ApiController] +[Route("api/equipment")] +[Authorize] +public class EquipmentController : ControllerBase +{ + private readonly IEquipmentService _equipmentService; + + public EquipmentController(IEquipmentService equipmentService) => _equipmentService = equipmentService; + + private Guid CurrentPlayerId => Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier) + ?? User.FindFirstValue("sub") + ?? throw new UnauthorizedAccessException()); + + /// Get the current player's equipment. + [HttpGet] + public async Task> GetMyEquipment() + { + var equipment = await _equipmentService.GetEquipmentAsync(CurrentPlayerId); + return Ok(equipment); + } + + /// Equip an item from the bank. + [HttpPost("equip")] + public async Task> EquipItem([FromBody] EquipItemRequest request) + { + var equipment = await _equipmentService.EquipItemAsync(CurrentPlayerId, request.BankItemId); + return Ok(equipment); + } + + /// Unequip an item from a slot. + [HttpPost("unequip")] + public async Task> UnequipItem([FromBody] UnequipItemRequest request) + { + var equipment = await _equipmentService.UnequipItemAsync(CurrentPlayerId, request.Slot); + return Ok(equipment); + } +} diff --git a/backend/WizardRPG.Api/Controllers/HouseController.cs b/backend/WizardRPG.Api/Controllers/HouseController.cs new file mode 100644 index 0000000..40d46bc --- /dev/null +++ b/backend/WizardRPG.Api/Controllers/HouseController.cs @@ -0,0 +1,60 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using WizardRPG.Api.DTOs.House; +using WizardRPG.Api.DTOs.Player; +using WizardRPG.Api.Services; + +namespace WizardRPG.Api.Controllers; + +[ApiController] +[Route("api/house")] +[Authorize] +public class HouseController : ControllerBase +{ + private readonly IHouseService _houseService; + + public HouseController(IHouseService houseService) => _houseService = houseService; + + private Guid CurrentPlayerId => Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier) + ?? User.FindFirstValue("sub") + ?? throw new UnauthorizedAccessException()); + + /// Get the house leaderboard. + [HttpGet("leaderboard")] + [AllowAnonymous] + public async Task>> GetLeaderboard() + { + var leaderboard = await _houseService.GetHouseLeaderboardAsync(); + return Ok(leaderboard); + } + + /// Get house points for a specific house. + [HttpGet("{house}/points")] + public async Task>> GetHousePoints(string house, [FromQuery] int limit = 50) + { + var points = await _houseService.GetHousePointsAsync(house, limit); + return Ok(points); + } + + /// Select a house for the current player. + [HttpPost("select")] + public async Task> SelectHouse([FromBody] SelectHouseRequest request) + { + var profile = await _houseService.SelectHouseAsync(CurrentPlayerId, request.House); + return Ok(profile); + } + + /// Award house points (admin only). + [HttpPost("points")] + public async Task> AwardPoints([FromBody] AwardHousePointsRequest request) + { + // Check admin + var isAdmin = User.FindFirstValue("isAdmin"); + if (isAdmin != "true") + return Forbid(); + + var result = await _houseService.AwardHousePointsAsync(request.PlayerId, request.Points, request.Activity); + return Ok(result); + } +} diff --git a/backend/WizardRPG.Api/Controllers/LoginRewardController.cs b/backend/WizardRPG.Api/Controllers/LoginRewardController.cs new file mode 100644 index 0000000..f0807f2 --- /dev/null +++ b/backend/WizardRPG.Api/Controllers/LoginRewardController.cs @@ -0,0 +1,37 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using WizardRPG.Api.DTOs.LoginReward; +using WizardRPG.Api.Services; + +namespace WizardRPG.Api.Controllers; + +[ApiController] +[Route("api/login-reward")] +[Authorize] +public class LoginRewardController : ControllerBase +{ + private readonly ILoginRewardService _loginRewardService; + + public LoginRewardController(ILoginRewardService loginRewardService) => _loginRewardService = loginRewardService; + + private Guid CurrentPlayerId => Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier) + ?? User.FindFirstValue("sub") + ?? throw new UnauthorizedAccessException()); + + /// Get the current player's login reward status. + [HttpGet("status")] + public async Task> GetStatus() + { + var status = await _loginRewardService.GetLoginRewardStatusAsync(CurrentPlayerId); + return Ok(status); + } + + /// Claim the daily login reward. + [HttpPost("claim")] + public async Task> ClaimReward() + { + var reward = await _loginRewardService.ClaimDailyRewardAsync(CurrentPlayerId); + return Ok(reward); + } +} diff --git a/backend/WizardRPG.Api/Controllers/NotificationController.cs b/backend/WizardRPG.Api/Controllers/NotificationController.cs new file mode 100644 index 0000000..e1262b8 --- /dev/null +++ b/backend/WizardRPG.Api/Controllers/NotificationController.cs @@ -0,0 +1,53 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using WizardRPG.Api.DTOs.Notification; +using WizardRPG.Api.Services; + +namespace WizardRPG.Api.Controllers; + +[ApiController] +[Route("api/notification")] +[Authorize] +public class NotificationController : ControllerBase +{ + private readonly INotificationService _notificationService; + + public NotificationController(INotificationService notificationService) => _notificationService = notificationService; + + private Guid CurrentPlayerId => Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier) + ?? User.FindFirstValue("sub") + ?? throw new UnauthorizedAccessException()); + + /// Get the current player's notifications. + [HttpGet] + public async Task>> GetMyNotifications([FromQuery] int limit = 20) + { + var notifications = await _notificationService.GetPlayerNotificationsAsync(CurrentPlayerId, limit); + return Ok(notifications); + } + + /// Get unread notification count. + [HttpGet("unread-count")] + public async Task> GetUnreadCount() + { + var count = await _notificationService.GetUnreadCountAsync(CurrentPlayerId); + return Ok(count); + } + + /// Mark a notification as read. + [HttpPost("{id:guid}/read")] + public async Task MarkAsRead(Guid id) + { + await _notificationService.MarkAsReadAsync(CurrentPlayerId, id); + return Ok(new { message = "Notification marked as read." }); + } + + /// Mark all notifications as read. + [HttpPost("read-all")] + public async Task MarkAllAsRead() + { + await _notificationService.MarkAllAsReadAsync(CurrentPlayerId); + return Ok(new { message = "All notifications marked as read." }); + } +} diff --git a/backend/WizardRPG.Api/Controllers/PlayerController.cs b/backend/WizardRPG.Api/Controllers/PlayerController.cs index ec7e9e5..146e97a 100644 --- a/backend/WizardRPG.Api/Controllers/PlayerController.cs +++ b/backend/WizardRPG.Api/Controllers/PlayerController.cs @@ -51,4 +51,12 @@ public async Task>> GetLeaderboard([Fro var leaderboard = await _playerService.GetLeaderboardAsync(top); return Ok(leaderboard); } + + /// Get battle statistics for a player. + [HttpGet("battle-stats/{playerId:guid}")] + public async Task> GetBattleStats(Guid playerId) + { + var stats = await _playerService.GetBattleStatsAsync(playerId); + return Ok(stats); + } } diff --git a/backend/WizardRPG.Api/Controllers/QuestController.cs b/backend/WizardRPG.Api/Controllers/QuestController.cs new file mode 100644 index 0000000..66d0dfc --- /dev/null +++ b/backend/WizardRPG.Api/Controllers/QuestController.cs @@ -0,0 +1,45 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using WizardRPG.Api.DTOs.Quest; +using WizardRPG.Api.Services; + +namespace WizardRPG.Api.Controllers; + +[ApiController] +[Route("api/quest")] +[Authorize] +public class QuestController : ControllerBase +{ + private readonly IQuestService _questService; + + public QuestController(IQuestService questService) => _questService = questService; + + private Guid CurrentPlayerId => Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier) + ?? User.FindFirstValue("sub") + ?? throw new UnauthorizedAccessException()); + + /// Get the current player's quests. + [HttpGet] + public async Task>> GetMyQuests() + { + var quests = await _questService.GetPlayerQuestsAsync(CurrentPlayerId); + return Ok(quests); + } + + /// Generate daily quests for the current player. + [HttpPost("generate-daily")] + public async Task GenerateDaily() + { + await _questService.GenerateDailyQuestsAsync(CurrentPlayerId); + return Ok(new { message = "Daily quests generated." }); + } + + /// Generate weekly quests for the current player. + [HttpPost("generate-weekly")] + public async Task GenerateWeekly() + { + await _questService.GenerateWeeklyQuestsAsync(CurrentPlayerId); + return Ok(new { message = "Weekly quests generated." }); + } +} diff --git a/backend/WizardRPG.Api/DTOs/Achievement/AchievementDtos.cs b/backend/WizardRPG.Api/DTOs/Achievement/AchievementDtos.cs new file mode 100644 index 0000000..1feda9d --- /dev/null +++ b/backend/WizardRPG.Api/DTOs/Achievement/AchievementDtos.cs @@ -0,0 +1,3 @@ +namespace WizardRPG.Api.DTOs.Achievement; + +public record AchievementResponse(Guid Id, string Key, string Name, string Description, DateTime UnlockedAt); diff --git a/backend/WizardRPG.Api/DTOs/Auth/AuthDtos.cs b/backend/WizardRPG.Api/DTOs/Auth/AuthDtos.cs index ff7f4ac..2c9ec8a 100644 --- a/backend/WizardRPG.Api/DTOs/Auth/AuthDtos.cs +++ b/backend/WizardRPG.Api/DTOs/Auth/AuthDtos.cs @@ -1,6 +1,6 @@ namespace WizardRPG.Api.DTOs.Auth; -public record RegisterRequest(string Username, string Email, string Password, string? ReferralCode); +public record RegisterRequest(string Username, string Email, string Password, string? ReferralCode, string? House); public record LoginRequest(string Email, string Password); public record RefreshTokenRequest(string RefreshToken); diff --git a/backend/WizardRPG.Api/DTOs/Equipment/EquipmentDtos.cs b/backend/WizardRPG.Api/DTOs/Equipment/EquipmentDtos.cs new file mode 100644 index 0000000..4033b61 --- /dev/null +++ b/backend/WizardRPG.Api/DTOs/Equipment/EquipmentDtos.cs @@ -0,0 +1,15 @@ +namespace WizardRPG.Api.DTOs.Equipment; + +using WizardRPG.Api.Models; + +public record EquipmentSlots( + EquippedItemResponse? Wand, + EquippedItemResponse? Robe, + EquippedItemResponse? Hat, + EquippedItemResponse? Amulet, + EquippedItemResponse? Broom); + +public record EquippedItemResponse(Guid BankItemId, Guid ItemId, string Name, string Description, ItemType Type, int MagicBonus, int StrengthBonus, int WisdomBonus, int SpeedBonus); + +public record EquipItemRequest(Guid BankItemId); +public record UnequipItemRequest(string Slot); diff --git a/backend/WizardRPG.Api/DTOs/House/HouseDtos.cs b/backend/WizardRPG.Api/DTOs/House/HouseDtos.cs new file mode 100644 index 0000000..5c9902d --- /dev/null +++ b/backend/WizardRPG.Api/DTOs/House/HouseDtos.cs @@ -0,0 +1,6 @@ +namespace WizardRPG.Api.DTOs.House; + +public record HouseLeaderboardEntry(string House, long TotalPoints, int MemberCount); +public record HousePointsResponse(Guid Id, Guid PlayerId, string PlayerUsername, string House, int Points, string Activity, DateTime EarnedAt); +public record SelectHouseRequest(string House); +public record AwardHousePointsRequest(Guid PlayerId, int Points, string Activity); diff --git a/backend/WizardRPG.Api/DTOs/LoginReward/LoginRewardDtos.cs b/backend/WizardRPG.Api/DTOs/LoginReward/LoginRewardDtos.cs new file mode 100644 index 0000000..bd0e9d0 --- /dev/null +++ b/backend/WizardRPG.Api/DTOs/LoginReward/LoginRewardDtos.cs @@ -0,0 +1,4 @@ +namespace WizardRPG.Api.DTOs.LoginReward; + +public record LoginRewardResponse(int Day, long GoldReward, string? ItemReward, int LoginStreak); +public record LoginRewardStatus(int LoginStreak, bool CanClaimToday, DateTime? LastClaimDate); diff --git a/backend/WizardRPG.Api/DTOs/Notification/NotificationDtos.cs b/backend/WizardRPG.Api/DTOs/Notification/NotificationDtos.cs new file mode 100644 index 0000000..9f2758f --- /dev/null +++ b/backend/WizardRPG.Api/DTOs/Notification/NotificationDtos.cs @@ -0,0 +1,3 @@ +namespace WizardRPG.Api.DTOs.Notification; + +public record NotificationResponse(Guid Id, string Title, string Message, string Type, bool IsRead, DateTime CreatedAt); diff --git a/backend/WizardRPG.Api/DTOs/Player/BattleStatsDtos.cs b/backend/WizardRPG.Api/DTOs/Player/BattleStatsDtos.cs new file mode 100644 index 0000000..90b35ea --- /dev/null +++ b/backend/WizardRPG.Api/DTOs/Player/BattleStatsDtos.cs @@ -0,0 +1,6 @@ +namespace WizardRPG.Api.DTOs.Player; + +public record BattleStatsResponse( + int TotalBattles, int Wins, int Losses, double WinRate, + long TotalDamageDealt, long TotalDamageReceived, + string? MostUsedSpell, int CurrentWinStreak, int BestWinStreak); diff --git a/backend/WizardRPG.Api/DTOs/Player/PlayerDtos.cs b/backend/WizardRPG.Api/DTOs/Player/PlayerDtos.cs index db33a8f..50c6826 100644 --- a/backend/WizardRPG.Api/DTOs/Player/PlayerDtos.cs +++ b/backend/WizardRPG.Api/DTOs/Player/PlayerDtos.cs @@ -13,7 +13,13 @@ public record PlayerProfileResponse( int Speed, string ReferralCode, DateTime CreatedAt, - bool IsAdmin); + bool IsAdmin, + int EloRating, + string House, + string RankTier, + string RankBadge, + bool HasCompletedOnboarding, + int LoginStreak); public record UpdateProfileRequest(string? Username, string? Email); diff --git a/backend/WizardRPG.Api/DTOs/Quest/QuestDtos.cs b/backend/WizardRPG.Api/DTOs/Quest/QuestDtos.cs new file mode 100644 index 0000000..84b0417 --- /dev/null +++ b/backend/WizardRPG.Api/DTOs/Quest/QuestDtos.cs @@ -0,0 +1,5 @@ +namespace WizardRPG.Api.DTOs.Quest; + +using WizardRPG.Api.Models; + +public record QuestResponse(Guid Id, string Title, string Description, QuestType Type, QuestStatus Status, int TargetCount, int CurrentCount, long GoldReward, int XpReward, DateTime ExpiresAt); diff --git a/backend/WizardRPG.Api/Data/AppDbContext.cs b/backend/WizardRPG.Api/Data/AppDbContext.cs index f33435e..d0b7039 100644 --- a/backend/WizardRPG.Api/Data/AppDbContext.cs +++ b/backend/WizardRPG.Api/Data/AppDbContext.cs @@ -19,6 +19,10 @@ public AppDbContext(DbContextOptions options) : base(options) { } public DbSet Battles => Set(); public DbSet BattleTurns => Set(); public DbSet Spells => Set(); + public DbSet HousePoints => Set(); + public DbSet Achievements => Set(); + public DbSet Quests => Set(); + public DbSet Notifications => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) { @@ -32,6 +36,43 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) e.HasIndex(p => p.ReferralCode).IsUnique(); e.Property(p => p.GoldCoins).HasDefaultValue(0L); e.Property(p => p.Level).HasDefaultValue(1); + e.Property(p => p.EloRating).HasDefaultValue(1000); + + e.HasOne(p => p.ReferredByPlayer) + .WithMany() + .HasForeignKey(p => p.ReferredByPlayerId) + .OnDelete(DeleteBehavior.SetNull) + .IsRequired(false); + + e.HasOne(p => p.EquippedWand) + .WithMany() + .HasForeignKey(p => p.EquippedWandId) + .OnDelete(DeleteBehavior.SetNull) + .IsRequired(false); + + e.HasOne(p => p.EquippedRobe) + .WithMany() + .HasForeignKey(p => p.EquippedRobeId) + .OnDelete(DeleteBehavior.SetNull) + .IsRequired(false); + + e.HasOne(p => p.EquippedHat) + .WithMany() + .HasForeignKey(p => p.EquippedHatId) + .OnDelete(DeleteBehavior.SetNull) + .IsRequired(false); + + e.HasOne(p => p.EquippedAmulet) + .WithMany() + .HasForeignKey(p => p.EquippedAmuletId) + .OnDelete(DeleteBehavior.SetNull) + .IsRequired(false); + + e.HasOne(p => p.EquippedBroom) + .WithMany() + .HasForeignKey(p => p.EquippedBroomId) + .OnDelete(DeleteBehavior.SetNull) + .IsRequired(false); }); modelBuilder.Entity(e => @@ -151,5 +192,41 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) .HasForeignKey(bt => bt.SpellId) .OnDelete(DeleteBehavior.Restrict); }); + + modelBuilder.Entity(e => + { + e.HasKey(hp => hp.Id); + e.HasOne(hp => hp.Player) + .WithMany(p => p.HousePoints) + .HasForeignKey(hp => hp.PlayerId) + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity(e => + { + e.HasKey(a => a.Id); + e.HasOne(a => a.Player) + .WithMany(p => p.Achievements) + .HasForeignKey(a => a.PlayerId) + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity(e => + { + e.HasKey(q => q.Id); + e.HasOne(q => q.Player) + .WithMany(p => p.Quests) + .HasForeignKey(q => q.PlayerId) + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity(e => + { + e.HasKey(n => n.Id); + e.HasOne(n => n.Player) + .WithMany(p => p.Notifications) + .HasForeignKey(n => n.PlayerId) + .OnDelete(DeleteBehavior.Cascade); + }); } } diff --git a/backend/WizardRPG.Api/Models/Achievement.cs b/backend/WizardRPG.Api/Models/Achievement.cs new file mode 100644 index 0000000..2ab1587 --- /dev/null +++ b/backend/WizardRPG.Api/Models/Achievement.cs @@ -0,0 +1,13 @@ +namespace WizardRPG.Api.Models; + +public class Achievement +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public Guid PlayerId { get; set; } + public string Key { get; set; } = string.Empty; + public string Name { get; set; } = string.Empty; + public string Description { get; set; } = string.Empty; + public DateTime UnlockedAt { get; set; } = DateTime.UtcNow; + + public Player? Player { get; set; } +} diff --git a/backend/WizardRPG.Api/Models/HousePoints.cs b/backend/WizardRPG.Api/Models/HousePoints.cs new file mode 100644 index 0000000..f881612 --- /dev/null +++ b/backend/WizardRPG.Api/Models/HousePoints.cs @@ -0,0 +1,13 @@ +namespace WizardRPG.Api.Models; + +public class HousePoints +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public Guid PlayerId { get; set; } + public string House { get; set; } = string.Empty; + public int Points { get; set; } + public string Activity { get; set; } = string.Empty; + public DateTime EarnedAt { get; set; } = DateTime.UtcNow; + + public Player? Player { get; set; } +} diff --git a/backend/WizardRPG.Api/Models/Notification.cs b/backend/WizardRPG.Api/Models/Notification.cs new file mode 100644 index 0000000..d992d6a --- /dev/null +++ b/backend/WizardRPG.Api/Models/Notification.cs @@ -0,0 +1,14 @@ +namespace WizardRPG.Api.Models; + +public class Notification +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public Guid PlayerId { get; set; } + public string Title { get; set; } = string.Empty; + public string Message { get; set; } = string.Empty; + public string Type { get; set; } = string.Empty; + public bool IsRead { get; set; } = false; + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + + public Player? Player { get; set; } +} diff --git a/backend/WizardRPG.Api/Models/Player.cs b/backend/WizardRPG.Api/Models/Player.cs index 06aee8d..d9bc2e5 100644 --- a/backend/WizardRPG.Api/Models/Player.cs +++ b/backend/WizardRPG.Api/Models/Player.cs @@ -18,9 +18,36 @@ public class Player public bool IsAdmin { get; set; } = false; public string? RefreshToken { get; set; } public DateTime? RefreshTokenExpiry { get; set; } + public int EloRating { get; set; } = 1000; + public string House { get; set; } = string.Empty; + public Guid? ReferredByPlayerId { get; set; } + public int ReferralCount { get; set; } = 0; + public DateTime? LastLoginDate { get; set; } + public int LoginStreak { get; set; } = 0; + public DateTime? LastLoginRewardDate { get; set; } + public bool HasCompletedOnboarding { get; set; } = false; + + // Equipment slots + public Guid? EquippedWandId { get; set; } + public Guid? EquippedRobeId { get; set; } + public Guid? EquippedHatId { get; set; } + public Guid? EquippedAmuletId { get; set; } + public Guid? EquippedBroomId { get; set; } + + // Navigation properties for equipped items + public BankItem? EquippedWand { get; set; } + public BankItem? EquippedRobe { get; set; } + public BankItem? EquippedHat { get; set; } + public BankItem? EquippedAmulet { get; set; } + public BankItem? EquippedBroom { get; set; } + public Player? ReferredByPlayer { get; set; } public BankAccount? BankAccount { get; set; } public ICollection BankItems { get; set; } = new List(); public ICollection BroomBets { get; set; } = new List(); public ICollection FellowshipMemberships { get; set; } = new List(); + public ICollection Achievements { get; set; } = new List(); + public ICollection Quests { get; set; } = new List(); + public ICollection Notifications { get; set; } = new List(); + public ICollection HousePoints { get; set; } = new List(); } diff --git a/backend/WizardRPG.Api/Models/Quest.cs b/backend/WizardRPG.Api/Models/Quest.cs new file mode 100644 index 0000000..faa1b56 --- /dev/null +++ b/backend/WizardRPG.Api/Models/Quest.cs @@ -0,0 +1,32 @@ +namespace WizardRPG.Api.Models; + +public enum QuestType +{ + Daily, + Weekly +} + +public enum QuestStatus +{ + Active, + Completed, + Expired +} + +public class Quest +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public Guid PlayerId { get; set; } + public string Title { get; set; } = string.Empty; + public string Description { get; set; } = string.Empty; + public QuestType Type { get; set; } + public QuestStatus Status { get; set; } = QuestStatus.Active; + public int TargetCount { get; set; } + public int CurrentCount { get; set; } = 0; + public long GoldReward { get; set; } + public int XpReward { get; set; } + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public DateTime ExpiresAt { get; set; } + + public Player? Player { get; set; } +} diff --git a/backend/WizardRPG.Api/Program.cs b/backend/WizardRPG.Api/Program.cs index f7abecb..defc4dc 100644 --- a/backend/WizardRPG.Api/Program.cs +++ b/backend/WizardRPG.Api/Program.cs @@ -59,6 +59,12 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddSingleton(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); // SignalR builder.Services.AddSignalR(); diff --git a/backend/WizardRPG.Api/Services/AchievementService.cs b/backend/WizardRPG.Api/Services/AchievementService.cs new file mode 100644 index 0000000..cddc6f1 --- /dev/null +++ b/backend/WizardRPG.Api/Services/AchievementService.cs @@ -0,0 +1,126 @@ +using Microsoft.EntityFrameworkCore; +using WizardRPG.Api.Data; +using WizardRPG.Api.DTOs.Achievement; +using WizardRPG.Api.Models; + +namespace WizardRPG.Api.Services; + +public interface IAchievementService +{ + Task> GetPlayerAchievementsAsync(Guid playerId); + Task CheckAndAwardAchievementsAsync(Guid playerId); +} + +public class AchievementService : IAchievementService +{ + private readonly AppDbContext _db; + private readonly INotificationService _notifications; + + public AchievementService(AppDbContext db, INotificationService notifications) + { + _db = db; + _notifications = notifications; + } + + public async Task> GetPlayerAchievementsAsync(Guid playerId) + { + var achievements = await _db.Achievements + .Where(a => a.PlayerId == playerId) + .OrderByDescending(a => a.UnlockedAt) + .ToListAsync(); + + return achievements.Select(a => new AchievementResponse( + a.Id, a.Key, a.Name, a.Description, a.UnlockedAt)).ToList(); + } + + public async Task CheckAndAwardAchievementsAsync(Guid playerId) + { + var existing = await _db.Achievements + .Where(a => a.PlayerId == playerId) + .Select(a => a.Key) + .ToListAsync(); + + var existingKeys = new HashSet(existing); + + // First Blood - Win your first battle + if (!existingKeys.Contains("first_blood")) + { + var hasWin = await _db.Battles.AnyAsync(b => + b.WinnerId == playerId && b.Status == BattleStatus.Finished); + if (hasWin) + await AwardAchievementAsync(playerId, "first_blood", "First Blood", "Win your first battle."); + } + + // Spell Master - Use every spell at least once + if (!existingKeys.Contains("spell_master")) + { + var totalSpells = await _db.Spells.CountAsync(); + if (totalSpells > 0) + { + var usedSpells = await _db.BattleTurns + .Where(t => t.AttackerId == playerId) + .Select(t => t.SpellId) + .Distinct() + .CountAsync(); + if (usedSpells >= totalSpells) + await AwardAchievementAsync(playerId, "spell_master", "Spell Master", "Use every spell at least once."); + } + } + + // Gold Hoarder - Accumulate 10,000 gold + if (!existingKeys.Contains("gold_hoarder")) + { + var player = await _db.Players.FindAsync(playerId); + var bankBalance = await _db.BankAccounts + .Where(b => b.PlayerId == playerId) + .Select(b => b.GoldBalance) + .FirstOrDefaultAsync(); + + if (player != null && (player.GoldCoins + bankBalance) >= 10000) + await AwardAchievementAsync(playerId, "gold_hoarder", "Gold Hoarder", "Accumulate 10,000 gold."); + } + + // Social Butterfly - Join a fellowship + if (!existingKeys.Contains("social_butterfly")) + { + var inFellowship = await _db.FellowshipMembers.AnyAsync(fm => fm.PlayerId == playerId); + if (inFellowship) + await AwardAchievementAsync(playerId, "social_butterfly", "Social Butterfly", "Join a fellowship."); + } + + // Veteran Duelist - Win 50 battles + if (!existingKeys.Contains("veteran")) + { + var wins = await _db.Battles.CountAsync(b => + b.WinnerId == playerId && b.Status == BattleStatus.Finished); + if (wins >= 50) + await AwardAchievementAsync(playerId, "veteran", "Veteran Duelist", "Win 50 battles."); + } + + // Grand Wizard - Reach Grand Wizard rank (ELO 1600+) + if (!existingKeys.Contains("grand_wizard")) + { + var player = await _db.Players.FindAsync(playerId); + if (player != null && player.EloRating >= 1600) + await AwardAchievementAsync(playerId, "grand_wizard", "Grand Wizard", "Reach Grand Wizard rank (ELO 1600+)."); + } + + await _db.SaveChangesAsync(); + } + + private async Task AwardAchievementAsync(Guid playerId, string key, string name, string description) + { + _db.Achievements.Add(new Achievement + { + PlayerId = playerId, + Key = key, + Name = name, + Description = description + }); + + await _notifications.CreateNotificationAsync(playerId, + "Achievement Unlocked!", + $"You earned the '{name}' achievement: {description}", + "achievement"); + } +} diff --git a/backend/WizardRPG.Api/Services/AuthService.cs b/backend/WizardRPG.Api/Services/AuthService.cs index 9b68019..1d95f3c 100644 --- a/backend/WizardRPG.Api/Services/AuthService.cs +++ b/backend/WizardRPG.Api/Services/AuthService.cs @@ -20,13 +20,20 @@ public interface IAuthService public class AuthService : IAuthService { + private static readonly HashSet ValidHouses = new(StringComparer.OrdinalIgnoreCase) + { + "Pyromancers", "Frostwardens", "Stormcallers", "Earthshapers" + }; + private readonly AppDbContext _db; private readonly IConfiguration _config; + private readonly INotificationService _notificationService; - public AuthService(AppDbContext db, IConfiguration config) + public AuthService(AppDbContext db, IConfiguration config, INotificationService notificationService) { _db = db; _config = config; + _notificationService = notificationService; } public async Task RegisterAsync(RegisterRequest request) @@ -37,6 +44,11 @@ public async Task RegisterAsync(RegisterRequest request) if (await _db.Players.AnyAsync(p => p.Username == request.Username)) throw new InvalidOperationException("Username already taken."); + // Validate and set house + var house = string.Empty; + if (!string.IsNullOrWhiteSpace(request.House) && ValidHouses.Contains(request.House)) + house = request.House; + var player = new Player { Id = Guid.NewGuid(), @@ -45,7 +57,8 @@ public async Task RegisterAsync(RegisterRequest request) PasswordHash = BCrypt.Net.BCrypt.HashPassword(request.Password), ReferralCode = GenerateReferralCode(), CreatedAt = DateTime.UtcNow, - GoldCoins = 100 + GoldCoins = 100, + House = house }; _db.Players.Add(player); @@ -53,19 +66,53 @@ public async Task RegisterAsync(RegisterRequest request) var bankAccount = new BankAccount { PlayerId = player.Id }; _db.BankAccounts.Add(bankAccount); - // Handle referral + // Handle referral code if (!string.IsNullOrWhiteSpace(request.ReferralCode)) { - var fellowship = await _db.Fellowships - .FirstOrDefaultAsync(f => f.ReferralCode == request.ReferralCode); - if (fellowship != null) + // Check player referral first + var referrer = await _db.Players + .FirstOrDefaultAsync(p => p.ReferralCode == request.ReferralCode); + if (referrer != null) + { + player.ReferredByPlayerId = referrer.Id; + player.GoldCoins += 50; // Bonus gold for referred player + referrer.GoldCoins += 100; // Bonus gold for referrer + referrer.ReferralCount++; + + // Award house points to referrer + if (!string.IsNullOrWhiteSpace(referrer.House)) + { + _db.HousePoints.Add(new HousePoints + { + PlayerId = referrer.Id, + House = referrer.House, + Points = 20, + Activity = "Referral" + }); + } + + // Save first so notification can reference valid player + await _db.SaveChangesAsync(); + + await _notificationService.CreateNotificationAsync(referrer.Id, + "New Referral!", + $"{player.Username} joined using your referral code! You earned 100 bonus gold.", + "referral"); + } + else { - _db.FellowshipMembers.Add(new FellowshipMember + // Check fellowship referral + var fellowship = await _db.Fellowships + .FirstOrDefaultAsync(f => f.ReferralCode == request.ReferralCode); + if (fellowship != null) { - FellowshipId = fellowship.Id, - PlayerId = player.Id, - ContributionPercent = 0 - }); + _db.FellowshipMembers.Add(new FellowshipMember + { + FellowshipId = fellowship.Id, + PlayerId = player.Id, + ContributionPercent = 0 + }); + } } } diff --git a/backend/WizardRPG.Api/Services/BattleService.cs b/backend/WizardRPG.Api/Services/BattleService.cs index b4623bb..2ecb3d9 100644 --- a/backend/WizardRPG.Api/Services/BattleService.cs +++ b/backend/WizardRPG.Api/Services/BattleService.cs @@ -19,11 +19,25 @@ public class BattleService : IBattleService { private readonly AppDbContext _db; private readonly ILLMNarratorService _narrator; - - public BattleService(AppDbContext db, ILLMNarratorService narrator) + private readonly IEquipmentService _equipmentService; + private readonly INotificationService _notificationService; + private readonly IQuestService _questService; + private readonly IAchievementService _achievementService; + + public BattleService( + AppDbContext db, + ILLMNarratorService narrator, + IEquipmentService equipmentService, + INotificationService notificationService, + IQuestService questService, + IAchievementService achievementService) { _db = db; _narrator = narrator; + _equipmentService = equipmentService; + _notificationService = notificationService; + _questService = questService; + _achievementService = achievementService; } public async Task> GetSpellsAsync() @@ -123,7 +137,7 @@ public async Task ExecuteTurnAsync(Guid battleId, Guid attackerI var spell = await _db.Spells.FindAsync(spellId) ?? throw new KeyNotFoundException("Spell not found."); - var damage = CalculateDamage(attacker, spell); + var damage = await CalculateDamageWithEquipmentAsync(attacker, spell); var narrative = await _narrator.GenerateTurnNarrativeAsync(attacker.Username, defender.Username, spell.Name, damage); var turn = new BattleTurn @@ -158,14 +172,57 @@ public async Task ExecuteTurnAsync(Guid battleId, Guid attackerI ? battle.ChallengerId : battle.DefenderId; - // Reward experience + var loserId = battle.WinnerId == battle.ChallengerId + ? battle.DefenderId + : battle.ChallengerId; + + // Reward experience and gold var winner = await _db.Players.FindAsync(battle.WinnerId); + var loser = await _db.Players.FindAsync(loserId); if (winner != null) { winner.Experience += 100; winner.GoldCoins += 50; } + // ELO rating changes + if (winner != null && loser != null) + { + var (winnerChange, loserChange) = CalculateEloChange(winner.EloRating, loser.EloRating); + winner.EloRating = Math.Max(0, winner.EloRating + winnerChange); + loser.EloRating = Math.Max(0, loser.EloRating + loserChange); + } + + // Award house points for PvP win + if (winner != null && !string.IsNullOrWhiteSpace(winner.House)) + { + _db.HousePoints.Add(new HousePoints + { + PlayerId = winner.Id, + House = winner.House, + Points = 10, + Activity = "PvP Win" + }); + } + + // Update quest progress + if (winner != null) + await _questService.UpdateQuestProgressAsync(winner.Id, "battle_win"); + + // Check achievements for both players + if (winner != null) + await _achievementService.CheckAndAwardAchievementsAsync(winner.Id); + if (loser != null) + await _achievementService.CheckAndAwardAchievementsAsync(loser.Id); + + // Notifications + if (winner != null) + await _notificationService.CreateNotificationAsync(winner.Id, + "Battle Won!", "You won a PvP battle and earned 50 gold and 100 XP!", "battle_result"); + if (loser != null) + await _notificationService.CreateNotificationAsync(loser.Id, + "Battle Lost", "You lost a PvP battle. Better luck next time!", "battle_result"); + battle.NarratorStory = await _narrator.GenerateBattleStoryAsync( battle.Turns.Select(t => t.Narrative ?? string.Empty).ToList()); } @@ -174,15 +231,23 @@ public async Task ExecuteTurnAsync(Guid battleId, Guid attackerI return await GetBattleAsync(battleId); } - private static int CalculateDamage(Player attacker, Spell spell) + private async Task CalculateDamageWithEquipmentAsync(Player attacker, Spell spell) + { + var (magicBonus, strengthBonus, wisdomBonus, speedBonus) = + await _equipmentService.GetEquipmentBonusesAsync(attacker.Id); + return CalculateDamageWithBonuses(attacker, spell, magicBonus, strengthBonus, wisdomBonus, speedBonus); + } + + private static int CalculateDamageWithBonuses(Player attacker, Spell spell, + int magicBonus, int strengthBonus, int wisdomBonus, int speedBonus) { var statBonus = spell.Element switch { - SpellElement.Fire or SpellElement.Arcane => attacker.MagicPower, - SpellElement.Ice => attacker.Wisdom, - SpellElement.Lightning => attacker.Speed, - SpellElement.Earth => attacker.Strength, - _ => attacker.MagicPower + SpellElement.Fire or SpellElement.Arcane => attacker.MagicPower + magicBonus, + SpellElement.Ice => attacker.Wisdom + wisdomBonus, + SpellElement.Lightning => attacker.Speed + speedBonus, + SpellElement.Earth => attacker.Strength + strengthBonus, + _ => attacker.MagicPower + magicBonus }; var damage = spell.BaseDamage + (statBonus / 5); @@ -190,6 +255,16 @@ private static int CalculateDamage(Player attacker, Spell spell) return Math.Max(1, damage + variance); } + private static (int winnerChange, int loserChange) CalculateEloChange(int winnerRating, int loserRating) + { + const int k = 32; + double expectedWinner = 1.0 / (1.0 + Math.Pow(10.0, (loserRating - winnerRating) / 400.0)); + double expectedLoser = 1.0 / (1.0 + Math.Pow(10.0, (winnerRating - loserRating) / 400.0)); + int winnerChange = (int)Math.Round(k * (1.0 - expectedWinner)); + int loserChange = (int)Math.Round(k * (0.0 - expectedLoser)); + return (winnerChange, loserChange); + } + private async Task LoadBattleAsync(Guid battleId) => await _db.Battles .Include(b => b.Challenger) diff --git a/backend/WizardRPG.Api/Services/EquipmentService.cs b/backend/WizardRPG.Api/Services/EquipmentService.cs new file mode 100644 index 0000000..3451220 --- /dev/null +++ b/backend/WizardRPG.Api/Services/EquipmentService.cs @@ -0,0 +1,152 @@ +using Microsoft.EntityFrameworkCore; +using WizardRPG.Api.Data; +using WizardRPG.Api.DTOs.Equipment; +using WizardRPG.Api.Models; + +namespace WizardRPG.Api.Services; + +public interface IEquipmentService +{ + Task GetEquipmentAsync(Guid playerId); + Task EquipItemAsync(Guid playerId, Guid bankItemId); + Task UnequipItemAsync(Guid playerId, string slot); + Task<(int MagicBonus, int StrengthBonus, int WisdomBonus, int SpeedBonus)> GetEquipmentBonusesAsync(Guid playerId); +} + +public class EquipmentService : IEquipmentService +{ + private readonly AppDbContext _db; + + public EquipmentService(AppDbContext db) => _db = db; + + public async Task GetEquipmentAsync(Guid playerId) + { + var player = await LoadPlayerWithEquipment(playerId); + return MapToSlots(player); + } + + public async Task EquipItemAsync(Guid playerId, Guid bankItemId) + { + var player = await LoadPlayerWithEquipment(playerId); + + var bankItem = await _db.BankItems + .Include(bi => bi.Item) + .FirstOrDefaultAsync(bi => bi.Id == bankItemId && bi.PlayerId == playerId) + ?? throw new KeyNotFoundException("Bank item not found or does not belong to you."); + + var item = bankItem.Item + ?? throw new InvalidOperationException("Item data not found."); + + switch (item.Type) + { + case ItemType.Wand: + player.EquippedWandId = bankItem.Id; + break; + case ItemType.Robe: + player.EquippedRobeId = bankItem.Id; + break; + case ItemType.Hat: + player.EquippedHatId = bankItem.Id; + break; + case ItemType.Amulet: + player.EquippedAmuletId = bankItem.Id; + break; + case ItemType.Broom: + player.EquippedBroomId = bankItem.Id; + break; + default: + throw new InvalidOperationException($"Item type '{item.Type}' cannot be equipped."); + } + + await _db.SaveChangesAsync(); + player = await LoadPlayerWithEquipment(playerId); + return MapToSlots(player); + } + + public async Task UnequipItemAsync(Guid playerId, string slot) + { + var player = await _db.Players.FindAsync(playerId) + ?? throw new KeyNotFoundException("Player not found."); + + switch (slot.ToLowerInvariant()) + { + case "wand": + player.EquippedWandId = null; + break; + case "robe": + player.EquippedRobeId = null; + break; + case "hat": + player.EquippedHatId = null; + break; + case "amulet": + player.EquippedAmuletId = null; + break; + case "broom": + player.EquippedBroomId = null; + break; + default: + throw new ArgumentException($"Invalid slot '{slot}'. Must be one of: wand, robe, hat, amulet, broom."); + } + + await _db.SaveChangesAsync(); + player = await LoadPlayerWithEquipment(playerId); + return MapToSlots(player); + } + + public async Task<(int MagicBonus, int StrengthBonus, int WisdomBonus, int SpeedBonus)> GetEquipmentBonusesAsync(Guid playerId) + { + var player = await LoadPlayerWithEquipment(playerId); + + int magic = 0, strength = 0, wisdom = 0, speed = 0; + + void AddBonuses(BankItem? bankItem) + { + if (bankItem?.Item == null) return; + magic += bankItem.Item.MagicBonus; + strength += bankItem.Item.StrengthBonus; + wisdom += bankItem.Item.WisdomBonus; + speed += bankItem.Item.SpeedBonus; + } + + AddBonuses(player.EquippedWand); + AddBonuses(player.EquippedRobe); + AddBonuses(player.EquippedHat); + AddBonuses(player.EquippedAmulet); + AddBonuses(player.EquippedBroom); + + return (magic, strength, wisdom, speed); + } + + private async Task LoadPlayerWithEquipment(Guid playerId) + { + return await _db.Players + .Include(p => p.EquippedWand).ThenInclude(bi => bi!.Item) + .Include(p => p.EquippedRobe).ThenInclude(bi => bi!.Item) + .Include(p => p.EquippedHat).ThenInclude(bi => bi!.Item) + .Include(p => p.EquippedAmulet).ThenInclude(bi => bi!.Item) + .Include(p => p.EquippedBroom).ThenInclude(bi => bi!.Item) + .FirstOrDefaultAsync(p => p.Id == playerId) + ?? throw new KeyNotFoundException("Player not found."); + } + + private static EquipmentSlots MapToSlots(Player player) + { + return new EquipmentSlots( + MapItem(player.EquippedWand), + MapItem(player.EquippedRobe), + MapItem(player.EquippedHat), + MapItem(player.EquippedAmulet), + MapItem(player.EquippedBroom)); + } + + private static EquippedItemResponse? MapItem(BankItem? bankItem) + { + if (bankItem?.Item == null) return null; + return new EquippedItemResponse( + bankItem.Id, bankItem.ItemId, bankItem.Item.Name, + bankItem.Item.Description, bankItem.Item.Type, + bankItem.Item.MagicBonus, bankItem.Item.StrengthBonus, + bankItem.Item.WisdomBonus, bankItem.Item.SpeedBonus); + } +} diff --git a/backend/WizardRPG.Api/Services/HouseService.cs b/backend/WizardRPG.Api/Services/HouseService.cs new file mode 100644 index 0000000..15a3982 --- /dev/null +++ b/backend/WizardRPG.Api/Services/HouseService.cs @@ -0,0 +1,122 @@ +using Microsoft.EntityFrameworkCore; +using WizardRPG.Api.Data; +using WizardRPG.Api.DTOs.House; +using WizardRPG.Api.DTOs.Player; +using WizardRPG.Api.Models; + +namespace WizardRPG.Api.Services; + +public interface IHouseService +{ + Task> GetHouseLeaderboardAsync(); + Task> GetHousePointsAsync(string house, int limit = 50); + Task AwardHousePointsAsync(Guid playerId, int points, string activity); + Task SelectHouseAsync(Guid playerId, string house); +} + +public class HouseService : IHouseService +{ + private static readonly HashSet ValidHouses = new(StringComparer.OrdinalIgnoreCase) + { + "Pyromancers", "Frostwardens", "Stormcallers", "Earthshapers" + }; + + private readonly AppDbContext _db; + + public HouseService(AppDbContext db) => _db = db; + + public async Task> GetHouseLeaderboardAsync() + { + var houseStats = await _db.HousePoints + .GroupBy(hp => hp.House) + .Select(g => new + { + House = g.Key, + TotalPoints = (long)g.Sum(hp => hp.Points) + }) + .ToListAsync(); + + var memberCounts = await _db.Players + .Where(p => !string.IsNullOrEmpty(p.House)) + .GroupBy(p => p.House) + .Select(g => new { House = g.Key, Count = g.Count() }) + .ToListAsync(); + + var memberCountDict = memberCounts.ToDictionary(m => m.House, m => m.Count, StringComparer.OrdinalIgnoreCase); + + return houseStats + .Select(h => new HouseLeaderboardEntry( + h.House, + h.TotalPoints, + memberCountDict.GetValueOrDefault(h.House, 0))) + .OrderByDescending(h => h.TotalPoints) + .ToList(); + } + + public async Task> GetHousePointsAsync(string house, int limit = 50) + { + var points = await _db.HousePoints + .Include(hp => hp.Player) + .Where(hp => hp.House == house) + .OrderByDescending(hp => hp.EarnedAt) + .Take(limit) + .ToListAsync(); + + return points.Select(hp => new HousePointsResponse( + hp.Id, hp.PlayerId, hp.Player?.Username ?? string.Empty, + hp.House, hp.Points, hp.Activity, hp.EarnedAt)).ToList(); + } + + public async Task AwardHousePointsAsync(Guid playerId, int points, string activity) + { + var player = await _db.Players.FindAsync(playerId) + ?? throw new KeyNotFoundException("Player not found."); + + if (string.IsNullOrWhiteSpace(player.House)) + throw new InvalidOperationException("Player has not joined a house."); + + var hp = new HousePoints + { + PlayerId = playerId, + House = player.House, + Points = points, + Activity = activity + }; + + _db.HousePoints.Add(hp); + await _db.SaveChangesAsync(); + + return new HousePointsResponse( + hp.Id, hp.PlayerId, player.Username, + hp.House, hp.Points, hp.Activity, hp.EarnedAt); + } + + public async Task SelectHouseAsync(Guid playerId, string house) + { + if (!ValidHouses.Contains(house)) + throw new ArgumentException($"Invalid house. Must be one of: {string.Join(", ", ValidHouses)}"); + + var player = await _db.Players.FindAsync(playerId) + ?? throw new KeyNotFoundException("Player not found."); + + player.House = house; + await _db.SaveChangesAsync(); + + var (tier, badge) = GetRankInfo(player.EloRating); + return new PlayerProfileResponse( + player.Id, player.Username, player.Email, player.GoldCoins, + player.Level, player.Experience, player.MagicPower, player.Strength, + player.Wisdom, player.Speed, player.ReferralCode, player.CreatedAt, + player.IsAdmin, player.EloRating, player.House, tier, badge, + player.HasCompletedOnboarding, player.LoginStreak); + } + + private static (string Tier, string Badge) GetRankInfo(int elo) => elo switch + { + >= 2000 => ("Archmage", "๐Ÿ”ฎ"), + >= 1600 => ("Master Wizard", "โญ"), + >= 1200 => ("Journeyman", "๐ŸŒŸ"), + >= 800 => ("Apprentice", "๐Ÿ“–"), + _ => ("Novice", "๐Ÿช„") + }; +} diff --git a/backend/WizardRPG.Api/Services/LoginRewardService.cs b/backend/WizardRPG.Api/Services/LoginRewardService.cs new file mode 100644 index 0000000..5f5a150 --- /dev/null +++ b/backend/WizardRPG.Api/Services/LoginRewardService.cs @@ -0,0 +1,90 @@ +using WizardRPG.Api.Data; +using WizardRPG.Api.DTOs.LoginReward; + +namespace WizardRPG.Api.Services; + +public interface ILoginRewardService +{ + Task ClaimDailyRewardAsync(Guid playerId); + Task GetLoginRewardStatusAsync(Guid playerId); +} + +public class LoginRewardService : ILoginRewardService +{ + private readonly AppDbContext _db; + private readonly INotificationService _notifications; + + public LoginRewardService(AppDbContext db, INotificationService notifications) + { + _db = db; + _notifications = notifications; + } + + public async Task GetLoginRewardStatusAsync(Guid playerId) + { + var player = await _db.Players.FindAsync(playerId) + ?? throw new KeyNotFoundException("Player not found."); + + var canClaim = player.LastLoginRewardDate == null || + player.LastLoginRewardDate.Value.Date < DateTime.UtcNow.Date; + + return new LoginRewardStatus(player.LoginStreak, canClaim, player.LastLoginRewardDate); + } + + public async Task ClaimDailyRewardAsync(Guid playerId) + { + var player = await _db.Players.FindAsync(playerId) + ?? throw new KeyNotFoundException("Player not found."); + + var today = DateTime.UtcNow.Date; + + if (player.LastLoginRewardDate != null && player.LastLoginRewardDate.Value.Date >= today) + throw new InvalidOperationException("Daily reward already claimed today."); + + // Update streak + if (player.LastLoginRewardDate != null && player.LastLoginRewardDate.Value.Date == today.AddDays(-1)) + { + player.LoginStreak++; + } + else + { + player.LoginStreak = 1; + } + + player.LastLoginRewardDate = DateTime.UtcNow; + player.LastLoginDate = DateTime.UtcNow; + + var day = player.LoginStreak; + var (goldReward, itemReward) = GetRewardForDay(day); + + player.GoldCoins += goldReward; + + await _db.SaveChangesAsync(); + + var rewardMessage = goldReward > 0 ? $"{goldReward} gold" : ""; + if (itemReward != null) + rewardMessage = string.IsNullOrEmpty(rewardMessage) ? itemReward : $"{rewardMessage} + {itemReward}"; + + await _notifications.CreateNotificationAsync(playerId, + "Daily Reward!", + $"Day {day} reward: {rewardMessage}", + "login_reward"); + + return new LoginRewardResponse(day, goldReward, itemReward, player.LoginStreak); + } + + private static (long Gold, string? Item) GetRewardForDay(int day) + { + return day switch + { + 1 => (10, null), + 2 => (20, null), + 3 => (50, null), + 5 => (0, "Mystery Wand"), + 7 => (200, null), + 14 => (0, "Exclusive Enchanted Robe"), + 30 => (500, "Legendary Phoenix Feather Wand"), + _ => ((long)day * 10, null) + }; + } +} diff --git a/backend/WizardRPG.Api/Services/NotificationService.cs b/backend/WizardRPG.Api/Services/NotificationService.cs new file mode 100644 index 0000000..829d220 --- /dev/null +++ b/backend/WizardRPG.Api/Services/NotificationService.cs @@ -0,0 +1,76 @@ +using Microsoft.EntityFrameworkCore; +using WizardRPG.Api.Data; +using WizardRPG.Api.DTOs.Notification; +using WizardRPG.Api.Models; + +namespace WizardRPG.Api.Services; + +public interface INotificationService +{ + Task> GetPlayerNotificationsAsync(Guid playerId, int limit = 20); + Task GetUnreadCountAsync(Guid playerId); + Task MarkAsReadAsync(Guid playerId, Guid notificationId); + Task MarkAllAsReadAsync(Guid playerId); + Task CreateNotificationAsync(Guid playerId, string title, string message, string type); +} + +public class NotificationService : INotificationService +{ + private readonly AppDbContext _db; + + public NotificationService(AppDbContext db) => _db = db; + + public async Task> GetPlayerNotificationsAsync(Guid playerId, int limit = 20) + { + var notifications = await _db.Notifications + .Where(n => n.PlayerId == playerId) + .OrderByDescending(n => n.CreatedAt) + .Take(limit) + .ToListAsync(); + + return notifications.Select(n => new NotificationResponse( + n.Id, n.Title, n.Message, n.Type, n.IsRead, n.CreatedAt)).ToList(); + } + + public async Task GetUnreadCountAsync(Guid playerId) + { + return await _db.Notifications + .CountAsync(n => n.PlayerId == playerId && !n.IsRead); + } + + public async Task MarkAsReadAsync(Guid playerId, Guid notificationId) + { + var notification = await _db.Notifications + .FirstOrDefaultAsync(n => n.Id == notificationId && n.PlayerId == playerId) + ?? throw new KeyNotFoundException("Notification not found."); + + notification.IsRead = true; + await _db.SaveChangesAsync(); + } + + public async Task MarkAllAsReadAsync(Guid playerId) + { + var unread = await _db.Notifications + .Where(n => n.PlayerId == playerId && !n.IsRead) + .ToListAsync(); + + foreach (var n in unread) + n.IsRead = true; + + await _db.SaveChangesAsync(); + } + + public async Task CreateNotificationAsync(Guid playerId, string title, string message, string type) + { + var notification = new Notification + { + PlayerId = playerId, + Title = title, + Message = message, + Type = type + }; + + _db.Notifications.Add(notification); + await _db.SaveChangesAsync(); + } +} diff --git a/backend/WizardRPG.Api/Services/PlayerService.cs b/backend/WizardRPG.Api/Services/PlayerService.cs index 8b8b437..703310c 100644 --- a/backend/WizardRPG.Api/Services/PlayerService.cs +++ b/backend/WizardRPG.Api/Services/PlayerService.cs @@ -11,6 +11,7 @@ public interface IPlayerService Task UpdateProfileAsync(Guid playerId, UpdateProfileRequest request); Task AddExperienceAsync(Guid playerId, long amount); Task> GetLeaderboardAsync(int top = 10); + Task GetBattleStatsAsync(Guid playerId); } public class PlayerService : IPlayerService @@ -82,8 +83,80 @@ public async Task> GetLeaderboardAsync(int top = 10) return players.Select(MapToResponse).ToList(); } - private static PlayerProfileResponse MapToResponse(Player p) => new( - p.Id, p.Username, p.Email, p.GoldCoins, p.Level, - p.Experience, p.MagicPower, p.Strength, p.Wisdom, - p.Speed, p.ReferralCode, p.CreatedAt, p.IsAdmin); + public async Task GetBattleStatsAsync(Guid playerId) + { + _ = await _db.Players.FindAsync(playerId) + ?? throw new KeyNotFoundException("Player not found."); + + var battles = await _db.Battles + .Where(b => (b.ChallengerId == playerId || b.DefenderId == playerId) + && b.Status == BattleStatus.Finished) + .OrderByDescending(b => b.FinishedAt) + .ToListAsync(); + + var wins = battles.Count(b => b.WinnerId == playerId); + var losses = battles.Count - wins; + var winRate = battles.Count > 0 ? (double)wins / battles.Count : 0.0; + + var turns = await _db.BattleTurns + .Include(t => t.Spell) + .Where(t => t.Battle!.ChallengerId == playerId || t.Battle!.DefenderId == playerId) + .Where(t => t.Battle!.Status == BattleStatus.Finished) + .ToListAsync(); + + long totalDamageDealt = turns.Where(t => t.AttackerId == playerId).Sum(t => (long)t.DamageDealt); + long totalDamageReceived = turns.Where(t => t.AttackerId != playerId).Sum(t => (long)t.DamageDealt); + + var mostUsedSpell = turns + .Where(t => t.AttackerId == playerId && t.Spell != null) + .GroupBy(t => t.Spell!.Name) + .OrderByDescending(g => g.Count()) + .Select(g => g.Key) + .FirstOrDefault(); + + // Calculate win streaks in a single pass (battles ordered most recent first) + int currentStreak = 0; + int bestStreak = 0; + int tempStreak = 0; + bool countingCurrent = true; + foreach (var b in battles) + { + if (b.WinnerId == playerId) + { + tempStreak++; + if (countingCurrent) currentStreak++; + if (tempStreak > bestStreak) bestStreak = tempStreak; + } + else + { + countingCurrent = false; + tempStreak = 0; + } + } + + return new BattleStatsResponse( + battles.Count, wins, losses, Math.Round(winRate, 4), + totalDamageDealt, totalDamageReceived, + mostUsedSpell, currentStreak, bestStreak); + } + + private static PlayerProfileResponse MapToResponse(Player p) + { + var (tier, badge) = GetRankInfo(p.EloRating); + return new( + p.Id, p.Username, p.Email, p.GoldCoins, p.Level, + p.Experience, p.MagicPower, p.Strength, p.Wisdom, + p.Speed, p.ReferralCode, p.CreatedAt, p.IsAdmin, + p.EloRating, p.House, tier, badge, + p.HasCompletedOnboarding, p.LoginStreak); + } + + private static (string Tier, string Badge) GetRankInfo(int elo) => elo switch + { + >= 2000 => ("Archmage", "๐Ÿ”ฎ"), + >= 1600 => ("Master Wizard", "โญ"), + >= 1200 => ("Journeyman", "๐ŸŒŸ"), + >= 800 => ("Apprentice", "๐Ÿ“–"), + _ => ("Novice", "๐Ÿช„") + }; } diff --git a/backend/WizardRPG.Api/Services/QuestService.cs b/backend/WizardRPG.Api/Services/QuestService.cs new file mode 100644 index 0000000..1564792 --- /dev/null +++ b/backend/WizardRPG.Api/Services/QuestService.cs @@ -0,0 +1,180 @@ +using Microsoft.EntityFrameworkCore; +using WizardRPG.Api.Data; +using WizardRPG.Api.DTOs.Quest; +using WizardRPG.Api.Models; + +namespace WizardRPG.Api.Services; + +public interface IQuestService +{ + Task> GetPlayerQuestsAsync(Guid playerId); + Task GenerateDailyQuestsAsync(Guid playerId); + Task GenerateWeeklyQuestsAsync(Guid playerId); + Task UpdateQuestProgressAsync(Guid playerId, string activityType, int count = 1); +} + +public class QuestService : IQuestService +{ + private readonly AppDbContext _db; + private readonly INotificationService _notifications; + + public QuestService(AppDbContext db, INotificationService notifications) + { + _db = db; + _notifications = notifications; + } + + public async Task> GetPlayerQuestsAsync(Guid playerId) + { + // Expire stale quests + var now = DateTime.UtcNow; + var expired = await _db.Quests + .Where(q => q.PlayerId == playerId && q.Status == QuestStatus.Active && q.ExpiresAt <= now) + .ToListAsync(); + + foreach (var q in expired) + q.Status = QuestStatus.Expired; + + if (expired.Count > 0) + await _db.SaveChangesAsync(); + + var quests = await _db.Quests + .Where(q => q.PlayerId == playerId) + .OrderByDescending(q => q.CreatedAt) + .ToListAsync(); + + return quests.Select(q => new QuestResponse( + q.Id, q.Title, q.Description, q.Type, q.Status, + q.TargetCount, q.CurrentCount, q.GoldReward, q.XpReward, q.ExpiresAt)).ToList(); + } + + public async Task GenerateDailyQuestsAsync(Guid playerId) + { + var now = DateTime.UtcNow; + var hasActive = await _db.Quests.AnyAsync(q => + q.PlayerId == playerId && q.Type == QuestType.Daily && q.Status == QuestStatus.Active && q.ExpiresAt > now); + + if (hasActive) return; + + var dailyQuests = new List + { + new() + { + PlayerId = playerId, + Title = "Win 2 battles", + Description = "Win 2 PvP battles today", + Type = QuestType.Daily, + TargetCount = 2, + GoldReward = 50, + XpReward = 25, + ExpiresAt = now.AddHours(24) + }, + new() + { + PlayerId = playerId, + Title = "Place a broom race bet", + Description = "Place a bet on any broom race", + Type = QuestType.Daily, + TargetCount = 1, + GoldReward = 20, + XpReward = 10, + ExpiresAt = now.AddHours(24) + }, + new() + { + PlayerId = playerId, + Title = "Deposit 100 gold in the bank", + Description = "Deposit at least 100 gold into your bank account", + Type = QuestType.Daily, + TargetCount = 1, + GoldReward = 30, + XpReward = 15, + ExpiresAt = now.AddHours(24) + } + }; + + _db.Quests.AddRange(dailyQuests); + await _db.SaveChangesAsync(); + } + + public async Task GenerateWeeklyQuestsAsync(Guid playerId) + { + var now = DateTime.UtcNow; + var hasActive = await _db.Quests.AnyAsync(q => + q.PlayerId == playerId && q.Type == QuestType.Weekly && q.Status == QuestStatus.Active && q.ExpiresAt > now); + + if (hasActive) return; + + var weeklyQuests = new List + { + new() + { + PlayerId = playerId, + Title = "Win 10 battles", + Description = "Win 10 PvP battles this week", + Type = QuestType.Weekly, + TargetCount = 10, + GoldReward = 300, + XpReward = 150, + ExpiresAt = now.AddDays(7) + }, + new() + { + PlayerId = playerId, + Title = "Earn 500 gold", + Description = "Accumulate 500 gold from any source this week", + Type = QuestType.Weekly, + TargetCount = 500, + GoldReward = 200, + XpReward = 100, + ExpiresAt = now.AddDays(7) + } + }; + + _db.Quests.AddRange(weeklyQuests); + await _db.SaveChangesAsync(); + } + + public async Task UpdateQuestProgressAsync(Guid playerId, string activityType, int count = 1) + { + var now = DateTime.UtcNow; + var activeQuests = await _db.Quests + .Where(q => q.PlayerId == playerId && q.Status == QuestStatus.Active && q.ExpiresAt > now) + .ToListAsync(); + + foreach (var quest in activeQuests) + { + bool matches = activityType switch + { + "battle_win" => quest.Title.Contains("battle", StringComparison.OrdinalIgnoreCase), + "broom_bet" => quest.Title.Contains("broom", StringComparison.OrdinalIgnoreCase), + "bank_deposit" => quest.Title.Contains("Deposit", StringComparison.OrdinalIgnoreCase), + "gold_earned" => quest.Title.Contains("Earn", StringComparison.OrdinalIgnoreCase), + _ => false + }; + + if (!matches) continue; + + quest.CurrentCount += count; + + if (quest.CurrentCount >= quest.TargetCount) + { + quest.Status = QuestStatus.Completed; + + var player = await _db.Players.FindAsync(playerId); + if (player != null) + { + player.GoldCoins += quest.GoldReward; + player.Experience += quest.XpReward; + } + + await _notifications.CreateNotificationAsync(playerId, + "Quest Completed!", + $"You completed '{quest.Title}' and earned {quest.GoldReward} gold and {quest.XpReward} XP!", + "quest_complete"); + } + } + + await _db.SaveChangesAsync(); + } +} diff --git a/backend/WizardRPG.Tests.Integration/Controllers/AuthControllerTests.cs b/backend/WizardRPG.Tests.Integration/Controllers/AuthControllerTests.cs index c7a8183..6429b57 100644 --- a/backend/WizardRPG.Tests.Integration/Controllers/AuthControllerTests.cs +++ b/backend/WizardRPG.Tests.Integration/Controllers/AuthControllerTests.cs @@ -43,7 +43,7 @@ public AuthControllerTests(WebApplicationFactory factory) public async Task Register_ValidRequest_ReturnsOkWithToken() { var client = _factory.CreateClient(); - var request = new RegisterRequest("TestWizard", "wizard@test.com", "Password123!", null); + var request = new RegisterRequest("TestWizard", "wizard@test.com", "Password123!", null, null); var response = await client.PostAsJsonAsync("/api/auth/register", request); @@ -59,11 +59,11 @@ public async Task Register_ValidRequest_ReturnsOkWithToken() public async Task Register_DuplicateEmail_ReturnsBadRequest() { var client = _factory.CreateClient(); - var request = new RegisterRequest("Wizard1", "dup@test.com", "Password123!", null); + var request = new RegisterRequest("Wizard1", "dup@test.com", "Password123!", null, null); await client.PostAsJsonAsync("/api/auth/register", request); var secondResponse = await client.PostAsJsonAsync("/api/auth/register", - new RegisterRequest("Wizard2", "dup@test.com", "Password123!", null)); + new RegisterRequest("Wizard2", "dup@test.com", "Password123!", null, null)); Assert.Equal(HttpStatusCode.BadRequest, secondResponse.StatusCode); } @@ -72,7 +72,7 @@ public async Task Register_DuplicateEmail_ReturnsBadRequest() public async Task Login_ValidCredentials_ReturnsOkWithToken() { var client = _factory.CreateClient(); - var regRequest = new RegisterRequest("LoginTestWizard", "login@test.com", "Password123!", null); + var regRequest = new RegisterRequest("LoginTestWizard", "login@test.com", "Password123!", null, null); await client.PostAsJsonAsync("/api/auth/register", regRequest); var loginRequest = new LoginRequest("login@test.com", "Password123!"); @@ -88,7 +88,7 @@ public async Task Login_ValidCredentials_ReturnsOkWithToken() public async Task Login_InvalidPassword_ReturnsUnauthorized() { var client = _factory.CreateClient(); - var regRequest = new RegisterRequest("BadPassWizard", "badpass@test.com", "CorrectPassword!", null); + var regRequest = new RegisterRequest("BadPassWizard", "badpass@test.com", "CorrectPassword!", null, null); await client.PostAsJsonAsync("/api/auth/register", regRequest); var loginRequest = new LoginRequest("badpass@test.com", "WrongPassword!"); @@ -101,7 +101,7 @@ public async Task Login_InvalidPassword_ReturnsUnauthorized() public async Task Refresh_ValidToken_ReturnsNewToken() { var client = _factory.CreateClient(); - var regRequest = new RegisterRequest("RefreshWizard", "refresh@test.com", "Password123!", null); + var regRequest = new RegisterRequest("RefreshWizard", "refresh@test.com", "Password123!", null, null); var regResponse = await client.PostAsJsonAsync("/api/auth/register", regRequest); var authResult = await regResponse.Content.ReadFromJsonAsync(); diff --git a/backend/WizardRPG.Tests.Integration/Controllers/PlayerControllerTests.cs b/backend/WizardRPG.Tests.Integration/Controllers/PlayerControllerTests.cs index 117e6ba..596f1e9 100644 --- a/backend/WizardRPG.Tests.Integration/Controllers/PlayerControllerTests.cs +++ b/backend/WizardRPG.Tests.Integration/Controllers/PlayerControllerTests.cs @@ -45,7 +45,7 @@ public PlayerControllerTests(WebApplicationFactory factory) string password = "Password123!") { var client = _factory.CreateClient(); - var regRequest = new RegisterRequest(username, email, password, null); + var regRequest = new RegisterRequest(username, email, password, null, null); var regResponse = await client.PostAsJsonAsync("/api/auth/register", regRequest); var auth = await regResponse.Content.ReadFromJsonAsync(); client.DefaultRequestHeaders.Authorization = diff --git a/backend/WizardRPG.Tests.Unit/Services/BattleServiceTests.cs b/backend/WizardRPG.Tests.Unit/Services/BattleServiceTests.cs index d9a0a65..781006f 100644 --- a/backend/WizardRPG.Tests.Unit/Services/BattleServiceTests.cs +++ b/backend/WizardRPG.Tests.Unit/Services/BattleServiceTests.cs @@ -53,6 +53,20 @@ private static Mock CreateNarratorMock() return mock; } + private static BattleService CreateService(AppDbContext db) + { + var narratorMock = CreateNarratorMock(); + var equipmentMock = new Mock(); + equipmentMock.Setup(e => e.GetEquipmentBonusesAsync(It.IsAny())) + .ReturnsAsync((0, 0, 0, 0)); + var notificationMock = new Mock(); + var questMock = new Mock(); + var achievementMock = new Mock(); + + return new BattleService(db, narratorMock.Object, equipmentMock.Object, + notificationMock.Object, questMock.Object, achievementMock.Object); + } + [Fact] public async Task ChallengeBattleAsync_CreatesPendingBattle() { @@ -62,7 +76,7 @@ public async Task ChallengeBattleAsync_CreatesPendingBattle() db.Players.AddRange(challenger, defender); await db.SaveChangesAsync(); - var service = new BattleService(db, CreateNarratorMock().Object); + var service = CreateService(db); var result = await service.ChallengeBattleAsync(challenger.Id, defender.Id); Assert.Equal(BattleStatus.Pending, result.Status); @@ -78,7 +92,7 @@ public async Task ChallengeBattleAsync_SamePlayer_ThrowsInvalidOperationExceptio db.Players.Add(player); await db.SaveChangesAsync(); - var service = new BattleService(db, CreateNarratorMock().Object); + var service = CreateService(db); await Assert.ThrowsAsync( () => service.ChallengeBattleAsync(player.Id, player.Id)); @@ -101,7 +115,7 @@ public async Task AcceptBattleAsync_SetsStatusToActive() db.Battles.Add(battle); await db.SaveChangesAsync(); - var service = new BattleService(db, CreateNarratorMock().Object); + var service = CreateService(db); var result = await service.AcceptBattleAsync(battle.Id, defender.Id); Assert.Equal(BattleStatus.Active, result.Status); @@ -126,7 +140,7 @@ public async Task ExecuteTurnAsync_FirstTurn_ChallengerAttacks() db.Battles.Add(battle); await db.SaveChangesAsync(); - var service = new BattleService(db, CreateNarratorMock().Object); + var service = CreateService(db); var result = await service.ExecuteTurnAsync(battle.Id, challenger.Id, spell.Id); Assert.Single(result.Turns); @@ -152,7 +166,7 @@ public async Task ExecuteTurnAsync_WrongPlayer_ThrowsInvalidOperationException() db.Battles.Add(battle); await db.SaveChangesAsync(); - var service = new BattleService(db, CreateNarratorMock().Object); + var service = CreateService(db); // Defender tries to attack first (should be challenger's turn) await Assert.ThrowsAsync( @@ -178,7 +192,7 @@ public async Task ExecuteTurnAsync_TenTurns_BattleFinishes() db.Battles.Add(battle); await db.SaveChangesAsync(); - var service = new BattleService(db, CreateNarratorMock().Object); + var service = CreateService(db); BattleResponse? result = null; for (var i = 0; i < 10; i++) diff --git a/frontend/src/api/achievement.ts b/frontend/src/api/achievement.ts new file mode 100644 index 0000000..78c52aa --- /dev/null +++ b/frontend/src/api/achievement.ts @@ -0,0 +1,10 @@ +import api from './client' +import type { AchievementResponse } from '../types' + +export const achievementApi = { + getMyAchievements: () => + api.get('/achievement'), + + checkAchievements: () => + api.post('/achievement/check'), +} diff --git a/frontend/src/api/equipment.ts b/frontend/src/api/equipment.ts new file mode 100644 index 0000000..6160190 --- /dev/null +++ b/frontend/src/api/equipment.ts @@ -0,0 +1,13 @@ +import api from './client' +import type { EquipmentSlots, EquipItemRequest, UnequipItemRequest } from '../types' + +export const equipmentApi = { + getEquipment: () => + api.get('/equipment'), + + equipItem: (data: EquipItemRequest) => + api.post('/equipment/equip', data), + + unequipItem: (data: UnequipItemRequest) => + api.post('/equipment/unequip', data), +} diff --git a/frontend/src/api/house.ts b/frontend/src/api/house.ts new file mode 100644 index 0000000..cd613d9 --- /dev/null +++ b/frontend/src/api/house.ts @@ -0,0 +1,13 @@ +import api from './client' +import type { HouseLeaderboardEntry, HousePointsResponse, SelectHouseRequest } from '../types' + +export const houseApi = { + getLeaderboard: () => + api.get('/house/leaderboard'), + + getHousePoints: (house: string) => + api.get(`/house/${house}/points`), + + selectHouse: (data: SelectHouseRequest) => + api.post('/house/select', data), +} diff --git a/frontend/src/api/loginReward.ts b/frontend/src/api/loginReward.ts new file mode 100644 index 0000000..95ce8af --- /dev/null +++ b/frontend/src/api/loginReward.ts @@ -0,0 +1,10 @@ +import api from './client' +import type { LoginRewardResponse, LoginRewardStatus } from '../types' + +export const loginRewardApi = { + getStatus: () => + api.get('/login-reward/status'), + + claimReward: () => + api.post('/login-reward/claim'), +} diff --git a/frontend/src/api/notification.ts b/frontend/src/api/notification.ts new file mode 100644 index 0000000..db59e1a --- /dev/null +++ b/frontend/src/api/notification.ts @@ -0,0 +1,16 @@ +import api from './client' +import type { NotificationResponse } from '../types' + +export const notificationApi = { + getNotifications: () => + api.get('/notification'), + + getUnreadCount: () => + api.get('/notification/unread-count'), + + markAsRead: (id: string) => + api.post(`/notification/${id}/read`), + + markAllAsRead: () => + api.post('/notification/read-all'), +} diff --git a/frontend/src/api/player.ts b/frontend/src/api/player.ts index f20a4eb..4a35bce 100644 --- a/frontend/src/api/player.ts +++ b/frontend/src/api/player.ts @@ -2,6 +2,7 @@ import api from './client' import type { PlayerProfileResponse, UpdateProfileRequest, + BattleStatsResponse, } from '../types' export const playerApi = { @@ -16,4 +17,7 @@ export const playerApi = { getLeaderboard: (top = 10) => api.get('/player/leaderboard', { params: { top } }), + + getBattleStats: (playerId: string) => + api.get(`/player/battle-stats/${playerId}`), } diff --git a/frontend/src/api/quest.ts b/frontend/src/api/quest.ts new file mode 100644 index 0000000..1ee583b --- /dev/null +++ b/frontend/src/api/quest.ts @@ -0,0 +1,13 @@ +import api from './client' +import type { QuestResponse } from '../types' + +export const questApi = { + getMyQuests: () => + api.get('/quest'), + + generateDaily: () => + api.post('/quest/generate-daily'), + + generateWeekly: () => + api.post('/quest/generate-weekly'), +} diff --git a/frontend/src/stores/equipment.ts b/frontend/src/stores/equipment.ts new file mode 100644 index 0000000..7250afc --- /dev/null +++ b/frontend/src/stores/equipment.ts @@ -0,0 +1,45 @@ +import { defineStore } from 'pinia' +import { ref } from 'vue' +import { equipmentApi } from '../api/equipment' +import type { EquipmentSlots } from '../types' + +export const useEquipmentStore = defineStore('equipment', () => { + const equipment = ref(null) + const loading = ref(false) + const error = ref('') + + async function fetchEquipment() { + loading.value = true + error.value = '' + try { + const res = await equipmentApi.getEquipment() + equipment.value = res.data + } catch { + error.value = 'Failed to load equipment' + } finally { + loading.value = false + } + } + + async function equipItem(bankItemId: string) { + error.value = '' + try { + const res = await equipmentApi.equipItem({ bankItemId }) + equipment.value = res.data + } catch { + error.value = 'Failed to equip item' + } + } + + async function unequipItem(slot: string) { + error.value = '' + try { + const res = await equipmentApi.unequipItem({ slot }) + equipment.value = res.data + } catch { + error.value = 'Failed to unequip item' + } + } + + return { equipment, loading, error, fetchEquipment, equipItem, unequipItem } +}) diff --git a/frontend/src/stores/house.ts b/frontend/src/stores/house.ts new file mode 100644 index 0000000..8191e24 --- /dev/null +++ b/frontend/src/stores/house.ts @@ -0,0 +1,25 @@ +import { defineStore } from 'pinia' +import { ref } from 'vue' +import { houseApi } from '../api/house' +import type { HouseLeaderboardEntry } from '../types' + +export const useHouseStore = defineStore('house', () => { + const leaderboard = ref([]) + const loading = ref(false) + const error = ref('') + + async function fetchLeaderboard() { + loading.value = true + error.value = '' + try { + const res = await houseApi.getLeaderboard() + leaderboard.value = res.data + } catch { + error.value = 'Failed to load house leaderboard' + } finally { + loading.value = false + } + } + + return { leaderboard, loading, error, fetchLeaderboard } +}) diff --git a/frontend/src/stores/notification.ts b/frontend/src/stores/notification.ts new file mode 100644 index 0000000..27d1ff6 --- /dev/null +++ b/frontend/src/stores/notification.ts @@ -0,0 +1,50 @@ +import { defineStore } from 'pinia' +import { ref } from 'vue' +import { notificationApi } from '../api/notification' +import type { NotificationResponse } from '../types' + +export const useNotificationStore = defineStore('notification', () => { + const notifications = ref([]) + const unreadCount = ref(0) + const loading = ref(false) + const error = ref('') + + async function fetchNotifications() { + loading.value = true + error.value = '' + try { + const res = await notificationApi.getNotifications() + notifications.value = res.data + } catch { + error.value = 'Failed to load notifications' + } finally { + loading.value = false + } + } + + async function fetchUnreadCount() { + try { + const res = await notificationApi.getUnreadCount() + unreadCount.value = res.data + } catch { + /* silent */ + } + } + + async function markAsRead(id: string) { + await notificationApi.markAsRead(id) + const n = notifications.value.find(n => n.id === id) + if (n) { + n.isRead = true + unreadCount.value = Math.max(0, unreadCount.value - 1) + } + } + + async function markAllAsRead() { + await notificationApi.markAllAsRead() + notifications.value.forEach(n => n.isRead = true) + unreadCount.value = 0 + } + + return { notifications, unreadCount, loading, error, fetchNotifications, fetchUnreadCount, markAsRead, markAllAsRead } +}) diff --git a/frontend/src/stores/quest.ts b/frontend/src/stores/quest.ts new file mode 100644 index 0000000..39d840b --- /dev/null +++ b/frontend/src/stores/quest.ts @@ -0,0 +1,43 @@ +import { defineStore } from 'pinia' +import { ref } from 'vue' +import { questApi } from '../api/quest' +import type { QuestResponse } from '../types' + +export const useQuestStore = defineStore('quest', () => { + const quests = ref([]) + const loading = ref(false) + const error = ref('') + + async function fetchQuests() { + loading.value = true + error.value = '' + try { + const res = await questApi.getMyQuests() + quests.value = res.data + } catch { + error.value = 'Failed to load quests' + } finally { + loading.value = false + } + } + + async function generateDaily() { + try { + await questApi.generateDaily() + await fetchQuests() + } catch { + error.value = 'Failed to generate daily quests' + } + } + + async function generateWeekly() { + try { + await questApi.generateWeekly() + await fetchQuests() + } catch { + error.value = 'Failed to generate weekly quests' + } + } + + return { quests, loading, error, fetchQuests, generateDaily, generateWeekly } +}) diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index c726801..14d0693 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -47,6 +47,7 @@ export interface RegisterRequest { email: string password: string referralCode?: string + house?: string } export interface LoginRequest { @@ -88,6 +89,12 @@ export interface PlayerProfileResponse { referralCode: string createdAt: string isAdmin: boolean + eloRating: number + house: string + rankTier: string + rankBadge: string + hasCompletedOnboarding: boolean + loginStreak: number } export interface UpdateProfileRequest { @@ -298,3 +305,133 @@ export interface CreateTeamRequest { export interface ResolveLeagueRequest { winnerTeamId: string } + +/* โ”€โ”€ Quest โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ + +export const QuestType = { + Daily: 0, + Weekly: 1, +} as const +export type QuestType = (typeof QuestType)[keyof typeof QuestType] + +export const QuestStatus = { + Active: 0, + Completed: 1, + Expired: 2, +} as const +export type QuestStatus = (typeof QuestStatus)[keyof typeof QuestStatus] + +export interface QuestResponse { + id: string + title: string + description: string + type: QuestType + status: QuestStatus + targetCount: number + currentCount: number + goldReward: number + xpReward: number + expiresAt: string +} + +/* โ”€โ”€ Achievement โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ + +export interface AchievementResponse { + id: string + key: string + name: string + description: string + unlockedAt: string +} + +/* โ”€โ”€ Notification โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ + +export interface NotificationResponse { + id: string + title: string + message: string + type: string + isRead: boolean + createdAt: string +} + +/* โ”€โ”€ Equipment โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ + +export interface EquippedItemResponse { + bankItemId: string + itemId: string + name: string + description: string + type: ItemType + magicBonus: number + strengthBonus: number + wisdomBonus: number + speedBonus: number +} + +export interface EquipmentSlots { + wand: EquippedItemResponse | null + robe: EquippedItemResponse | null + hat: EquippedItemResponse | null + amulet: EquippedItemResponse | null + broom: EquippedItemResponse | null +} + +export interface EquipItemRequest { + bankItemId: string +} + +export interface UnequipItemRequest { + slot: string +} + +/* โ”€โ”€ House โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ + +export interface HouseLeaderboardEntry { + house: string + totalPoints: number + memberCount: number +} + +export interface HousePointsResponse { + id: string + playerId: string + playerUsername: string + house: string + points: number + activity: string + earnedAt: string +} + +export interface SelectHouseRequest { + house: string +} + +/* โ”€โ”€ Login Reward โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ + +export interface LoginRewardResponse { + day: number + goldReward: number + itemReward: string | null + loginStreak: number +} + +export interface LoginRewardStatus { + loginStreak: number + canClaimToday: boolean + lastClaimDate: string | null +} + +/* โ”€โ”€ Battle Stats โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ + +export interface BattleStatsResponse { + totalBattles: number + wins: number + losses: number + winRate: number + totalDamageDealt: number + totalDamageReceived: number + mostUsedSpell: string | null + currentWinStreak: number + bestWinStreak: number +} diff --git a/frontend/src/views/DashboardView.vue b/frontend/src/views/DashboardView.vue index da6e70f..a2872ec 100644 --- a/frontend/src/views/DashboardView.vue +++ b/frontend/src/views/DashboardView.vue @@ -1,16 +1,47 @@