diff --git a/LctMonolith/Application/Extensions/ServiceCollectionExtensions.cs b/LctMonolith/Application/Extensions/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..6a1e9c7 --- /dev/null +++ b/LctMonolith/Application/Extensions/ServiceCollectionExtensions.cs @@ -0,0 +1,41 @@ +using LctMonolith.Application.Options; +using LctMonolith.Database.UnitOfWork; +using LctMonolith.Services; +using LctMonolith.Services.Contracts; +using LctMonolith.Services.Interfaces; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +namespace LctMonolith.Application.Extensions; + +public static class ServiceCollectionExtensions +{ + public static IServiceCollection AddApplicationServices(this IServiceCollection services, IConfiguration configuration) + { + // Unit of Work + services.AddScoped(); + + // Core domain / gamification services + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + services.Configure(configuration.GetSection("S3")); + services.AddSingleton(); + services.AddScoped(); + + return services; + } +} diff --git a/LctMonolith/Controllers/DialogueController.cs b/LctMonolith/Controllers/DialogueController.cs new file mode 100644 index 0000000..264666b --- /dev/null +++ b/LctMonolith/Controllers/DialogueController.cs @@ -0,0 +1,75 @@ +using LctMonolith.Models.Database; +using LctMonolith.Services.Interfaces; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace LctMonolith.Controllers; + +[ApiController] +[Route("api/dialogue")] +[Authorize] +public class DialogueController : ControllerBase +{ + private readonly IDialogueService _dialogueService; + + public DialogueController(IDialogueService dialogueService) + { + _dialogueService = dialogueService; + } + + [HttpGet("mission/{missionId:guid}")] + public async Task GetByMission(Guid missionId) + { + var d = await _dialogueService.GetDialogueByMissionIdAsync(missionId); + return d == null ? NotFound() : Ok(d); + } + + [HttpGet("message/{messageId:guid}")] + public async Task GetMessage(Guid messageId) + { + var m = await _dialogueService.GetDialogueMessageByIdAsync(messageId); + return m == null ? NotFound() : Ok(m); + } + + [HttpGet("message/{messageId:guid}/options")] + public async Task GetOptions(Guid messageId) + { + var opts = await _dialogueService.GetResponseOptionsAsync(messageId); + return Ok(opts); + } + + public record DialogueResponseRequest(Guid ResponseOptionId, Guid PlayerId); + + [HttpPost("message/{messageId:guid}/respond")] + public async Task Respond(Guid messageId, DialogueResponseRequest req) + { + var next = await _dialogueService.ProcessDialogueResponseAsync(messageId, req.ResponseOptionId, req.PlayerId); + if (next == null) return Ok(new { end = true }); + return Ok(next); + } + + public class CreateDialogueRequest + { + public Guid MissionId { get; set; } + public Guid InitialDialogueMessageId { get; set; } + public Guid InterimDialogueMessageId { get; set; } + public Guid EndDialogueMessageId { get; set; } + } + + [HttpPost] + [Authorize(Roles = "Admin")] + public async Task Create(CreateDialogueRequest dto) + { + var d = new Dialogue + { + Id = Guid.NewGuid(), + MissionId = dto.MissionId, + InitialDialogueMessageId = dto.InitialDialogueMessageId, + InterimDialogueMessageId = dto.InterimDialogueMessageId, + EndDialogueMessageId = dto.EndDialogueMessageId, + Mission = null! // EF will populate if included + }; + d = await _dialogueService.CreateDialogueAsync(d); + return CreatedAtAction(nameof(GetByMission), new { missionId = d.MissionId }, d); + } +} diff --git a/LctMonolith/Controllers/InventoryController.cs b/LctMonolith/Controllers/InventoryController.cs new file mode 100644 index 0000000..56840ba --- /dev/null +++ b/LctMonolith/Controllers/InventoryController.cs @@ -0,0 +1,34 @@ +using System.Security.Claims; +using LctMonolith.Services.Contracts; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace LctMonolith.Controllers; + +[ApiController] +[Route("api/inventory")] +[Authorize] +public class InventoryController : ControllerBase +{ + private readonly IInventoryService _inventoryService; + public InventoryController(IInventoryService inventoryService) => _inventoryService = inventoryService; + + private Guid CurrentUserId() => Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!); + + /// Get inventory for current authenticated user. + [HttpGet] + public async Task GetMine(CancellationToken ct) + { + var items = await _inventoryService.GetStoreInventoryAsync(CurrentUserId(), ct); + return Ok(items.Select(i => new { i.StoreItemId, i.Quantity, i.AcquiredAt })); + } + + /// Admin: get inventory for specific user. + [HttpGet("user/{userId:guid}")] + [Authorize(Roles = "Admin")] + public async Task GetByUser(Guid userId, CancellationToken ct) + { + var items = await _inventoryService.GetStoreInventoryAsync(userId, ct); + return Ok(items); + } +} diff --git a/LctMonolith/Controllers/MissionCategoriesController.cs b/LctMonolith/Controllers/MissionCategoriesController.cs new file mode 100644 index 0000000..4279446 --- /dev/null +++ b/LctMonolith/Controllers/MissionCategoriesController.cs @@ -0,0 +1,58 @@ +using LctMonolith.Models.Database; +using LctMonolith.Models.DTO; +using LctMonolith.Services.Interfaces; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace LctMonolith.Controllers; + +[ApiController] +[Route("api/mission-categories")] +[Authorize] +public class MissionCategoriesController : ControllerBase +{ + private readonly IMissionCategoryService _service; + public MissionCategoriesController(IMissionCategoryService service) => _service = service; + + [HttpGet] + public async Task GetAll() + { + var list = await _service.GetAllCategoriesAsync(); + return Ok(list); + } + + [HttpGet("{id:guid}")] + public async Task Get(Guid id) + { + var c = await _service.GetCategoryByIdAsync(id); + return c == null ? NotFound() : Ok(c); + } + + [HttpPost] + [Authorize(Roles = "Admin")] + public async Task Create(CreateMissionCategoryDto dto) + { + var c = await _service.CreateCategoryAsync(new MissionCategory { Title = dto.Title }); + return CreatedAtAction(nameof(Get), new { id = c.Id }, c); + } + + [HttpPut("{id:guid}")] + [Authorize(Roles = "Admin")] + public async Task Update(Guid id, CreateMissionCategoryDto dto) + { + var c = await _service.GetCategoryByIdAsync(id); + if (c == null) return NotFound(); + c.Title = dto.Title; + await _service.UpdateCategoryAsync(c); + return Ok(c); + } + + [HttpDelete("{id:guid}")] + [Authorize(Roles = "Admin")] + public async Task Delete(Guid id) + { + var ok = await _service.DeleteCategoryAsync(id); + return ok ? NoContent() : NotFound(); + } +} + diff --git a/LctMonolith/Controllers/MissionsController.cs b/LctMonolith/Controllers/MissionsController.cs new file mode 100644 index 0000000..3eaeef6 --- /dev/null +++ b/LctMonolith/Controllers/MissionsController.cs @@ -0,0 +1,112 @@ +using LctMonolith.Models.Database; +using LctMonolith.Models.DTO; +using LctMonolith.Services.Interfaces; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace LctMonolith.Controllers; + +[ApiController] +[Route("api/missions")] +[Authorize] +public class MissionsController : ControllerBase +{ + private readonly IMissionService _missions; + private readonly IRuleValidationService _rules; + + public MissionsController(IMissionService missions, IRuleValidationService rules) + { + _missions = missions; + _rules = rules; + } + + [HttpGet("{id:guid}")] + public async Task Get(Guid id) + { + var m = await _missions.GetMissionByIdAsync(id); + return m == null ? NotFound() : Ok(m); + } + + [HttpGet("category/{categoryId:guid}")] + public async Task ByCategory(Guid categoryId) + { + var list = await _missions.GetMissionsByCategoryAsync(categoryId); + return Ok(list); + } + + [HttpGet("player/{playerId:guid}/available")] + public async Task Available(Guid playerId) + { + var list = await _missions.GetAvailableMissionsForPlayerAsync(playerId); + return Ok(list); + } + + [HttpGet("{id:guid}/rank-rules")] + public async Task RankRules(Guid id) + { + var rules = await _rules.GetApplicableRankRulesAsync(id); + return Ok(rules); + } + + public class CreateMissionRequest + { + public string Title { get; set; } = string.Empty; + public string? Description { get; set; } + public Guid MissionCategoryId { get; set; } + public Guid? ParentMissionId { get; set; } + public int ExpReward { get; set; } + public int ManaReward { get; set; } + } + + [HttpPost] + [Authorize(Roles = "Admin")] + public async Task Create(CreateMissionRequest dto) + { + var mission = new Mission + { + Title = dto.Title, + Description = dto.Description ?? string.Empty, + MissionCategoryId = dto.MissionCategoryId, + ParentMissionId = dto.ParentMissionId, + ExpReward = dto.ExpReward, + ManaReward = dto.ManaReward + }; + mission = await _missions.CreateMissionAsync(mission); + return CreatedAtAction(nameof(Get), new { id = mission.Id }, mission); + } + + [HttpPut("{id:guid}")] + [Authorize(Roles = "Admin")] + public async Task Update(Guid id, CreateMissionRequest dto) + { + var existing = await _missions.GetMissionByIdAsync(id); + if (existing == null) return NotFound(); + existing.Title = dto.Title; + existing.Description = dto.Description ?? string.Empty; + existing.MissionCategoryId = dto.MissionCategoryId; + existing.ParentMissionId = dto.ParentMissionId; + existing.ExpReward = dto.ExpReward; + existing.ManaReward = dto.ManaReward; + await _missions.UpdateMissionAsync(existing); + return Ok(existing); + } + + [HttpDelete("{id:guid}")] + [Authorize(Roles = "Admin")] + public async Task Delete(Guid id) + { + var ok = await _missions.DeleteMissionAsync(id); + return ok ? NoContent() : NotFound(); + } + + public record CompleteMissionRequest(Guid PlayerId, object? Proof); + + [HttpPost("{missionId:guid}/complete")] + public async Task Complete(Guid missionId, CompleteMissionRequest r) + { + var result = await _missions.CompleteMissionAsync(missionId, r.PlayerId, r.Proof); + if (!result.Success) return BadRequest(result); + return Ok(result); + } +} + diff --git a/LctMonolith/Controllers/PlayersController.cs b/LctMonolith/Controllers/PlayersController.cs new file mode 100644 index 0000000..2bc19cb --- /dev/null +++ b/LctMonolith/Controllers/PlayersController.cs @@ -0,0 +1,78 @@ +using LctMonolith.Services.Interfaces; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace LctMonolith.Controllers; + +[ApiController] +[Route("api/players")] +[Authorize] +public class PlayersController : ControllerBase +{ + private readonly IPlayerService _playerService; + private readonly IProgressTrackingService _progressService; + + public PlayersController(IPlayerService playerService, IProgressTrackingService progressService) + { + _playerService = playerService; + _progressService = progressService; + } + + [HttpGet("{playerId:guid}")] + public async Task GetPlayer(Guid playerId) + { + var player = await _playerService.GetPlayerWithProgressAsync(playerId); + return Ok(player); + } + + [HttpGet("user/{userId:guid}")] + public async Task GetByUser(Guid userId) + { + var p = await _playerService.GetPlayerByUserIdAsync(userId.ToString()); + if (p == null) return NotFound(); + return Ok(p); + } + + public class CreatePlayerRequest { public Guid UserId { get; set; } public string Username { get; set; } = string.Empty; } + + [HttpPost] + [Authorize(Roles = "Admin")] + public async Task Create(CreatePlayerRequest req) + { + var p = await _playerService.CreatePlayerAsync(req.UserId.ToString(), req.Username); + return CreatedAtAction(nameof(GetPlayer), new { playerId = p.Id }, p); + } + + public record AdjustValueRequest(int Value); + + [HttpPost("{playerId:guid}/experience")] + [Authorize(Roles = "Admin")] // manual adjust + public async Task AddExperience(Guid playerId, AdjustValueRequest r) + { + var p = await _playerService.AddPlayerExperienceAsync(playerId, r.Value); + return Ok(new { p.Id, p.Experience }); + } + + [HttpPost("{playerId:guid}/mana")] + [Authorize(Roles = "Admin")] // manual adjust + public async Task AddMana(Guid playerId, AdjustValueRequest r) + { + var p = await _playerService.AddPlayerManaAsync(playerId, r.Value); + return Ok(new { p.Id, p.Mana }); + } + + [HttpGet("top")] + public async Task GetTop([FromQuery] int count = 10) + { + var list = await _playerService.GetTopPlayersAsync(count, TimeSpan.FromDays(30)); + return Ok(list); + } + + [HttpGet("{playerId:guid}/progress")] + public async Task GetProgress(Guid playerId) + { + var prog = await _progressService.GetPlayerOverallProgressAsync(playerId); + return Ok(prog); + } +} + diff --git a/LctMonolith/Controllers/RanksController.cs b/LctMonolith/Controllers/RanksController.cs new file mode 100644 index 0000000..3c1b98b --- /dev/null +++ b/LctMonolith/Controllers/RanksController.cs @@ -0,0 +1,72 @@ +using LctMonolith.Models.Database; +using LctMonolith.Models.DTO; +using LctMonolith.Services.Interfaces; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace LctMonolith.Controllers; + +[ApiController] +[Route("api/ranks")] +[Authorize] +public class RanksController : ControllerBase +{ + private readonly IRankService _rankService; + private readonly IRuleValidationService _ruleValidation; + + public RanksController(IRankService rankService, IRuleValidationService ruleValidation) + { + _rankService = rankService; + _ruleValidation = ruleValidation; + } + + [HttpGet] + public async Task GetAll() + { + var ranks = await _rankService.GetAllRanksAsync(); + return Ok(ranks); + } + + [HttpGet("{id:guid}")] + public async Task Get(Guid id) + { + var r = await _rankService.GetRankByIdAsync(id); + return r == null ? NotFound() : Ok(r); + } + + [HttpPost] + [Authorize(Roles = "Admin")] + public async Task Create(CreateRankDto dto) + { + var rank = await _rankService.CreateRankAsync(new Rank { Title = dto.Title, ExpNeeded = dto.ExpNeeded }); + return CreatedAtAction(nameof(Get), new { id = rank.Id }, rank); + } + + [HttpPut("{id:guid}")] + [Authorize(Roles = "Admin")] + public async Task Update(Guid id, CreateRankDto dto) + { + var r = await _rankService.GetRankByIdAsync(id); + if (r == null) return NotFound(); + r.Title = dto.Title; + r.ExpNeeded = dto.ExpNeeded; + await _rankService.UpdateRankAsync(r); + return Ok(r); + } + + [HttpDelete("{id:guid}")] + [Authorize(Roles = "Admin")] + public async Task Delete(Guid id) + { + var ok = await _rankService.DeleteRankAsync(id); + return ok ? NoContent() : NotFound(); + } + + [HttpGet("validate-advance/{playerId:guid}/{targetRankId:guid}")] + public async Task CanAdvance(Guid playerId, Guid targetRankId) + { + var ok = await _ruleValidation.ValidateRankAdvancementRulesAsync(playerId, targetRankId); + return Ok(new { playerId, targetRankId, canAdvance = ok }); + } +} + diff --git a/LctMonolith/Controllers/RewardController.cs b/LctMonolith/Controllers/RewardController.cs new file mode 100644 index 0000000..c608793 --- /dev/null +++ b/LctMonolith/Controllers/RewardController.cs @@ -0,0 +1,65 @@ +using LctMonolith.Services.Interfaces; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace LctMonolith.Controllers; + +[ApiController] +[Route("api/rewards")] +[Authorize] +public class RewardController : ControllerBase +{ + private readonly IRewardService _rewardService; + + public RewardController(IRewardService rewardService) + { + _rewardService = rewardService; + } + + /// List skill rewards configured for a mission. + [HttpGet("mission/{missionId:guid}/skills")] + public async Task GetMissionSkillRewards(Guid missionId) + { + var rewards = await _rewardService.GetMissionSkillRewardsAsync(missionId); + return Ok(rewards.Select(r => new { r.SkillId, r.Value })); + } + + /// List item rewards configured for a mission. + [HttpGet("mission/{missionId:guid}/items")] + public async Task GetMissionItemRewards(Guid missionId) + { + var rewards = await _rewardService.GetMissionItemRewardsAsync(missionId); + return Ok(rewards.Select(r => new { r.ItemId })); + } + + /// Check if mission rewards can be claimed by player (missionId used as rewardId). + [HttpGet("mission/{missionId:guid}/can-claim/{playerId:guid}")] + public async Task CanClaim(Guid missionId, Guid playerId) + { + var can = await _rewardService.CanClaimRewardAsync(missionId, playerId); + return Ok(new { missionId, playerId, canClaim = can }); + } + + public record ClaimRewardRequest(Guid PlayerId); + + /// Claim mission rewards if available (idempotent on already claimed). + [HttpPost("mission/{missionId:guid}/claim")] + public async Task Claim(Guid missionId, ClaimRewardRequest req) + { + var can = await _rewardService.CanClaimRewardAsync(missionId, req.PlayerId); + if (!can) return Conflict(new { message = "Rewards already claimed or mission not completed" }); + await _rewardService.DistributeMissionRewardsAsync(missionId, req.PlayerId); + return Ok(new { missionId, req.PlayerId, status = "claimed" }); + } + + public record ForceDistributeRequest(Guid PlayerId); + + /// Admin: force distribute rewards regardless of previous state (may duplicate). Use cautiously. + [HttpPost("mission/{missionId:guid}/force-distribute")] + [Authorize(Roles = "Admin")] + public async Task ForceDistribute(Guid missionId, ForceDistributeRequest req) + { + await _rewardService.DistributeMissionRewardsAsync(missionId, req.PlayerId); + return Ok(new { missionId, req.PlayerId, status = "forced" }); + } +} diff --git a/LctMonolith/Controllers/SkillsController.cs b/LctMonolith/Controllers/SkillsController.cs new file mode 100644 index 0000000..c269a15 --- /dev/null +++ b/LctMonolith/Controllers/SkillsController.cs @@ -0,0 +1,79 @@ +using LctMonolith.Models.Database; +using LctMonolith.Models.DTO; +using LctMonolith.Services.Interfaces; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace LctMonolith.Controllers; + +[ApiController] +[Route("api/skills")] +[Authorize] +public class SkillsController : ControllerBase +{ + private readonly ISkillService _skillService; + + public SkillsController(ISkillService skillService) + { + _skillService = skillService; + } + + [HttpGet] + public async Task GetAll() + { + var list = await _skillService.GetAllSkillsAsync(); + return Ok(list); + } + + [HttpGet("{id:guid}")] + public async Task Get(Guid id) + { + var s = await _skillService.GetSkillByIdAsync(id); + return s == null ? NotFound() : Ok(s); + } + + [HttpPost] + [Authorize(Roles = "Admin")] + public async Task Create(CreateSkillDto dto) + { + var skill = await _skillService.CreateSkillAsync(new Skill { Title = dto.Title }); + return CreatedAtAction(nameof(Get), new { id = skill.Id }, skill); + } + + [HttpPut("{id:guid}")] + [Authorize(Roles = "Admin")] + public async Task Update(Guid id, CreateSkillDto dto) + { + var s = await _skillService.GetSkillByIdAsync(id); + if (s == null) return NotFound(); + s.Title = dto.Title; + await _skillService.UpdateSkillAsync(s); + return Ok(s); + } + + [HttpDelete("{id:guid}")] + [Authorize(Roles = "Admin")] + public async Task Delete(Guid id) + { + var ok = await _skillService.DeleteSkillAsync(id); + return ok ? NoContent() : NotFound(); + } + + [HttpGet("player/{playerId:guid}")] + public async Task PlayerSkills(Guid playerId) + { + var list = await _skillService.GetPlayerSkillsAsync(playerId); + return Ok(list); + } + + public record UpdatePlayerSkillRequest(int Level); + + [HttpPost("player/{playerId:guid}/{skillId:guid}")] + [Authorize(Roles = "Admin")] + public async Task UpdatePlayerSkill(Guid playerId, Guid skillId, UpdatePlayerSkillRequest r) + { + var ps = await _skillService.UpdatePlayerSkillAsync(playerId, skillId, r.Level); + return Ok(new { ps.PlayerId, ps.SkillId, ps.Score }); + } +} + diff --git a/LctMonolith/Database/Data/AppDbContext.cs b/LctMonolith/Database/Data/AppDbContext.cs index 9e27cab..3c5df27 100644 --- a/LctMonolith/Database/Data/AppDbContext.cs +++ b/LctMonolith/Database/Data/AppDbContext.cs @@ -45,10 +45,24 @@ public class AppDbContext : IdentityDbContext, Guid> public DbSet RefreshTokens => Set(); public DbSet Notifications => Set(); + // Core profile / player chain + public DbSet Players => Set(); + public DbSet Profiles => Set(); + protected override void OnModelCreating(ModelBuilder b) { base.OnModelCreating(b); + // Player configuration + b.Entity() + .HasIndex(p => p.UserId) + .IsUnique(); + b.Entity() + .HasOne() + .WithOne() + .HasForeignKey(p => p.UserId) + .IsRequired(); + // Rank configurations b.Entity() .HasIndex(r => r.ExpNeeded) @@ -201,7 +215,6 @@ public class AppDbContext : IdentityDbContext, Guid> b.Entity().HasIndex(x => x.Token).IsUnique(); // ---------- Performance indexes ---------- - b.Entity().HasIndex(u => u.RankId); b.Entity().HasIndex(ps => ps.SkillId); b.Entity().HasIndex(e => new { e.UserId, e.Type, e.CreatedAt }); b.Entity().HasIndex(i => i.IsActive); diff --git a/LctMonolith/Database/UnitOfWork/IUnitOfWork.cs b/LctMonolith/Database/UnitOfWork/IUnitOfWork.cs index 8e9ef92..657c7ca 100644 --- a/LctMonolith/Database/UnitOfWork/IUnitOfWork.cs +++ b/LctMonolith/Database/UnitOfWork/IUnitOfWork.cs @@ -10,6 +10,8 @@ namespace LctMonolith.Database.UnitOfWork; public interface IUnitOfWork { IGenericRepository Users { get; } + IGenericRepository Players { get; } + IGenericRepository MissionCategories { get; } // added IGenericRepository Ranks { get; } IGenericRepository RankMissionRules { get; } IGenericRepository RankSkillRules { get; } @@ -24,6 +26,12 @@ public interface IUnitOfWork IGenericRepository EventLogs { get; } IGenericRepository RefreshTokens { get; } IGenericRepository Notifications { get; } + IGenericRepository MissionItemRewards { get; } // added + IGenericRepository MissionRankRules { get; } // added + IGenericRepository Dialogues { get; } + IGenericRepository DialogueMessages { get; } + IGenericRepository DialogueMessageResponseOptions { get; } + IGenericRepository Profiles { get; } Task SaveChangesAsync(CancellationToken ct = default); Task BeginTransactionAsync(CancellationToken ct = default); diff --git a/LctMonolith/Database/UnitOfWork/UnitOfWork.cs b/LctMonolith/Database/UnitOfWork/UnitOfWork.cs index 6f8638e..affcfa9 100644 --- a/LctMonolith/Database/UnitOfWork/UnitOfWork.cs +++ b/LctMonolith/Database/UnitOfWork/UnitOfWork.cs @@ -1,4 +1,3 @@ - using LctMonolith.Database.Data; using LctMonolith.Database.Repositories; using LctMonolith.Models.Database; @@ -35,6 +34,14 @@ public class UnitOfWork : IUnitOfWork, IAsyncDisposable private IGenericRepository? _eventLogs; private IGenericRepository? _refreshTokens; private IGenericRepository? _notifications; + private IGenericRepository? _players; + private IGenericRepository? _missionCategories; + private IGenericRepository? _missionItemRewards; + private IGenericRepository? _missionRankRules; + private IGenericRepository? _dialogues; + private IGenericRepository? _dialogueMessages; + private IGenericRepository? _dialogueMessageResponseOptions; + private IGenericRepository? _profiles; public IGenericRepository Users => _users ??= new GenericRepository(_ctx); public IGenericRepository Ranks => _ranks ??= new GenericRepository(_ctx); @@ -51,6 +58,14 @@ public class UnitOfWork : IUnitOfWork, IAsyncDisposable public IGenericRepository EventLogs => _eventLogs ??= new GenericRepository(_ctx); public IGenericRepository RefreshTokens => _refreshTokens ??= new GenericRepository(_ctx); public IGenericRepository Notifications => _notifications ??= new GenericRepository(_ctx); + public IGenericRepository Players => _players ??= new GenericRepository(_ctx); + public IGenericRepository MissionCategories => _missionCategories ??= new GenericRepository(_ctx); + public IGenericRepository MissionItemRewards => _missionItemRewards ??= new GenericRepository(_ctx); + public IGenericRepository MissionRankRules => _missionRankRules ??= new GenericRepository(_ctx); + public IGenericRepository Dialogues => _dialogues ??= new GenericRepository(_ctx); + public IGenericRepository DialogueMessages => _dialogueMessages ??= new GenericRepository(_ctx); + public IGenericRepository DialogueMessageResponseOptions => _dialogueMessageResponseOptions ??= new GenericRepository(_ctx); + public IGenericRepository Profiles => _profiles ??= new GenericRepository(_ctx); public Task SaveChangesAsync(CancellationToken ct = default) => _ctx.SaveChangesAsync(ct); diff --git a/LctMonolith/Models/DTO/CreateMissionCategoryDto.cs b/LctMonolith/Models/DTO/CreateMissionCategoryDto.cs new file mode 100644 index 0000000..90bbf23 --- /dev/null +++ b/LctMonolith/Models/DTO/CreateMissionCategoryDto.cs @@ -0,0 +1,3 @@ +namespace LctMonolith.Models.DTO; +public class CreateMissionCategoryDto { public string Title { get; set; } = string.Empty; } + diff --git a/LctMonolith/Models/DTO/CreateMissionDto.cs b/LctMonolith/Models/DTO/CreateMissionDto.cs new file mode 100644 index 0000000..b016e5f --- /dev/null +++ b/LctMonolith/Models/DTO/CreateMissionDto.cs @@ -0,0 +1,12 @@ +namespace LctMonolith.Models.DTO; + +public class CreateMissionDto +{ + public string Title { get; set; } = string.Empty; + public string? Description { get; set; } + public Guid MissionCategoryId { get; set; } + public Guid? ParentMissionId { get; set; } + public int ExpReward { get; set; } + public int ManaReward { get; set; } +} + diff --git a/LctMonolith/Models/DTO/CreateRankDto.cs b/LctMonolith/Models/DTO/CreateRankDto.cs new file mode 100644 index 0000000..4ce35ab --- /dev/null +++ b/LctMonolith/Models/DTO/CreateRankDto.cs @@ -0,0 +1,3 @@ +namespace LctMonolith.Models.DTO; +public class CreateRankDto { public string Title { get; set; } = string.Empty; public int ExpNeeded { get; set; } } + diff --git a/LctMonolith/Models/DTO/CreateSkillDto.cs b/LctMonolith/Models/DTO/CreateSkillDto.cs new file mode 100644 index 0000000..43d7edb --- /dev/null +++ b/LctMonolith/Models/DTO/CreateSkillDto.cs @@ -0,0 +1,3 @@ +namespace LctMonolith.Models.DTO; +public class CreateSkillDto { public string Title { get; set; } = string.Empty; } + diff --git a/LctMonolith/Models/Database/Mission.cs b/LctMonolith/Models/Database/Mission.cs index f275597..47997dd 100644 --- a/LctMonolith/Models/Database/Mission.cs +++ b/LctMonolith/Models/Database/Mission.cs @@ -6,15 +6,14 @@ public class Mission public string Title { get; set; } = string.Empty; public string Description { get; set; } = string.Empty; public MissionCategory? MissionCategory { get; set; } - public long MissionCategoryId { get; set; } + public Guid MissionCategoryId { get; set; } // changed from long public Mission? ParentMission { get; set; } - public long ParentMissionId { get; set; } + public Guid? ParentMissionId { get; set; } // changed from long to nullable Guid public int ExpReward { get; set; } public int ManaReward { get; set; } - public Guid DialogueId { get; set; } public Dialogue? Dialogue { get; set; } - + public ICollection ChildMissions { get; set; } = new List(); public ICollection PlayerMissions { get; set; } = new List(); public ICollection MissionItemRewards { get; set; } = new List(); diff --git a/LctMonolith/Models/Database/MissionSkillReward.cs b/LctMonolith/Models/Database/MissionSkillReward.cs index ecd02ee..d09fa66 100644 --- a/LctMonolith/Models/Database/MissionSkillReward.cs +++ b/LctMonolith/Models/Database/MissionSkillReward.cs @@ -5,7 +5,7 @@ public class MissionSkillReward public Guid Id { get; set; } public Guid MissionId { get; set; } public Mission Mission { get; set; } = null!; - public long SkillId { get; set; } + public Guid SkillId { get; set; } // changed from long public Skill Skill { get; set; } = null!; public int Value { get; set; } } diff --git a/LctMonolith/Models/Database/Player.cs b/LctMonolith/Models/Database/Player.cs index 72e58ba..0ee1744 100644 --- a/LctMonolith/Models/Database/Player.cs +++ b/LctMonolith/Models/Database/Player.cs @@ -3,10 +3,11 @@ namespace LctMonolith.Models.Database; public class Player { public Guid Id { get; set; } - public Guid UserId { get; set; } + public Guid UserId { get; set; } // 1:1 to AppUser (retain linkage) + public Guid RankId { get; set; } public Rank? Rank { get; set; } - public Guid RankId {get; set; } public int Experience { get; set; } + public int Mana { get; set; } public ICollection PlayerMissions { get; set; } = new List(); public ICollection PlayerSkills { get; set; } = new List(); diff --git a/LctMonolith/Models/Database/PlayerMission.cs b/LctMonolith/Models/Database/PlayerMission.cs index a79f4a5..c153416 100644 --- a/LctMonolith/Models/Database/PlayerMission.cs +++ b/LctMonolith/Models/Database/PlayerMission.cs @@ -4,10 +4,11 @@ public class PlayerMission { public Guid Id { get; set; } public Guid PlayerId { get; set; } - public required Player Player { get; set; } + public Player Player { get; set; } = null!; // removed required public Guid MissionId { get; set; } - public required Mission Mission { get; set; } + public Mission Mission { get; set; } = null!; // removed required public DateTime? Started { get; set; } public DateTime? Completed { get; set; } public DateTime? RewardsRedeemed { get; set; } + public int ProgressPercent { get; set; } // 0..100 } diff --git a/LctMonolith/Program.cs b/LctMonolith/Program.cs index 7aa941a..a96c0d0 100644 --- a/LctMonolith/Program.cs +++ b/LctMonolith/Program.cs @@ -12,6 +12,7 @@ using LctMonolith.Application.Options; using LctMonolith.Database.Data; using LctMonolith.Database.UnitOfWork; using LctMonolith.Services.Contracts; // Added for JwtOptions +using LctMonolith.Application.Extensions; // added var builder = WebApplication.CreateBuilder(args); @@ -90,14 +91,14 @@ builder.Services.AddOpenApi(); // Health checks builder.Services.AddHealthChecks(); -// UnitOfWork -builder.Services.AddScoped(); +// Remove individual service registrations and replace with extension +// builder.Services.AddScoped(); +// builder.Services.AddScoped(); +// builder.Services.AddScoped(); +// builder.Services.AddScoped(); +// builder.Services.AddScoped(); -// Domain services -builder.Services.AddScoped(); -builder.Services.AddScoped(); -builder.Services.AddScoped(); -builder.Services.AddScoped(); +builder.Services.AddApplicationServices(builder.Configuration); // CORS builder.Services.AddCors(p => p.AddDefaultPolicy(policy => diff --git a/LctMonolith/Services/AnalyticsService.cs b/LctMonolith/Services/AnalyticsService.cs index 0f7a161..1cd8a33 100644 --- a/LctMonolith/Services/AnalyticsService.cs +++ b/LctMonolith/Services/AnalyticsService.cs @@ -2,6 +2,7 @@ using LctMonolith.Database.UnitOfWork; using LctMonolith.Models.Database; using LctMonolith.Services.Models; using Microsoft.EntityFrameworkCore; +using Serilog; namespace LctMonolith.Services; @@ -15,16 +16,26 @@ public class AnalyticsService : IAnalyticsService public async Task GetSummaryAsync(CancellationToken ct = default) { - var totalUsers = await _uow.Users.Query().CountAsync(ct); - var totalMissions = await _uow.Missions.Query().CountAsync(ct); - var totalStoreItems = await _uow.StoreItems.Query().CountAsync(ct); - var totalExperience = await _uow.Users.Query().SumAsync(u => (long)u.Experience, ct); - return new AnalyticsSummary + try { - TotalUsers = totalUsers, - TotalMissions = totalMissions, - TotalStoreItems = totalStoreItems, - TotalExperience = totalExperience - }; + var totalUsers = await _uow.Users.Query().CountAsync(ct); + var totalMissions = await _uow.Missions.Query().CountAsync(ct); + var totalStoreItems = await _uow.StoreItems.Query().CountAsync(ct); + var totalExperience = await _uow.Players.Query().SumAsync(p => (long)p.Experience, ct); + var completedMissions = await _uow.PlayerMissions.Query(pm => pm.Completed != null).CountAsync(ct); + return new AnalyticsSummary + { + TotalUsers = totalUsers, + TotalMissions = totalMissions, + TotalStoreItems = totalStoreItems, + TotalExperience = totalExperience, + CompletedMissions = completedMissions + }; + } + catch (Exception ex) + { + Log.Error(ex, "Failed to build analytics summary"); + throw; + } } } diff --git a/LctMonolith/Services/DialogueService.cs b/LctMonolith/Services/DialogueService.cs new file mode 100644 index 0000000..20171ab --- /dev/null +++ b/LctMonolith/Services/DialogueService.cs @@ -0,0 +1,55 @@ +using LctMonolith.Database.UnitOfWork; +using LctMonolith.Models.Database; +using LctMonolith.Services.Interfaces; +using Microsoft.EntityFrameworkCore; +using Serilog; + +namespace LctMonolith.Services; + +public class DialogueService : IDialogueService +{ + private readonly IUnitOfWork _uow; + public DialogueService(IUnitOfWork uow) => _uow = uow; + + public async Task GetDialogueByMissionIdAsync(Guid missionId) + { + try { return await _uow.Dialogues.Query(d => d.MissionId == missionId).FirstOrDefaultAsync(); } + catch (Exception ex) { Log.Error(ex, "GetDialogueByMissionIdAsync failed {MissionId}", missionId); throw; } + } + + public async Task CreateDialogueAsync(Dialogue dialogue) + { + try + { + dialogue.Id = Guid.NewGuid(); + await _uow.Dialogues.AddAsync(dialogue); + await _uow.SaveChangesAsync(); + return dialogue; + } + catch (Exception ex) { Log.Error(ex, "CreateDialogueAsync failed {MissionId}", dialogue.MissionId); throw; } + } + + public async Task GetDialogueMessageByIdAsync(Guid messageId) + { + try { return await _uow.DialogueMessages.GetByIdAsync(messageId); } + catch (Exception ex) { Log.Error(ex, "GetDialogueMessageByIdAsync failed {MessageId}", messageId); throw; } + } + + public async Task> GetResponseOptionsAsync(Guid messageId) + { + try { return await _uow.DialogueMessageResponseOptions.Query(o => o.ParentDialogueMessageId == messageId).ToListAsync(); } + catch (Exception ex) { Log.Error(ex, "GetResponseOptionsAsync failed {MessageId}", messageId); throw; } + } + + public async Task ProcessDialogueResponseAsync(Guid messageId, Guid responseOptionId, Guid playerId) + { + try + { + var option = await _uow.DialogueMessageResponseOptions.Query(o => o.Id == responseOptionId && o.ParentDialogueMessageId == messageId).FirstOrDefaultAsync(); + if (option == null) return null; + if (option.DestinationDialogueMessageId == null) return null; // end branch + return await _uow.DialogueMessages.GetByIdAsync(option.DestinationDialogueMessageId); + } + catch (Exception ex) { Log.Error(ex, "ProcessDialogueResponseAsync failed {MessageId} {ResponseId}", messageId, responseOptionId); throw; } + } +} diff --git a/LctMonolith/Services/InventoryService.cs b/LctMonolith/Services/InventoryService.cs new file mode 100644 index 0000000..266594e --- /dev/null +++ b/LctMonolith/Services/InventoryService.cs @@ -0,0 +1,26 @@ +using LctMonolith.Database.UnitOfWork; +using LctMonolith.Models.Database; +using LctMonolith.Services.Contracts; +using Microsoft.EntityFrameworkCore; +using Serilog; + +namespace LctMonolith.Services; + +public class InventoryService : IInventoryService +{ + private readonly IUnitOfWork _uow; + public InventoryService(IUnitOfWork uow) => _uow = uow; + + public async Task> GetStoreInventoryAsync(Guid userId, CancellationToken ct = default) + { + try + { + return await _uow.UserInventoryItems.Query(i => i.UserId == userId, null, i => i.StoreItem).ToListAsync(ct); + } + catch (Exception ex) + { + Log.Error(ex, "GetStoreInventoryAsync failed {UserId}", userId); + throw; + } + } +} diff --git a/LctMonolith/Services/MissionCategoryService.cs b/LctMonolith/Services/MissionCategoryService.cs new file mode 100644 index 0000000..f626e54 --- /dev/null +++ b/LctMonolith/Services/MissionCategoryService.cs @@ -0,0 +1,49 @@ +using LctMonolith.Database.UnitOfWork; +using LctMonolith.Models.Database; +using LctMonolith.Services.Interfaces; +using Microsoft.EntityFrameworkCore; +using Serilog; + +namespace LctMonolith.Services; + +public class MissionCategoryService : IMissionCategoryService +{ + private readonly IUnitOfWork _uow; + public MissionCategoryService(IUnitOfWork uow) => _uow = uow; + + public async Task GetCategoryByIdAsync(Guid categoryId) + { + try { return await _uow.MissionCategories.GetByIdAsync(categoryId); } + catch (Exception ex) { Log.Error(ex, "GetCategoryByIdAsync failed {CategoryId}", categoryId); throw; } + } + + public async Task GetCategoryByTitleAsync(string title) + { + try { return await _uow.MissionCategories.Query(c => c.Title == title).FirstOrDefaultAsync(); } + catch (Exception ex) { Log.Error(ex, "GetCategoryByTitleAsync failed {Title}", title); throw; } + } + + public async Task> GetAllCategoriesAsync() + { + try { return await _uow.MissionCategories.Query().OrderBy(c => c.Title).ToListAsync(); } + catch (Exception ex) { Log.Error(ex, "GetAllCategoriesAsync failed"); throw; } + } + + public async Task CreateCategoryAsync(MissionCategory category) + { + try { category.Id = Guid.NewGuid(); await _uow.MissionCategories.AddAsync(category); await _uow.SaveChangesAsync(); return category; } + catch (Exception ex) { Log.Error(ex, "CreateCategoryAsync failed {Title}", category.Title); throw; } + } + + public async Task UpdateCategoryAsync(MissionCategory category) + { + try { _uow.MissionCategories.Update(category); await _uow.SaveChangesAsync(); return category; } + catch (Exception ex) { Log.Error(ex, "UpdateCategoryAsync failed {Id}", category.Id); throw; } + } + + public async Task DeleteCategoryAsync(Guid categoryId) + { + try { var c = await _uow.MissionCategories.GetByIdAsync(categoryId); if (c == null) return false; _uow.MissionCategories.Remove(c); await _uow.SaveChangesAsync(); return true; } + catch (Exception ex) { Log.Error(ex, "DeleteCategoryAsync failed {CategoryId}", categoryId); throw; } + } +} diff --git a/LctMonolith/Services/MissionService.cs b/LctMonolith/Services/MissionService.cs new file mode 100644 index 0000000..c930de1 --- /dev/null +++ b/LctMonolith/Services/MissionService.cs @@ -0,0 +1,165 @@ +using LctMonolith.Database.UnitOfWork; +using LctMonolith.Models.Database; +using LctMonolith.Models.DTO; +using LctMonolith.Services.Interfaces; +using Microsoft.EntityFrameworkCore; +using Serilog; + +namespace LctMonolith.Services; + +public class MissionService : IMissionService +{ + private readonly IUnitOfWork _uow; + private readonly IRewardService _rewardService; + private readonly IRuleValidationService _ruleValidationService; + + public MissionService(IUnitOfWork uow, IRewardService rewardService, IRuleValidationService ruleValidationService) + { + _uow = uow; + _rewardService = rewardService; + _ruleValidationService = ruleValidationService; + } + + public async Task GetMissionByIdAsync(Guid missionId) + { + try { return await _uow.Missions.GetByIdAsync(missionId); } + catch (Exception ex) { Log.Error(ex, "GetMissionByIdAsync failed {MissionId}", missionId); throw; } + } + + public async Task> GetMissionsByCategoryAsync(Guid categoryId) + { + try { return await _uow.Missions.Query(m => m.MissionCategoryId == categoryId).ToListAsync(); } + catch (Exception ex) { Log.Error(ex, "GetMissionsByCategoryAsync failed {Category}", categoryId); throw; } + } + + public async Task> GetAvailableMissionsForPlayerAsync(Guid playerId) + { + try + { + var missions = await _uow.Missions.Query().ToListAsync(); + var result = new List(); + foreach (var m in missions) + { + if (await IsMissionAvailableForPlayerAsync(m.Id, playerId)) result.Add(m); + } + return result; + } + catch (Exception ex) { Log.Error(ex, "GetAvailableMissionsForPlayerAsync failed {Player}", playerId); throw; } + } + + public async Task> GetChildMissionsAsync(Guid parentMissionId) + { + try { return await _uow.Missions.Query(m => m.ParentMissionId == parentMissionId).ToListAsync(); } + catch (Exception ex) { Log.Error(ex, "GetChildMissionsAsync failed {ParentMission}", parentMissionId); throw; } + } + + public async Task CreateMissionAsync(Mission mission) + { + try + { + mission.Id = Guid.NewGuid(); + await _uow.Missions.AddAsync(mission); + await _uow.SaveChangesAsync(); + return mission; + } + catch (Exception ex) { Log.Error(ex, "CreateMissionAsync failed {Title}", mission.Title); throw; } + } + + public async Task UpdateMissionAsync(Mission mission) + { + try { _uow.Missions.Update(mission); await _uow.SaveChangesAsync(); return mission; } + catch (Exception ex) { Log.Error(ex, "UpdateMissionAsync failed {MissionId}", mission.Id); throw; } + } + + public async Task DeleteMissionAsync(Guid missionId) + { + try { var m = await _uow.Missions.GetByIdAsync(missionId); if (m == null) return false; _uow.Missions.Remove(m); await _uow.SaveChangesAsync(); return true; } + catch (Exception ex) { Log.Error(ex, "DeleteMissionAsync failed {MissionId}", missionId); throw; } + } + + public async Task IsMissionAvailableForPlayerAsync(Guid missionId, Guid playerId) + { + try + { + var mission = await _uow.Missions.GetByIdAsync(missionId); + if (mission == null) return false; + // rule validation + if (!await _ruleValidationService.ValidateMissionRankRulesAsync(missionId, playerId)) return false; + // already completed? then not available + var completed = await _uow.PlayerMissions.Query(pm => pm.PlayerId == playerId && pm.MissionId == missionId && pm.Completed != null).AnyAsync(); + if (completed) return false; + // if parent mission required ensure parent completed + if (mission.ParentMissionId.HasValue) + { + var parentDone = await _uow.PlayerMissions.Query(pm => pm.PlayerId == playerId && pm.MissionId == mission.ParentMissionId && pm.Completed != null).AnyAsync(); + if (!parentDone) return false; + } + return true; + } + catch (Exception ex) { Log.Error(ex, "IsMissionAvailableForPlayerAsync failed {MissionId} {PlayerId}", missionId, playerId); throw; } + } + + public async Task CompleteMissionAsync(Guid missionId, Guid playerId, object? missionProof = null) + { + try + { + if (!await IsMissionAvailableForPlayerAsync(missionId, playerId)) + { + return new MissionCompletionResult { Success = false, Message = "Mission not available" }; + } + var mission = await _uow.Missions.GetByIdAsync(missionId) ?? throw new KeyNotFoundException("Mission not found"); + var player = await _uow.Players.GetByIdAsync(playerId) ?? throw new KeyNotFoundException("Player not found"); + + // snapshot skill levels before + var skillRewards = await _uow.MissionSkillRewards.Query(r => r.MissionId == missionId).ToListAsync(); + var beforeSkills = await _uow.PlayerSkills.Query(ps => ps.PlayerId == playerId).ToListAsync(); + + // mark PlayerMission + var pm = await _uow.PlayerMissions.Query(x => x.PlayerId == playerId && x.MissionId == missionId).FirstOrDefaultAsync(); + if (pm == null) + { + pm = new PlayerMission { Id = Guid.NewGuid(), PlayerId = playerId, MissionId = missionId, Started = DateTime.UtcNow }; + await _uow.PlayerMissions.AddAsync(pm); + } + pm.Completed = DateTime.UtcNow; + pm.ProgressPercent = 100; + await _uow.SaveChangesAsync(); + + var prevExp = player.Experience; + var prevMana = player.Mana; + + // distribute rewards (XP/Mana/Skills/Items) + await _rewardService.DistributeMissionRewardsAsync(missionId, playerId); + await _uow.SaveChangesAsync(); + + // build skill progress + var afterSkills = await _uow.PlayerSkills.Query(ps => ps.PlayerId == playerId).ToListAsync(); + var skillProgress = new List(); + foreach (var r in skillRewards) + { + var before = beforeSkills.FirstOrDefault(s => s.SkillId == r.SkillId)?.Score ?? 0; + var after = afterSkills.FirstOrDefault(s => s.SkillId == r.SkillId)?.Score ?? before; + if (after != before) + { + var skill = await _uow.Skills.GetByIdAsync(r.SkillId); + skillProgress.Add(new SkillProgress { SkillId = r.SkillId, SkillTitle = skill?.Title ?? string.Empty, PreviousLevel = before, NewLevel = after }); + } + } + + return new MissionCompletionResult + { + Success = true, + Message = "Mission completed", + ExperienceGained = player.Experience - prevExp, + ManaGained = player.Mana - prevMana, + SkillsProgress = skillProgress, + UnlockedMissions = (await _uow.Missions.Query(m => m.ParentMissionId == missionId).Select(m => m.Id).ToListAsync()) + }; + } + catch (Exception ex) + { + Log.Error(ex, "CompleteMissionAsync failed {MissionId} {PlayerId}", missionId, playerId); + throw; + } + } +} diff --git a/LctMonolith/Services/Models/AnalyticsSummary.cs b/LctMonolith/Services/Models/AnalyticsSummary.cs index 937a567..0478a52 100644 --- a/LctMonolith/Services/Models/AnalyticsSummary.cs +++ b/LctMonolith/Services/Models/AnalyticsSummary.cs @@ -5,7 +5,6 @@ public class AnalyticsSummary public int TotalUsers { get; set; } public int TotalMissions { get; set; } public int CompletedMissions { get; set; } - public int TotalArtifacts { get; set; } public int TotalStoreItems { get; set; } public long TotalExperience { get; set; } public DateTime GeneratedAtUtc { get; set; } = DateTime.UtcNow; diff --git a/LctMonolith/Services/Models/CreateMissionModel.cs b/LctMonolith/Services/Models/CreateMissionModel.cs index 002ced0..52b2fc7 100644 --- a/LctMonolith/Services/Models/CreateMissionModel.cs +++ b/LctMonolith/Services/Models/CreateMissionModel.cs @@ -12,5 +12,4 @@ public class CreateMissionModel public int ExperienceReward { get; set; } public int ManaReward { get; set; } public List CompetencyRewards { get; set; } = new(); - public List ArtifactRewardIds { get; set; } = new(); } diff --git a/LctMonolith/Services/NotificationService.cs b/LctMonolith/Services/NotificationService.cs index 62808e6..80d458c 100644 --- a/LctMonolith/Services/NotificationService.cs +++ b/LctMonolith/Services/NotificationService.cs @@ -2,6 +2,7 @@ using LctMonolith.Database.UnitOfWork; using LctMonolith.Models.Database; using LctMonolith.Services.Contracts; using Microsoft.EntityFrameworkCore; +using Serilog; namespace LctMonolith.Services; @@ -19,61 +20,49 @@ public class NotificationService : INotificationService public async Task CreateAsync(Guid userId, string type, string title, string message, CancellationToken ct = default) { - if (string.IsNullOrWhiteSpace(type)) throw new ArgumentException("Required", nameof(type)); - if (string.IsNullOrWhiteSpace(title)) throw new ArgumentException("Required", nameof(title)); - if (string.IsNullOrWhiteSpace(message)) throw new ArgumentException("Required", nameof(message)); - - var n = new Notification + try { - UserId = userId, - Type = type.Trim(), - Title = title.Trim(), - Message = message.Trim(), - CreatedAt = DateTime.UtcNow, - IsRead = false - }; - await _uow.Notifications.AddAsync(n, ct); - await _uow.SaveChangesAsync(ct); - return n; + if (string.IsNullOrWhiteSpace(type)) throw new ArgumentException("Required", nameof(type)); + if (string.IsNullOrWhiteSpace(title)) throw new ArgumentException("Required", nameof(title)); + if (string.IsNullOrWhiteSpace(message)) throw new ArgumentException("Required", nameof(message)); + + var n = new Notification + { + UserId = userId, + Type = type.Trim(), + Title = title.Trim(), + Message = message.Trim(), + CreatedAt = DateTime.UtcNow, + IsRead = false + }; + await _uow.Notifications.AddAsync(n, ct); + await _uow.SaveChangesAsync(ct); + return n; + } + catch (Exception ex) { Log.Error(ex, "Notification CreateAsync failed {UserId}", userId); throw; } } public async Task> GetUnreadAsync(Guid userId, CancellationToken ct = default) { - var query = _uow.Notifications.Query(n => n.UserId == userId && !n.IsRead, q => q.OrderByDescending(x => x.CreatedAt)); - return await query.Take(100).ToListAsync(ct); + try { return await _uow.Notifications.Query(n => n.UserId == userId && !n.IsRead, q => q.OrderByDescending(x => x.CreatedAt)).Take(100).ToListAsync(ct); } + catch (Exception ex) { Log.Error(ex, "GetUnreadAsync failed {UserId}", userId); throw; } } public async Task> GetAllAsync(Guid userId, int take = 100, CancellationToken ct = default) { - if (take <= 0) take = 1; - if (take > 500) take = 500; - var query = _uow.Notifications.Query(n => n.UserId == userId, q => q.OrderByDescending(x => x.CreatedAt)); - return await query.Take(take).ToListAsync(ct); + try { if (take <= 0) take = 1; if (take > 500) take = 500; return await _uow.Notifications.Query(n => n.UserId == userId, q => q.OrderByDescending(x => x.CreatedAt)).Take(take).ToListAsync(ct); } + catch (Exception ex) { Log.Error(ex, "GetAllAsync failed {UserId}", userId); throw; } } public async Task MarkReadAsync(Guid userId, Guid notificationId, CancellationToken ct = default) { - var notif = await _uow.Notifications.Query(n => n.Id == notificationId && n.UserId == userId).FirstOrDefaultAsync(ct) - ?? throw new KeyNotFoundException("Notification not found"); - if (!notif.IsRead) - { - notif.IsRead = true; - notif.ReadAt = DateTime.UtcNow; - await _uow.SaveChangesAsync(ct); - } + try { var notif = await _uow.Notifications.Query(n => n.Id == notificationId && n.UserId == userId).FirstOrDefaultAsync(ct) ?? throw new KeyNotFoundException("Notification not found"); if (!notif.IsRead) { notif.IsRead = true; notif.ReadAt = DateTime.UtcNow; await _uow.SaveChangesAsync(ct); } } + catch (Exception ex) { Log.Error(ex, "MarkReadAsync failed {NotificationId}", notificationId); throw; } } public async Task MarkAllReadAsync(Guid userId, CancellationToken ct = default) { - var unread = await _uow.Notifications.Query(n => n.UserId == userId && !n.IsRead).ToListAsync(ct); - if (unread.Count == 0) return 0; - var now = DateTime.UtcNow; - foreach (var n in unread) - { - n.IsRead = true; - n.ReadAt = now; - } - await _uow.SaveChangesAsync(ct); - return unread.Count; + try { var unread = await _uow.Notifications.Query(n => n.UserId == userId && !n.IsRead).ToListAsync(ct); if (unread.Count == 0) return 0; var now = DateTime.UtcNow; foreach (var n in unread) { n.IsRead = true; n.ReadAt = now; } await _uow.SaveChangesAsync(ct); return unread.Count; } + catch (Exception ex) { Log.Error(ex, "MarkAllReadAsync failed {UserId}", userId); throw; } } } diff --git a/LctMonolith/Services/PlayerService.cs b/LctMonolith/Services/PlayerService.cs new file mode 100644 index 0000000..5780d9d --- /dev/null +++ b/LctMonolith/Services/PlayerService.cs @@ -0,0 +1,153 @@ +using LctMonolith.Database.UnitOfWork; +using LctMonolith.Models.Database; +using LctMonolith.Services.Interfaces; +using Microsoft.EntityFrameworkCore; +using Serilog; + +namespace LctMonolith.Services; + +public class PlayerService : IPlayerService +{ + private readonly IUnitOfWork _uow; + public PlayerService(IUnitOfWork uow) => _uow = uow; + + public async Task GetPlayerByUserIdAsync(string userId) + { + try + { + if (!Guid.TryParse(userId, out var uid)) return null; + return await _uow.Players.Query(p => p.UserId == uid, null, p => p.Rank).FirstOrDefaultAsync(); + } + catch (Exception ex) + { + Log.Error(ex, "GetPlayerByUserIdAsync failed {UserId}", userId); + throw; + } + } + + public async Task CreatePlayerAsync(string userId, string username) + { + try + { + if (!Guid.TryParse(userId, out var uid)) throw new ArgumentException("Invalid user id", nameof(userId)); + var existing = await GetPlayerByUserIdAsync(userId); + if (existing != null) return existing; + // pick lowest exp rank + var baseRank = await _uow.Ranks.Query().OrderBy(r => r.ExpNeeded).FirstAsync(); + var player = new Player + { + Id = Guid.NewGuid(), + UserId = uid, + RankId = baseRank.Id, + Experience = 0, + Mana = 0 + }; + await _uow.Players.AddAsync(player); + await _uow.SaveChangesAsync(); + Log.Information("Created player {PlayerId} for user {UserId}", player.Id, userId); + return player; + } + catch (Exception ex) + { + Log.Error(ex, "CreatePlayerAsync failed {UserId}", userId); + throw; + } + } + + public async Task UpdatePlayerRankAsync(Guid playerId, Guid newRankId) + { + try + { + var player = await _uow.Players.GetByIdAsync(playerId) ?? throw new KeyNotFoundException("Player not found"); + var rank = await _uow.Ranks.GetByIdAsync(newRankId) ?? throw new KeyNotFoundException("Rank not found"); + player.RankId = rank.Id; + await _uow.SaveChangesAsync(); + return player; + } + catch (Exception ex) + { + Log.Error(ex, "UpdatePlayerRankAsync failed {PlayerId} -> {RankId}", playerId, newRankId); + throw; + } + } + + public async Task AddPlayerExperienceAsync(Guid playerId, int experience) + { + try + { + if (experience == 0) return await _uow.Players.GetByIdAsync(playerId) ?? throw new KeyNotFoundException(); + var player = await _uow.Players.GetByIdAsync(playerId) ?? throw new KeyNotFoundException("Player not found"); + player.Experience += experience; + await _uow.SaveChangesAsync(); + await AutoRankUpAsync(player); + return player; + } + catch (Exception ex) + { + Log.Error(ex, "AddPlayerExperienceAsync failed {PlayerId}", playerId); + throw; + } + } + + private async Task AutoRankUpAsync(Player player) + { + // find highest rank whose ExpNeeded <= player's experience + var target = await _uow.Ranks.Query(r => r.ExpNeeded <= player.Experience) + .OrderByDescending(r => r.ExpNeeded) + .FirstOrDefaultAsync(); + if (target != null && target.Id != player.RankId) + { + player.RankId = target.Id; + await _uow.SaveChangesAsync(); + Log.Information("Player {Player} advanced to rank {Rank}", player.Id, target.Title); + } + } + + public async Task AddPlayerManaAsync(Guid playerId, int mana) + { + try + { + if (mana == 0) return await _uow.Players.GetByIdAsync(playerId) ?? throw new KeyNotFoundException(); + var player = await _uow.Players.GetByIdAsync(playerId) ?? throw new KeyNotFoundException("Player not found"); + player.Mana += mana; + await _uow.SaveChangesAsync(); + return player; + } + catch (Exception ex) + { + Log.Error(ex, "AddPlayerManaAsync failed {PlayerId}", playerId); + throw; + } + } + + public async Task> GetTopPlayersAsync(int topCount, TimeSpan timeFrame) + { + try + { + // Simple ordering by experience (timeFrame ignored due to no timestamp on Player) + return await _uow.Players.Query() + .OrderByDescending(p => p.Experience) + .Take(topCount) + .ToListAsync(); + } + catch (Exception ex) + { + Log.Error(ex, "GetTopPlayersAsync failed"); + throw; + } + } + + public async Task GetPlayerWithProgressAsync(Guid playerId) + { + try + { + return await _uow.Players.Query(p => p.Id == playerId, null, p => p.Rank) + .FirstOrDefaultAsync() ?? throw new KeyNotFoundException("Player not found"); + } + catch (Exception ex) + { + Log.Error(ex, "GetPlayerWithProgressAsync failed {PlayerId}", playerId); + throw; + } + } +} diff --git a/LctMonolith/Services/ProgressTrackingService.cs b/LctMonolith/Services/ProgressTrackingService.cs new file mode 100644 index 0000000..1a8ae20 --- /dev/null +++ b/LctMonolith/Services/ProgressTrackingService.cs @@ -0,0 +1,103 @@ +using LctMonolith.Database.UnitOfWork; +using LctMonolith.Models.Database; +using LctMonolith.Models.DTO; +using LctMonolith.Services.Interfaces; +using Microsoft.EntityFrameworkCore; +using Serilog; + +namespace LctMonolith.Services; + +public class ProgressTrackingService : IProgressTrackingService +{ + private readonly IUnitOfWork _uow; + private readonly IMissionService _missionService; + + public ProgressTrackingService(IUnitOfWork uow, IMissionService missionService) + { + _uow = uow; + _missionService = missionService; + } + + public async Task StartMissionAsync(Guid missionId, Guid playerId) + { + try + { + var existing = await _uow.PlayerMissions.Query(pm => pm.PlayerId == playerId && pm.MissionId == missionId).FirstOrDefaultAsync(); + if (existing != null) return existing; + var pm = new PlayerMission { Id = Guid.NewGuid(), PlayerId = playerId, MissionId = missionId, Started = DateTime.UtcNow, ProgressPercent = 0 }; + await _uow.PlayerMissions.AddAsync(pm); + await _uow.SaveChangesAsync(); + return pm; + } + catch (Exception ex) { Log.Error(ex, "StartMissionAsync failed {MissionId} {PlayerId}", missionId, playerId); throw; } + } + + public async Task UpdateMissionProgressAsync(Guid playerMissionId, int progressPercentage, object? proof = null) + { + try + { + if (progressPercentage is < 0 or > 100) throw new ArgumentOutOfRangeException(nameof(progressPercentage)); + var pm = await _uow.PlayerMissions.GetByIdAsync(playerMissionId) ?? throw new KeyNotFoundException("PlayerMission not found"); + if (pm.Completed != null) return pm; + pm.ProgressPercent = progressPercentage; + if (progressPercentage == 100 && pm.Completed == null) + { + // Complete mission through mission service to allocate rewards, etc. + await _missionService.CompleteMissionAsync(pm.MissionId, pm.PlayerId); + } + await _uow.SaveChangesAsync(); + return pm; + } + catch (Exception ex) { Log.Error(ex, "UpdateMissionProgressAsync failed {PlayerMissionId}", playerMissionId); throw; } + } + + public async Task CompleteMissionAsync(Guid playerMissionId, object? proof = null) + { + try + { + var pm = await _uow.PlayerMissions.GetByIdAsync(playerMissionId) ?? throw new KeyNotFoundException("PlayerMission not found"); + if (pm.Completed != null) return pm; + await _missionService.CompleteMissionAsync(pm.MissionId, pm.PlayerId, proof); + await _uow.SaveChangesAsync(); + return pm; + } + catch (Exception ex) { Log.Error(ex, "CompleteMissionAsync (progress) failed {PlayerMissionId}", playerMissionId); throw; } + } + + public async Task> GetPlayerMissionsAsync(Guid playerId) + { + try { return await _uow.PlayerMissions.Query(pm => pm.PlayerId == playerId).ToListAsync(); } + catch (Exception ex) { Log.Error(ex, "GetPlayerMissionsAsync failed {PlayerId}", playerId); throw; } + } + + public async Task GetPlayerMissionAsync(Guid playerId, Guid missionId) + { + try { return await _uow.PlayerMissions.Query(pm => pm.PlayerId == playerId && pm.MissionId == missionId).FirstOrDefaultAsync(); } + catch (Exception ex) { Log.Error(ex, "GetPlayerMissionAsync failed {MissionId} {PlayerId}", missionId, playerId); throw; } + } + + public async Task GetPlayerOverallProgressAsync(Guid playerId) + { + try + { + var player = await _uow.Players.GetByIdAsync(playerId) ?? throw new KeyNotFoundException("Player not found"); + var missions = await _uow.PlayerMissions.Query(pm => pm.PlayerId == playerId).ToListAsync(); + var completed = missions.Count(m => m.Completed != null); + var totalMissions = await _uow.Missions.Query().CountAsync(); + var skillLevels = await _uow.PlayerSkills.Query(ps => ps.PlayerId == playerId, null, ps => ps.Skill) + .ToDictionaryAsync(ps => ps.Skill.Title, ps => ps.Score); + return new PlayerProgress + { + PlayerId = playerId, + PlayerName = playerId.ToString(), + CurrentRank = await _uow.Ranks.GetByIdAsync(player.RankId), + TotalExperience = player.Experience, + TotalMana = player.Mana, + CompletedMissions = completed, + TotalAvailableMissions = totalMissions, + SkillLevels = skillLevels + }; + } + catch (Exception ex) { Log.Error(ex, "GetPlayerOverallProgressAsync failed {PlayerId}", playerId); throw; } + } +} diff --git a/LctMonolith/Services/RankService.cs b/LctMonolith/Services/RankService.cs new file mode 100644 index 0000000..55b5c98 --- /dev/null +++ b/LctMonolith/Services/RankService.cs @@ -0,0 +1,75 @@ +using LctMonolith.Database.UnitOfWork; +using LctMonolith.Models.Database; +using LctMonolith.Services.Interfaces; +using Microsoft.EntityFrameworkCore; +using Serilog; + +namespace LctMonolith.Services; + +public class RankService : IRankService +{ + private readonly IUnitOfWork _uow; + public RankService(IUnitOfWork uow) => _uow = uow; + + public async Task GetRankByIdAsync(Guid rankId) + { + try { return await _uow.Ranks.GetByIdAsync(rankId); } + catch (Exception ex) { Log.Error(ex, "GetRankByIdAsync failed {RankId}", rankId); throw; } + } + public async Task GetRankByTitleAsync(string title) + { + try { return await _uow.Ranks.Query(r => r.Title == title).FirstOrDefaultAsync(); } + catch (Exception ex) { Log.Error(ex, "GetRankByTitleAsync failed {Title}", title); throw; } + } + public async Task> GetAllRanksAsync() + { + try { return await _uow.Ranks.Query().OrderBy(r => r.ExpNeeded).ToListAsync(); } + catch (Exception ex) { Log.Error(ex, "GetAllRanksAsync failed"); throw; } + } + public async Task CreateRankAsync(Rank rank) + { + try { rank.Id = Guid.NewGuid(); await _uow.Ranks.AddAsync(rank); await _uow.SaveChangesAsync(); return rank; } + catch (Exception ex) { Log.Error(ex, "CreateRankAsync failed {Title}", rank.Title); throw; } + } + public async Task UpdateRankAsync(Rank rank) + { + try { _uow.Ranks.Update(rank); await _uow.SaveChangesAsync(); return rank; } + catch (Exception ex) { Log.Error(ex, "UpdateRankAsync failed {RankId}", rank.Id); throw; } + } + public async Task DeleteRankAsync(Guid rankId) + { + try { var r = await _uow.Ranks.GetByIdAsync(rankId); if (r == null) return false; _uow.Ranks.Remove(r); await _uow.SaveChangesAsync(); return true; } + catch (Exception ex) { Log.Error(ex, "DeleteRankAsync failed {RankId}", rankId); throw; } + } + public async Task CanPlayerAdvanceToRankAsync(Guid playerId, Guid rankId) + { + try { + var player = await _uow.Players.GetByIdAsync(playerId) ?? throw new KeyNotFoundException("Player not found"); + var rank = await _uow.Ranks.GetByIdAsync(rankId) ?? throw new KeyNotFoundException("Rank not found"); + if (player.Experience < rank.ExpNeeded) return false; + var missionReqs = await _uow.RankMissionRules.Query(rmr => rmr.RankId == rankId).Select(r => r.MissionId).ToListAsync(); + if (missionReqs.Count > 0) + { + var completed = await _uow.PlayerMissions.Query(pm => pm.PlayerId == playerId && pm.Completed != null).Select(pm => pm.MissionId).ToListAsync(); + if (missionReqs.Except(completed).Any()) return false; + } + var skillReqs = await _uow.RankSkillRules.Query(rsr => rsr.RankId == rankId).ToListAsync(); + if (skillReqs.Count > 0) + { + var playerSkills = await _uow.PlayerSkills.Query(ps => ps.PlayerId == playerId).ToListAsync(); + foreach (var req in skillReqs) + { + var ps = playerSkills.FirstOrDefault(s => s.SkillId == req.SkillId); + if (ps == null || ps.Score < req.Min) return false; + } + } + return true; } + catch (Exception ex) { Log.Error(ex, "CanPlayerAdvanceToRankAsync failed {PlayerId}->{RankId}", playerId, rankId); throw; } + } + public async Task GetNextRankAsync(Guid currentRankId) + { + try { + var current = await _uow.Ranks.GetByIdAsync(currentRankId); if (current == null) return null; return await _uow.Ranks.Query(r => r.ExpNeeded > current.ExpNeeded).OrderBy(r => r.ExpNeeded).FirstOrDefaultAsync(); } + catch (Exception ex) { Log.Error(ex, "GetNextRankAsync failed {RankId}", currentRankId); throw; } + } +} diff --git a/LctMonolith/Services/RewardService.cs b/LctMonolith/Services/RewardService.cs new file mode 100644 index 0000000..49d9bbc --- /dev/null +++ b/LctMonolith/Services/RewardService.cs @@ -0,0 +1,92 @@ +using LctMonolith.Database.UnitOfWork; +using LctMonolith.Models.Database; +using LctMonolith.Services.Interfaces; +using Microsoft.EntityFrameworkCore; +using Serilog; + +namespace LctMonolith.Services; + +public class RewardService : IRewardService +{ + private readonly IUnitOfWork _uow; + public RewardService(IUnitOfWork uow) => _uow = uow; + + public async Task> GetMissionSkillRewardsAsync(Guid missionId) + { + try { return await _uow.MissionSkillRewards.Query(r => r.MissionId == missionId, null, r => r.Skill).ToListAsync(); } + catch (Exception ex) { Log.Error(ex, "GetMissionSkillRewardsAsync failed {MissionId}", missionId); throw; } + } + + public async Task> GetMissionItemRewardsAsync(Guid missionId) + { + try { return await _uow.MissionItemRewards.Query(r => r.MissionId == missionId).ToListAsync(); } + catch (Exception ex) { Log.Error(ex, "GetMissionItemRewardsAsync failed {MissionId}", missionId); throw; } + } + + public async Task DistributeMissionRewardsAsync(Guid missionId, Guid playerId) + { + try + { + var mission = await _uow.Missions.GetByIdAsync(missionId) ?? throw new KeyNotFoundException("Mission not found"); + var player = await _uow.Players.GetByIdAsync(playerId) ?? throw new KeyNotFoundException("Player not found"); + + player.Experience += mission.ExpReward; + player.Mana += mission.ManaReward; + + // Skill rewards + var skillRewards = await _uow.MissionSkillRewards.Query(r => r.MissionId == missionId).ToListAsync(); + foreach (var sr in skillRewards) + { + var ps = await _uow.PlayerSkills.Query(x => x.PlayerId == playerId && x.SkillId == sr.SkillId).FirstOrDefaultAsync(); + if (ps == null) + { + ps = new PlayerSkill { Id = Guid.NewGuid(), PlayerId = playerId, SkillId = sr.SkillId, Score = sr.Value }; + await _uow.PlayerSkills.AddAsync(ps); + } + else + { + ps.Score += sr.Value; + _uow.PlayerSkills.Update(ps); + } + } + + // Item rewards (store items) one each + var itemRewards = await _uow.MissionItemRewards.Query(r => r.MissionId == missionId).ToListAsync(); + foreach (var ir in itemRewards) + { + var inv = await _uow.UserInventoryItems.FindAsync(player.UserId, ir.ItemId); + if (inv == null) + { + inv = new UserInventoryItem { UserId = player.UserId, StoreItemId = ir.ItemId, Quantity = 1, AcquiredAt = DateTime.UtcNow }; + await _uow.UserInventoryItems.AddAsync(inv); + } + else inv.Quantity += 1; + } + + // Mark redeemed + var pm = await _uow.PlayerMissions.Query(pm => pm.PlayerId == playerId && pm.MissionId == missionId).FirstOrDefaultAsync(); + if (pm != null && pm.RewardsRedeemed == null) + { + pm.RewardsRedeemed = DateTime.UtcNow; + _uow.PlayerMissions.Update(pm); + } + } + catch (Exception ex) + { + Log.Error(ex, "DistributeMissionRewardsAsync failed {MissionId} {PlayerId}", missionId, playerId); + throw; + } + } + + public async Task CanClaimRewardAsync(Guid rewardId, Guid playerId) + { + try + { + // Interpret rewardId as missionId; claim if mission completed and rewards not yet redeemed + var pm = await _uow.PlayerMissions.Query(pm => pm.PlayerId == playerId && pm.MissionId == rewardId).FirstOrDefaultAsync(); + if (pm == null || pm.Completed == null) return false; + return pm.RewardsRedeemed == null; + } + catch (Exception ex) { Log.Error(ex, "CanClaimRewardAsync failed {RewardId} {PlayerId}", rewardId, playerId); throw; } + } +} diff --git a/LctMonolith/Services/RuleValidationService.cs b/LctMonolith/Services/RuleValidationService.cs new file mode 100644 index 0000000..741d71b --- /dev/null +++ b/LctMonolith/Services/RuleValidationService.cs @@ -0,0 +1,64 @@ +using LctMonolith.Database.UnitOfWork; +using LctMonolith.Models.Database; +using LctMonolith.Services.Interfaces; +using Microsoft.EntityFrameworkCore; +using Serilog; + +namespace LctMonolith.Services; + +public class RuleValidationService : IRuleValidationService +{ + private readonly IUnitOfWork _uow; + public RuleValidationService(IUnitOfWork uow) => _uow = uow; + + public async Task ValidateMissionRankRulesAsync(Guid missionId, Guid playerId) + { + try + { + var player = await _uow.Players.GetByIdAsync(playerId); + if (player == null) return false; + var rankRules = await _uow.MissionRankRules.Query(r => r.MissionId == missionId).Select(r => r.RankId).ToListAsync(); + if (rankRules.Count == 0) return true; // no restriction + return rankRules.Contains(player.RankId); + } + catch (Exception ex) { Log.Error(ex, "ValidateMissionRankRulesAsync failed {MissionId} {PlayerId}", missionId, playerId); throw; } + } + + public async Task ValidateRankAdvancementRulesAsync(Guid playerId, Guid targetRankId) + { + try + { + var player = await _uow.Players.GetByIdAsync(playerId); + if (player == null) return false; + var rank = await _uow.Ranks.GetByIdAsync(targetRankId); + if (rank == null) return false; + if (player.Experience < rank.ExpNeeded) return false; + // required missions + var missionReqs = await _uow.RankMissionRules.Query(r => r.RankId == targetRankId).Select(r => r.MissionId).ToListAsync(); + if (missionReqs.Count > 0) + { + var completed = await _uow.PlayerMissions.Query(pm => pm.PlayerId == playerId && pm.Completed != null).Select(pm => pm.MissionId).ToListAsync(); + if (missionReqs.Except(completed).Any()) return false; + } + // required skills + var skillReqs = await _uow.RankSkillRules.Query(r => r.RankId == targetRankId).ToListAsync(); + if (skillReqs.Count > 0) + { + var playerSkills = await _uow.PlayerSkills.Query(ps => ps.PlayerId == playerId).ToListAsync(); + foreach (var req in skillReqs) + { + var ps = playerSkills.FirstOrDefault(s => s.SkillId == req.SkillId); + if (ps == null || ps.Score < req.Min) return false; + } + } + return true; + } + catch (Exception ex) { Log.Error(ex, "ValidateRankAdvancementRulesAsync failed {PlayerId}->{RankId}", playerId, targetRankId); throw; } + } + + public async Task> GetApplicableRankRulesAsync(Guid missionId) + { + try { return await _uow.MissionRankRules.Query(r => r.MissionId == missionId, null, r => r.Rank).ToListAsync(); } + catch (Exception ex) { Log.Error(ex, "GetApplicableRankRulesAsync failed {MissionId}", missionId); throw; } + } +} diff --git a/LctMonolith/Services/SkillService.cs b/LctMonolith/Services/SkillService.cs new file mode 100644 index 0000000..70363b2 --- /dev/null +++ b/LctMonolith/Services/SkillService.cs @@ -0,0 +1,59 @@ +using LctMonolith.Database.UnitOfWork; +using LctMonolith.Models.Database; +using LctMonolith.Services.Interfaces; +using Microsoft.EntityFrameworkCore; +using Serilog; + +namespace LctMonolith.Services; + +public class SkillService : ISkillService +{ + private readonly IUnitOfWork _uow; + public SkillService(IUnitOfWork uow) => _uow = uow; + + public async Task GetSkillByIdAsync(Guid skillId) + { + try { return await _uow.Skills.GetByIdAsync(skillId); } + catch (Exception ex) { Log.Error(ex, "GetSkillByIdAsync failed {SkillId}", skillId); throw; } + } + public async Task GetSkillByTitleAsync(string title) + { + try { return await _uow.Skills.Query(s => s.Title == title).FirstOrDefaultAsync(); } + catch (Exception ex) { Log.Error(ex, "GetSkillByTitleAsync failed {Title}", title); throw; } + } + public async Task> GetAllSkillsAsync() + { + try { return await _uow.Skills.Query().OrderBy(s => s.Title).ToListAsync(); } + catch (Exception ex) { Log.Error(ex, "GetAllSkillsAsync failed"); throw; } + } + public async Task CreateSkillAsync(Skill skill) + { + try { skill.Id = Guid.NewGuid(); await _uow.Skills.AddAsync(skill); await _uow.SaveChangesAsync(); return skill; } + catch (Exception ex) { Log.Error(ex, "CreateSkillAsync failed {Title}", skill.Title); throw; } + } + public async Task UpdateSkillAsync(Skill skill) + { + try { _uow.Skills.Update(skill); await _uow.SaveChangesAsync(); return skill; } + catch (Exception ex) { Log.Error(ex, "UpdateSkillAsync failed {SkillId}", skill.Id); throw; } + } + public async Task DeleteSkillAsync(Guid skillId) + { + try { var skill = await _uow.Skills.GetByIdAsync(skillId); if (skill == null) return false; _uow.Skills.Remove(skill); await _uow.SaveChangesAsync(); return true; } + catch (Exception ex) { Log.Error(ex, "DeleteSkillAsync failed {SkillId}", skillId); throw; } + } + public async Task UpdatePlayerSkillAsync(Guid playerId, Guid skillId, int level) + { + try { var ps = await _uow.PlayerSkills.Query(x => x.PlayerId == playerId && x.SkillId == skillId).FirstOrDefaultAsync(); if (ps == null) { ps = new PlayerSkill { Id = Guid.NewGuid(), PlayerId = playerId, SkillId = skillId, Score = level }; await _uow.PlayerSkills.AddAsync(ps); } else { ps.Score = level; _uow.PlayerSkills.Update(ps); } await _uow.SaveChangesAsync(); return ps; } + catch (Exception ex) { Log.Error(ex, "UpdatePlayerSkillAsync failed {PlayerId} {SkillId}", playerId, skillId); throw; } + } + public async Task> GetPlayerSkillsAsync(Guid playerId) + { + try { return await _uow.PlayerSkills.Query(ps => ps.PlayerId == playerId, null, ps => ps.Skill).ToListAsync(); } + catch (Exception ex) { Log.Error(ex, "GetPlayerSkillsAsync failed {PlayerId}", playerId); throw; } + } + public async Task GetPlayerSkillLevelAsync(Guid playerId, Guid skillId) + { + try { var ps = await _uow.PlayerSkills.Query(x => x.PlayerId == playerId && x.SkillId == skillId).FirstOrDefaultAsync(); return ps?.Score ?? 0; } + catch (Exception ex) { Log.Error(ex, "GetPlayerSkillLevelAsync failed {PlayerId} {SkillId}", playerId, skillId); throw; } + } +} diff --git a/LctMonolith/Services/StoreService.cs b/LctMonolith/Services/StoreService.cs index 19e165f..7191a3a 100644 --- a/LctMonolith/Services/StoreService.cs +++ b/LctMonolith/Services/StoreService.cs @@ -21,55 +21,42 @@ public class StoreService : IStoreService public async Task> GetActiveItemsAsync(CancellationToken ct = default) { - return await _uow.StoreItems.Query(i => i.IsActive).ToListAsync(ct); + try { return await _uow.StoreItems.Query(i => i.IsActive).ToListAsync(ct); } + catch (Exception ex) { Log.Error(ex, "GetActiveItemsAsync failed"); throw; } } public async Task PurchaseAsync(Guid userId, Guid itemId, int quantity, CancellationToken ct = default) { - if (quantity <= 0) throw new ArgumentOutOfRangeException(nameof(quantity)); - var user = await _uow.Users.Query(u => u.Id == userId).FirstOrDefaultAsync(ct) ?? throw new KeyNotFoundException("User not found"); - var item = await _uow.StoreItems.Query(i => i.Id == itemId && i.IsActive).FirstOrDefaultAsync(ct) ?? throw new KeyNotFoundException("Item not found or inactive"); - var totalPrice = item.Price * quantity; - if (user.Mana < totalPrice) throw new InvalidOperationException("Insufficient mana"); - if (item.Stock.HasValue && item.Stock.Value < quantity) throw new InvalidOperationException("Insufficient stock"); - - user.Mana -= totalPrice; - if (item.Stock.HasValue) item.Stock -= quantity; - - var inv = await _uow.UserInventoryItems.FindAsync(userId, itemId); - if (inv == null) + try { - inv = new UserInventoryItem + if (quantity <= 0) throw new ArgumentOutOfRangeException(nameof(quantity)); + var player = await _uow.Players.Query(p => p.UserId == userId).FirstOrDefaultAsync(ct) ?? throw new KeyNotFoundException("Player not found for user"); + var item = await _uow.StoreItems.Query(i => i.Id == itemId && i.IsActive).FirstOrDefaultAsync(ct) ?? throw new KeyNotFoundException("Item not found or inactive"); + var totalPrice = item.Price * quantity; + if (player.Mana < totalPrice) throw new InvalidOperationException("Insufficient mana"); + if (item.Stock.HasValue && item.Stock.Value < quantity) throw new InvalidOperationException("Insufficient stock"); + + player.Mana -= totalPrice; + if (item.Stock.HasValue) item.Stock -= quantity; + + var inv = await _uow.UserInventoryItems.FindAsync(userId, itemId); + if (inv == null) { - UserId = userId, - StoreItemId = itemId, - Quantity = quantity, - AcquiredAt = DateTime.UtcNow - }; - await _uow.UserInventoryItems.AddAsync(inv, ct); + inv = new UserInventoryItem { UserId = userId, StoreItemId = itemId, Quantity = quantity, AcquiredAt = DateTime.UtcNow }; + await _uow.UserInventoryItems.AddAsync(inv, ct); + } + else inv.Quantity += quantity; + + await _uow.Transactions.AddAsync(new Transaction { UserId = userId, StoreItemId = itemId, Type = TransactionType.Purchase, ManaAmount = -totalPrice }, ct); + await _uow.EventLogs.AddAsync(new EventLog { Type = EventType.ItemPurchased, UserId = userId, Data = JsonSerializer.Serialize(new { itemId, quantity, totalPrice }) }, ct); + await _uow.SaveChangesAsync(ct); + Log.Information("User {User} purchased {Qty} of {Item}", userId, quantity, itemId); + return inv; } - else + catch (Exception ex) { - inv.Quantity += quantity; + Log.Error(ex, "PurchaseAsync failed {UserId} {ItemId}", userId, itemId); + throw; } - - await _uow.Transactions.AddAsync(new Transaction - { - UserId = userId, - StoreItemId = itemId, - Type = TransactionType.Purchase, - ManaAmount = -totalPrice - }, ct); - - await _uow.EventLogs.AddAsync(new EventLog - { - Type = EventType.ItemPurchased, - UserId = userId, - Data = JsonSerializer.Serialize(new { itemId, quantity, totalPrice }) - }, ct); - - await _uow.SaveChangesAsync(ct); - Log.Information("User {User} purchased {Qty} of {Item}", userId, quantity, itemId); - return inv; } } diff --git a/LctMonolith/Services/TokenService.cs b/LctMonolith/Services/TokenService.cs index 3e5bd37..ec2e1ca 100644 --- a/LctMonolith/Services/TokenService.cs +++ b/LctMonolith/Services/TokenService.cs @@ -10,6 +10,7 @@ using LctMonolith.Services.Models; using Microsoft.AspNetCore.Identity; using Microsoft.Extensions.Options; using Microsoft.IdentityModel.Tokens; +using Serilog; namespace LctMonolith.Services; @@ -34,54 +35,78 @@ public class TokenService : ITokenService public async Task IssueAsync(AppUser user, CancellationToken ct = default) { - var now = DateTime.UtcNow; - var accessExp = now.AddMinutes(_options.AccessTokenMinutes); - var refreshExp = now.AddDays(_options.RefreshTokenDays); - var claims = new List + try { - new(JwtRegisteredClaimNames.Sub, user.Id.ToString()), - new(ClaimTypes.NameIdentifier, user.Id.ToString()), - new(JwtRegisteredClaimNames.Email, user.Email ?? string.Empty), - new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()), - }; - var jwt = new JwtSecurityToken( - issuer: _options.Issuer, - audience: _options.Audience, - claims: claims, - notBefore: now, - expires: accessExp, - signingCredentials: _creds); - var accessToken = new JwtSecurityTokenHandler().WriteToken(jwt); + var now = DateTime.UtcNow; + var accessExp = now.AddMinutes(_options.AccessTokenMinutes); + var refreshExp = now.AddDays(_options.RefreshTokenDays); + var claims = new List + { + new(JwtRegisteredClaimNames.Sub, user.Id.ToString()), + new(ClaimTypes.NameIdentifier, user.Id.ToString()), + new(JwtRegisteredClaimNames.Email, user.Email ?? string.Empty), + new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()), + }; + var jwt = new JwtSecurityToken( + issuer: _options.Issuer, + audience: _options.Audience, + claims: claims, + notBefore: now, + expires: accessExp, + signingCredentials: _creds); + var accessToken = new JwtSecurityTokenHandler().WriteToken(jwt); - var refreshToken = GenerateSecureToken(); - var rt = new RefreshToken + var refreshToken = GenerateSecureToken(); + var rt = new RefreshToken + { + Token = refreshToken, + UserId = user.Id, + ExpiresAt = refreshExp + }; + await _uow.RefreshTokens.AddAsync(rt, ct); + await _uow.SaveChangesAsync(ct); + return new TokenPair(accessToken, accessExp, refreshToken, refreshExp); + } + catch (Exception ex) { - Token = refreshToken, - UserId = user.Id, - ExpiresAt = refreshExp - }; - await _uow.RefreshTokens.AddAsync(rt, ct); - await _uow.SaveChangesAsync(ct); - return new TokenPair(accessToken, accessExp, refreshToken, refreshExp); + Log.Error(ex, "IssueAsync failed for user {UserId}", user.Id); + throw; + } } public async Task RefreshAsync(string refreshToken, CancellationToken ct = default) { - var token = await _uow.RefreshTokens.Query(r => r.Token == refreshToken).FirstOrDefaultAsync(ct); - if (token == null || token.IsRevoked || token.ExpiresAt < DateTime.UtcNow) - throw new SecurityTokenException("Invalid refresh token"); - var user = await _userManager.FindByIdAsync(token.UserId.ToString()) ?? throw new SecurityTokenException("User not found"); - token.IsRevoked = true; // rotate - await _uow.SaveChangesAsync(ct); - return await IssueAsync(user, ct); + try + { + var token = await _uow.RefreshTokens.Query(r => r.Token == refreshToken).FirstOrDefaultAsync(ct); + if (token == null || token.IsRevoked || token.ExpiresAt < DateTime.UtcNow) + throw new SecurityTokenException("Invalid refresh token"); + var user = await _userManager.FindByIdAsync(token.UserId.ToString()) ?? throw new SecurityTokenException("User not found"); + token.IsRevoked = true; // rotate + await _uow.SaveChangesAsync(ct); + return await IssueAsync(user, ct); + } + catch (Exception ex) + { + Log.Error(ex, "RefreshAsync failed"); + throw; + } } public async Task RevokeAsync(string refreshToken, CancellationToken ct = default) { - var token = await _uow.RefreshTokens.Query(r => r.Token == refreshToken).FirstOrDefaultAsync(ct); - if (token == null) return; // idempotent - token.IsRevoked = true; - await _uow.SaveChangesAsync(ct); + try + { + var token = await _uow.RefreshTokens.Query(r => r.Token == refreshToken).FirstOrDefaultAsync(ct); + if (token == null) return; // idempotent + token.IsRevoked = true; + await _uow.SaveChangesAsync(ct); + } + catch (Exception ex) + { + Log.Error(ex, "RevokeAsync failed"); + throw; + } } private static string GenerateSecureToken()