feat: Project preready
This commit is contained in:
@@ -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<IUnitOfWork, UnitOfWork>();
|
||||||
|
|
||||||
|
// Core domain / gamification services
|
||||||
|
services.AddScoped<ITokenService, TokenService>();
|
||||||
|
services.AddScoped<IStoreService, StoreService>();
|
||||||
|
services.AddScoped<INotificationService, NotificationService>();
|
||||||
|
services.AddScoped<IAnalyticsService, AnalyticsService>();
|
||||||
|
services.AddScoped<IPlayerService, PlayerService>();
|
||||||
|
services.AddScoped<IRankService, RankService>();
|
||||||
|
services.AddScoped<ISkillService, SkillService>();
|
||||||
|
services.AddScoped<IMissionCategoryService, MissionCategoryService>();
|
||||||
|
services.AddScoped<IMissionService, MissionService>();
|
||||||
|
services.AddScoped<IRewardService, RewardService>();
|
||||||
|
services.AddScoped<IRuleValidationService, RuleValidationService>();
|
||||||
|
services.AddScoped<IProgressTrackingService, ProgressTrackingService>();
|
||||||
|
services.AddScoped<IDialogueService, DialogueService>();
|
||||||
|
services.AddScoped<IInventoryService, InventoryService>();
|
||||||
|
|
||||||
|
services.Configure<S3StorageOptions>(configuration.GetSection("S3"));
|
||||||
|
services.AddSingleton<IFileStorageService, S3FileStorageService>();
|
||||||
|
services.AddScoped<IProfileService, ProfileService>();
|
||||||
|
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
}
|
||||||
75
LctMonolith/Controllers/DialogueController.cs
Normal file
75
LctMonolith/Controllers/DialogueController.cs
Normal file
@@ -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<IActionResult> GetByMission(Guid missionId)
|
||||||
|
{
|
||||||
|
var d = await _dialogueService.GetDialogueByMissionIdAsync(missionId);
|
||||||
|
return d == null ? NotFound() : Ok(d);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("message/{messageId:guid}")]
|
||||||
|
public async Task<IActionResult> GetMessage(Guid messageId)
|
||||||
|
{
|
||||||
|
var m = await _dialogueService.GetDialogueMessageByIdAsync(messageId);
|
||||||
|
return m == null ? NotFound() : Ok(m);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("message/{messageId:guid}/options")]
|
||||||
|
public async Task<IActionResult> 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<IActionResult> 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<IActionResult> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
34
LctMonolith/Controllers/InventoryController.cs
Normal file
34
LctMonolith/Controllers/InventoryController.cs
Normal file
@@ -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)!);
|
||||||
|
|
||||||
|
/// <summary>Get inventory for current authenticated user.</summary>
|
||||||
|
[HttpGet]
|
||||||
|
public async Task<IActionResult> GetMine(CancellationToken ct)
|
||||||
|
{
|
||||||
|
var items = await _inventoryService.GetStoreInventoryAsync(CurrentUserId(), ct);
|
||||||
|
return Ok(items.Select(i => new { i.StoreItemId, i.Quantity, i.AcquiredAt }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Admin: get inventory for specific user.</summary>
|
||||||
|
[HttpGet("user/{userId:guid}")]
|
||||||
|
[Authorize(Roles = "Admin")]
|
||||||
|
public async Task<IActionResult> GetByUser(Guid userId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var items = await _inventoryService.GetStoreInventoryAsync(userId, ct);
|
||||||
|
return Ok(items);
|
||||||
|
}
|
||||||
|
}
|
||||||
58
LctMonolith/Controllers/MissionCategoriesController.cs
Normal file
58
LctMonolith/Controllers/MissionCategoriesController.cs
Normal file
@@ -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<IActionResult> GetAll()
|
||||||
|
{
|
||||||
|
var list = await _service.GetAllCategoriesAsync();
|
||||||
|
return Ok(list);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("{id:guid}")]
|
||||||
|
public async Task<IActionResult> Get(Guid id)
|
||||||
|
{
|
||||||
|
var c = await _service.GetCategoryByIdAsync(id);
|
||||||
|
return c == null ? NotFound() : Ok(c);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost]
|
||||||
|
[Authorize(Roles = "Admin")]
|
||||||
|
public async Task<IActionResult> 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<IActionResult> 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<IActionResult> Delete(Guid id)
|
||||||
|
{
|
||||||
|
var ok = await _service.DeleteCategoryAsync(id);
|
||||||
|
return ok ? NoContent() : NotFound();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
112
LctMonolith/Controllers/MissionsController.cs
Normal file
112
LctMonolith/Controllers/MissionsController.cs
Normal file
@@ -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<IActionResult> Get(Guid id)
|
||||||
|
{
|
||||||
|
var m = await _missions.GetMissionByIdAsync(id);
|
||||||
|
return m == null ? NotFound() : Ok(m);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("category/{categoryId:guid}")]
|
||||||
|
public async Task<IActionResult> ByCategory(Guid categoryId)
|
||||||
|
{
|
||||||
|
var list = await _missions.GetMissionsByCategoryAsync(categoryId);
|
||||||
|
return Ok(list);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("player/{playerId:guid}/available")]
|
||||||
|
public async Task<IActionResult> Available(Guid playerId)
|
||||||
|
{
|
||||||
|
var list = await _missions.GetAvailableMissionsForPlayerAsync(playerId);
|
||||||
|
return Ok(list);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("{id:guid}/rank-rules")]
|
||||||
|
public async Task<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> Complete(Guid missionId, CompleteMissionRequest r)
|
||||||
|
{
|
||||||
|
var result = await _missions.CompleteMissionAsync(missionId, r.PlayerId, r.Proof);
|
||||||
|
if (!result.Success) return BadRequest(result);
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
78
LctMonolith/Controllers/PlayersController.cs
Normal file
78
LctMonolith/Controllers/PlayersController.cs
Normal file
@@ -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<IActionResult> GetPlayer(Guid playerId)
|
||||||
|
{
|
||||||
|
var player = await _playerService.GetPlayerWithProgressAsync(playerId);
|
||||||
|
return Ok(player);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("user/{userId:guid}")]
|
||||||
|
public async Task<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> GetTop([FromQuery] int count = 10)
|
||||||
|
{
|
||||||
|
var list = await _playerService.GetTopPlayersAsync(count, TimeSpan.FromDays(30));
|
||||||
|
return Ok(list);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("{playerId:guid}/progress")]
|
||||||
|
public async Task<IActionResult> GetProgress(Guid playerId)
|
||||||
|
{
|
||||||
|
var prog = await _progressService.GetPlayerOverallProgressAsync(playerId);
|
||||||
|
return Ok(prog);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
72
LctMonolith/Controllers/RanksController.cs
Normal file
72
LctMonolith/Controllers/RanksController.cs
Normal file
@@ -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<IActionResult> GetAll()
|
||||||
|
{
|
||||||
|
var ranks = await _rankService.GetAllRanksAsync();
|
||||||
|
return Ok(ranks);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("{id:guid}")]
|
||||||
|
public async Task<IActionResult> Get(Guid id)
|
||||||
|
{
|
||||||
|
var r = await _rankService.GetRankByIdAsync(id);
|
||||||
|
return r == null ? NotFound() : Ok(r);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost]
|
||||||
|
[Authorize(Roles = "Admin")]
|
||||||
|
public async Task<IActionResult> 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<IActionResult> 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<IActionResult> Delete(Guid id)
|
||||||
|
{
|
||||||
|
var ok = await _rankService.DeleteRankAsync(id);
|
||||||
|
return ok ? NoContent() : NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("validate-advance/{playerId:guid}/{targetRankId:guid}")]
|
||||||
|
public async Task<IActionResult> CanAdvance(Guid playerId, Guid targetRankId)
|
||||||
|
{
|
||||||
|
var ok = await _ruleValidation.ValidateRankAdvancementRulesAsync(playerId, targetRankId);
|
||||||
|
return Ok(new { playerId, targetRankId, canAdvance = ok });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
65
LctMonolith/Controllers/RewardController.cs
Normal file
65
LctMonolith/Controllers/RewardController.cs
Normal file
@@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>List skill rewards configured for a mission.</summary>
|
||||||
|
[HttpGet("mission/{missionId:guid}/skills")]
|
||||||
|
public async Task<IActionResult> GetMissionSkillRewards(Guid missionId)
|
||||||
|
{
|
||||||
|
var rewards = await _rewardService.GetMissionSkillRewardsAsync(missionId);
|
||||||
|
return Ok(rewards.Select(r => new { r.SkillId, r.Value }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>List item rewards configured for a mission.</summary>
|
||||||
|
[HttpGet("mission/{missionId:guid}/items")]
|
||||||
|
public async Task<IActionResult> GetMissionItemRewards(Guid missionId)
|
||||||
|
{
|
||||||
|
var rewards = await _rewardService.GetMissionItemRewardsAsync(missionId);
|
||||||
|
return Ok(rewards.Select(r => new { r.ItemId }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Check if mission rewards can be claimed by player (missionId used as rewardId).</summary>
|
||||||
|
[HttpGet("mission/{missionId:guid}/can-claim/{playerId:guid}")]
|
||||||
|
public async Task<IActionResult> CanClaim(Guid missionId, Guid playerId)
|
||||||
|
{
|
||||||
|
var can = await _rewardService.CanClaimRewardAsync(missionId, playerId);
|
||||||
|
return Ok(new { missionId, playerId, canClaim = can });
|
||||||
|
}
|
||||||
|
|
||||||
|
public record ClaimRewardRequest(Guid PlayerId);
|
||||||
|
|
||||||
|
/// <summary>Claim mission rewards if available (idempotent on already claimed).</summary>
|
||||||
|
[HttpPost("mission/{missionId:guid}/claim")]
|
||||||
|
public async Task<IActionResult> 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);
|
||||||
|
|
||||||
|
/// <summary>Admin: force distribute rewards regardless of previous state (may duplicate). Use cautiously.</summary>
|
||||||
|
[HttpPost("mission/{missionId:guid}/force-distribute")]
|
||||||
|
[Authorize(Roles = "Admin")]
|
||||||
|
public async Task<IActionResult> ForceDistribute(Guid missionId, ForceDistributeRequest req)
|
||||||
|
{
|
||||||
|
await _rewardService.DistributeMissionRewardsAsync(missionId, req.PlayerId);
|
||||||
|
return Ok(new { missionId, req.PlayerId, status = "forced" });
|
||||||
|
}
|
||||||
|
}
|
||||||
79
LctMonolith/Controllers/SkillsController.cs
Normal file
79
LctMonolith/Controllers/SkillsController.cs
Normal file
@@ -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<IActionResult> GetAll()
|
||||||
|
{
|
||||||
|
var list = await _skillService.GetAllSkillsAsync();
|
||||||
|
return Ok(list);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("{id:guid}")]
|
||||||
|
public async Task<IActionResult> Get(Guid id)
|
||||||
|
{
|
||||||
|
var s = await _skillService.GetSkillByIdAsync(id);
|
||||||
|
return s == null ? NotFound() : Ok(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost]
|
||||||
|
[Authorize(Roles = "Admin")]
|
||||||
|
public async Task<IActionResult> 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<IActionResult> 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<IActionResult> Delete(Guid id)
|
||||||
|
{
|
||||||
|
var ok = await _skillService.DeleteSkillAsync(id);
|
||||||
|
return ok ? NoContent() : NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("player/{playerId:guid}")]
|
||||||
|
public async Task<IActionResult> 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<IActionResult> 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -45,10 +45,24 @@ public class AppDbContext : IdentityDbContext<AppUser, IdentityRole<Guid>, Guid>
|
|||||||
public DbSet<RefreshToken> RefreshTokens => Set<RefreshToken>();
|
public DbSet<RefreshToken> RefreshTokens => Set<RefreshToken>();
|
||||||
public DbSet<Notification> Notifications => Set<Notification>();
|
public DbSet<Notification> Notifications => Set<Notification>();
|
||||||
|
|
||||||
|
// Core profile / player chain
|
||||||
|
public DbSet<Player> Players => Set<Player>();
|
||||||
|
public DbSet<Profile> Profiles => Set<Profile>();
|
||||||
|
|
||||||
protected override void OnModelCreating(ModelBuilder b)
|
protected override void OnModelCreating(ModelBuilder b)
|
||||||
{
|
{
|
||||||
base.OnModelCreating(b);
|
base.OnModelCreating(b);
|
||||||
|
|
||||||
|
// Player configuration
|
||||||
|
b.Entity<Player>()
|
||||||
|
.HasIndex(p => p.UserId)
|
||||||
|
.IsUnique();
|
||||||
|
b.Entity<Player>()
|
||||||
|
.HasOne<AppUser>()
|
||||||
|
.WithOne()
|
||||||
|
.HasForeignKey<Player>(p => p.UserId)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
// Rank configurations
|
// Rank configurations
|
||||||
b.Entity<Rank>()
|
b.Entity<Rank>()
|
||||||
.HasIndex(r => r.ExpNeeded)
|
.HasIndex(r => r.ExpNeeded)
|
||||||
@@ -201,7 +215,6 @@ public class AppDbContext : IdentityDbContext<AppUser, IdentityRole<Guid>, Guid>
|
|||||||
b.Entity<RefreshToken>().HasIndex(x => x.Token).IsUnique();
|
b.Entity<RefreshToken>().HasIndex(x => x.Token).IsUnique();
|
||||||
|
|
||||||
// ---------- Performance indexes ----------
|
// ---------- Performance indexes ----------
|
||||||
b.Entity<AppUser>().HasIndex(u => u.RankId);
|
|
||||||
b.Entity<PlayerSkill>().HasIndex(ps => ps.SkillId);
|
b.Entity<PlayerSkill>().HasIndex(ps => ps.SkillId);
|
||||||
b.Entity<EventLog>().HasIndex(e => new { e.UserId, e.Type, e.CreatedAt });
|
b.Entity<EventLog>().HasIndex(e => new { e.UserId, e.Type, e.CreatedAt });
|
||||||
b.Entity<StoreItem>().HasIndex(i => i.IsActive);
|
b.Entity<StoreItem>().HasIndex(i => i.IsActive);
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ namespace LctMonolith.Database.UnitOfWork;
|
|||||||
public interface IUnitOfWork
|
public interface IUnitOfWork
|
||||||
{
|
{
|
||||||
IGenericRepository<AppUser> Users { get; }
|
IGenericRepository<AppUser> Users { get; }
|
||||||
|
IGenericRepository<Player> Players { get; }
|
||||||
|
IGenericRepository<MissionCategory> MissionCategories { get; } // added
|
||||||
IGenericRepository<Rank> Ranks { get; }
|
IGenericRepository<Rank> Ranks { get; }
|
||||||
IGenericRepository<RankMissionRule> RankMissionRules { get; }
|
IGenericRepository<RankMissionRule> RankMissionRules { get; }
|
||||||
IGenericRepository<RankSkillRule> RankSkillRules { get; }
|
IGenericRepository<RankSkillRule> RankSkillRules { get; }
|
||||||
@@ -24,6 +26,12 @@ public interface IUnitOfWork
|
|||||||
IGenericRepository<EventLog> EventLogs { get; }
|
IGenericRepository<EventLog> EventLogs { get; }
|
||||||
IGenericRepository<RefreshToken> RefreshTokens { get; }
|
IGenericRepository<RefreshToken> RefreshTokens { get; }
|
||||||
IGenericRepository<Notification> Notifications { get; }
|
IGenericRepository<Notification> Notifications { get; }
|
||||||
|
IGenericRepository<MissionItemReward> MissionItemRewards { get; } // added
|
||||||
|
IGenericRepository<MissionRankRule> MissionRankRules { get; } // added
|
||||||
|
IGenericRepository<Dialogue> Dialogues { get; }
|
||||||
|
IGenericRepository<DialogueMessage> DialogueMessages { get; }
|
||||||
|
IGenericRepository<DialogueMessageResponseOption> DialogueMessageResponseOptions { get; }
|
||||||
|
IGenericRepository<Profile> Profiles { get; }
|
||||||
|
|
||||||
Task<int> SaveChangesAsync(CancellationToken ct = default);
|
Task<int> SaveChangesAsync(CancellationToken ct = default);
|
||||||
Task BeginTransactionAsync(CancellationToken ct = default);
|
Task BeginTransactionAsync(CancellationToken ct = default);
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
|
|
||||||
using LctMonolith.Database.Data;
|
using LctMonolith.Database.Data;
|
||||||
using LctMonolith.Database.Repositories;
|
using LctMonolith.Database.Repositories;
|
||||||
using LctMonolith.Models.Database;
|
using LctMonolith.Models.Database;
|
||||||
@@ -35,6 +34,14 @@ public class UnitOfWork : IUnitOfWork, IAsyncDisposable
|
|||||||
private IGenericRepository<EventLog>? _eventLogs;
|
private IGenericRepository<EventLog>? _eventLogs;
|
||||||
private IGenericRepository<RefreshToken>? _refreshTokens;
|
private IGenericRepository<RefreshToken>? _refreshTokens;
|
||||||
private IGenericRepository<Notification>? _notifications;
|
private IGenericRepository<Notification>? _notifications;
|
||||||
|
private IGenericRepository<Player>? _players;
|
||||||
|
private IGenericRepository<MissionCategory>? _missionCategories;
|
||||||
|
private IGenericRepository<MissionItemReward>? _missionItemRewards;
|
||||||
|
private IGenericRepository<MissionRankRule>? _missionRankRules;
|
||||||
|
private IGenericRepository<Dialogue>? _dialogues;
|
||||||
|
private IGenericRepository<DialogueMessage>? _dialogueMessages;
|
||||||
|
private IGenericRepository<DialogueMessageResponseOption>? _dialogueMessageResponseOptions;
|
||||||
|
private IGenericRepository<Profile>? _profiles;
|
||||||
|
|
||||||
public IGenericRepository<AppUser> Users => _users ??= new GenericRepository<AppUser>(_ctx);
|
public IGenericRepository<AppUser> Users => _users ??= new GenericRepository<AppUser>(_ctx);
|
||||||
public IGenericRepository<Rank> Ranks => _ranks ??= new GenericRepository<Rank>(_ctx);
|
public IGenericRepository<Rank> Ranks => _ranks ??= new GenericRepository<Rank>(_ctx);
|
||||||
@@ -51,6 +58,14 @@ public class UnitOfWork : IUnitOfWork, IAsyncDisposable
|
|||||||
public IGenericRepository<EventLog> EventLogs => _eventLogs ??= new GenericRepository<EventLog>(_ctx);
|
public IGenericRepository<EventLog> EventLogs => _eventLogs ??= new GenericRepository<EventLog>(_ctx);
|
||||||
public IGenericRepository<RefreshToken> RefreshTokens => _refreshTokens ??= new GenericRepository<RefreshToken>(_ctx);
|
public IGenericRepository<RefreshToken> RefreshTokens => _refreshTokens ??= new GenericRepository<RefreshToken>(_ctx);
|
||||||
public IGenericRepository<Notification> Notifications => _notifications ??= new GenericRepository<Notification>(_ctx);
|
public IGenericRepository<Notification> Notifications => _notifications ??= new GenericRepository<Notification>(_ctx);
|
||||||
|
public IGenericRepository<Player> Players => _players ??= new GenericRepository<Player>(_ctx);
|
||||||
|
public IGenericRepository<MissionCategory> MissionCategories => _missionCategories ??= new GenericRepository<MissionCategory>(_ctx);
|
||||||
|
public IGenericRepository<MissionItemReward> MissionItemRewards => _missionItemRewards ??= new GenericRepository<MissionItemReward>(_ctx);
|
||||||
|
public IGenericRepository<MissionRankRule> MissionRankRules => _missionRankRules ??= new GenericRepository<MissionRankRule>(_ctx);
|
||||||
|
public IGenericRepository<Dialogue> Dialogues => _dialogues ??= new GenericRepository<Dialogue>(_ctx);
|
||||||
|
public IGenericRepository<DialogueMessage> DialogueMessages => _dialogueMessages ??= new GenericRepository<DialogueMessage>(_ctx);
|
||||||
|
public IGenericRepository<DialogueMessageResponseOption> DialogueMessageResponseOptions => _dialogueMessageResponseOptions ??= new GenericRepository<DialogueMessageResponseOption>(_ctx);
|
||||||
|
public IGenericRepository<Profile> Profiles => _profiles ??= new GenericRepository<Profile>(_ctx);
|
||||||
|
|
||||||
public Task<int> SaveChangesAsync(CancellationToken ct = default) => _ctx.SaveChangesAsync(ct);
|
public Task<int> SaveChangesAsync(CancellationToken ct = default) => _ctx.SaveChangesAsync(ct);
|
||||||
|
|
||||||
|
|||||||
3
LctMonolith/Models/DTO/CreateMissionCategoryDto.cs
Normal file
3
LctMonolith/Models/DTO/CreateMissionCategoryDto.cs
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
namespace LctMonolith.Models.DTO;
|
||||||
|
public class CreateMissionCategoryDto { public string Title { get; set; } = string.Empty; }
|
||||||
|
|
||||||
12
LctMonolith/Models/DTO/CreateMissionDto.cs
Normal file
12
LctMonolith/Models/DTO/CreateMissionDto.cs
Normal file
@@ -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; }
|
||||||
|
}
|
||||||
|
|
||||||
3
LctMonolith/Models/DTO/CreateRankDto.cs
Normal file
3
LctMonolith/Models/DTO/CreateRankDto.cs
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
namespace LctMonolith.Models.DTO;
|
||||||
|
public class CreateRankDto { public string Title { get; set; } = string.Empty; public int ExpNeeded { get; set; } }
|
||||||
|
|
||||||
3
LctMonolith/Models/DTO/CreateSkillDto.cs
Normal file
3
LctMonolith/Models/DTO/CreateSkillDto.cs
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
namespace LctMonolith.Models.DTO;
|
||||||
|
public class CreateSkillDto { public string Title { get; set; } = string.Empty; }
|
||||||
|
|
||||||
@@ -6,12 +6,11 @@ public class Mission
|
|||||||
public string Title { get; set; } = string.Empty;
|
public string Title { get; set; } = string.Empty;
|
||||||
public string Description { get; set; } = string.Empty;
|
public string Description { get; set; } = string.Empty;
|
||||||
public MissionCategory? MissionCategory { get; set; }
|
public MissionCategory? MissionCategory { get; set; }
|
||||||
public long MissionCategoryId { get; set; }
|
public Guid MissionCategoryId { get; set; } // changed from long
|
||||||
public Mission? ParentMission { get; set; }
|
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 ExpReward { get; set; }
|
||||||
public int ManaReward { get; set; }
|
public int ManaReward { get; set; }
|
||||||
|
|
||||||
public Guid DialogueId { get; set; }
|
public Guid DialogueId { get; set; }
|
||||||
public Dialogue? Dialogue { get; set; }
|
public Dialogue? Dialogue { get; set; }
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ public class MissionSkillReward
|
|||||||
public Guid Id { get; set; }
|
public Guid Id { get; set; }
|
||||||
public Guid MissionId { get; set; }
|
public Guid MissionId { get; set; }
|
||||||
public Mission Mission { get; set; } = null!;
|
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 Skill Skill { get; set; } = null!;
|
||||||
public int Value { get; set; }
|
public int Value { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,10 +3,11 @@ namespace LctMonolith.Models.Database;
|
|||||||
public class Player
|
public class Player
|
||||||
{
|
{
|
||||||
public Guid Id { get; set; }
|
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 Rank? Rank { get; set; }
|
||||||
public Guid RankId {get; set; }
|
|
||||||
public int Experience { get; set; }
|
public int Experience { get; set; }
|
||||||
|
public int Mana { get; set; }
|
||||||
|
|
||||||
public ICollection<PlayerMission> PlayerMissions { get; set; } = new List<PlayerMission>();
|
public ICollection<PlayerMission> PlayerMissions { get; set; } = new List<PlayerMission>();
|
||||||
public ICollection<PlayerSkill> PlayerSkills { get; set; } = new List<PlayerSkill>();
|
public ICollection<PlayerSkill> PlayerSkills { get; set; } = new List<PlayerSkill>();
|
||||||
|
|||||||
@@ -4,10 +4,11 @@ public class PlayerMission
|
|||||||
{
|
{
|
||||||
public Guid Id { get; set; }
|
public Guid Id { get; set; }
|
||||||
public Guid PlayerId { 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 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? Started { get; set; }
|
||||||
public DateTime? Completed { get; set; }
|
public DateTime? Completed { get; set; }
|
||||||
public DateTime? RewardsRedeemed { get; set; }
|
public DateTime? RewardsRedeemed { get; set; }
|
||||||
|
public int ProgressPercent { get; set; } // 0..100
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ using LctMonolith.Application.Options;
|
|||||||
using LctMonolith.Database.Data;
|
using LctMonolith.Database.Data;
|
||||||
using LctMonolith.Database.UnitOfWork;
|
using LctMonolith.Database.UnitOfWork;
|
||||||
using LctMonolith.Services.Contracts; // Added for JwtOptions
|
using LctMonolith.Services.Contracts; // Added for JwtOptions
|
||||||
|
using LctMonolith.Application.Extensions; // added
|
||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
@@ -90,14 +91,14 @@ builder.Services.AddOpenApi();
|
|||||||
// Health checks
|
// Health checks
|
||||||
builder.Services.AddHealthChecks();
|
builder.Services.AddHealthChecks();
|
||||||
|
|
||||||
// UnitOfWork
|
// Remove individual service registrations and replace with extension
|
||||||
builder.Services.AddScoped<IUnitOfWork, UnitOfWork>();
|
// builder.Services.AddScoped<IUnitOfWork, UnitOfWork>();
|
||||||
|
// builder.Services.AddScoped<ITokenService, TokenService>();
|
||||||
|
// builder.Services.AddScoped<IStoreService, StoreService>();
|
||||||
|
// builder.Services.AddScoped<INotificationService, NotificationService>();
|
||||||
|
// builder.Services.AddScoped<IAnalyticsService, AnalyticsService>();
|
||||||
|
|
||||||
// Domain services
|
builder.Services.AddApplicationServices(builder.Configuration);
|
||||||
builder.Services.AddScoped<ITokenService, TokenService>();
|
|
||||||
builder.Services.AddScoped<IStoreService, StoreService>();
|
|
||||||
builder.Services.AddScoped<INotificationService, NotificationService>();
|
|
||||||
builder.Services.AddScoped<IAnalyticsService, AnalyticsService>();
|
|
||||||
|
|
||||||
// CORS
|
// CORS
|
||||||
builder.Services.AddCors(p => p.AddDefaultPolicy(policy =>
|
builder.Services.AddCors(p => p.AddDefaultPolicy(policy =>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using LctMonolith.Database.UnitOfWork;
|
|||||||
using LctMonolith.Models.Database;
|
using LctMonolith.Models.Database;
|
||||||
using LctMonolith.Services.Models;
|
using LctMonolith.Services.Models;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Serilog;
|
||||||
|
|
||||||
namespace LctMonolith.Services;
|
namespace LctMonolith.Services;
|
||||||
|
|
||||||
@@ -15,16 +16,26 @@ public class AnalyticsService : IAnalyticsService
|
|||||||
|
|
||||||
public async Task<AnalyticsSummary> GetSummaryAsync(CancellationToken ct = default)
|
public async Task<AnalyticsSummary> GetSummaryAsync(CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var totalUsers = await _uow.Users.Query().CountAsync(ct);
|
try
|
||||||
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
|
|
||||||
{
|
{
|
||||||
TotalUsers = totalUsers,
|
var totalUsers = await _uow.Users.Query().CountAsync(ct);
|
||||||
TotalMissions = totalMissions,
|
var totalMissions = await _uow.Missions.Query().CountAsync(ct);
|
||||||
TotalStoreItems = totalStoreItems,
|
var totalStoreItems = await _uow.StoreItems.Query().CountAsync(ct);
|
||||||
TotalExperience = totalExperience
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
55
LctMonolith/Services/DialogueService.cs
Normal file
55
LctMonolith/Services/DialogueService.cs
Normal file
@@ -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<Dialogue?> 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<Dialogue> 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<DialogueMessage?> GetDialogueMessageByIdAsync(Guid messageId)
|
||||||
|
{
|
||||||
|
try { return await _uow.DialogueMessages.GetByIdAsync(messageId); }
|
||||||
|
catch (Exception ex) { Log.Error(ex, "GetDialogueMessageByIdAsync failed {MessageId}", messageId); throw; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IEnumerable<DialogueMessageResponseOption>> 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<DialogueMessage?> 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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
26
LctMonolith/Services/InventoryService.cs
Normal file
26
LctMonolith/Services/InventoryService.cs
Normal file
@@ -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<IEnumerable<UserInventoryItem>> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
49
LctMonolith/Services/MissionCategoryService.cs
Normal file
49
LctMonolith/Services/MissionCategoryService.cs
Normal file
@@ -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<MissionCategory?> GetCategoryByIdAsync(Guid categoryId)
|
||||||
|
{
|
||||||
|
try { return await _uow.MissionCategories.GetByIdAsync(categoryId); }
|
||||||
|
catch (Exception ex) { Log.Error(ex, "GetCategoryByIdAsync failed {CategoryId}", categoryId); throw; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<MissionCategory?> 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<IEnumerable<MissionCategory>> GetAllCategoriesAsync()
|
||||||
|
{
|
||||||
|
try { return await _uow.MissionCategories.Query().OrderBy(c => c.Title).ToListAsync(); }
|
||||||
|
catch (Exception ex) { Log.Error(ex, "GetAllCategoriesAsync failed"); throw; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<MissionCategory> 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<MissionCategory> 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<bool> 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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
165
LctMonolith/Services/MissionService.cs
Normal file
165
LctMonolith/Services/MissionService.cs
Normal file
@@ -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<Mission?> GetMissionByIdAsync(Guid missionId)
|
||||||
|
{
|
||||||
|
try { return await _uow.Missions.GetByIdAsync(missionId); }
|
||||||
|
catch (Exception ex) { Log.Error(ex, "GetMissionByIdAsync failed {MissionId}", missionId); throw; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IEnumerable<Mission>> 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<IEnumerable<Mission>> GetAvailableMissionsForPlayerAsync(Guid playerId)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var missions = await _uow.Missions.Query().ToListAsync();
|
||||||
|
var result = new List<Mission>();
|
||||||
|
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<IEnumerable<Mission>> 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<Mission> 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<Mission> 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<bool> 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<bool> 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<MissionCompletionResult> 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<SkillProgress>();
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,7 +5,6 @@ public class AnalyticsSummary
|
|||||||
public int TotalUsers { get; set; }
|
public int TotalUsers { get; set; }
|
||||||
public int TotalMissions { get; set; }
|
public int TotalMissions { get; set; }
|
||||||
public int CompletedMissions { get; set; }
|
public int CompletedMissions { get; set; }
|
||||||
public int TotalArtifacts { get; set; }
|
|
||||||
public int TotalStoreItems { get; set; }
|
public int TotalStoreItems { get; set; }
|
||||||
public long TotalExperience { get; set; }
|
public long TotalExperience { get; set; }
|
||||||
public DateTime GeneratedAtUtc { get; set; } = DateTime.UtcNow;
|
public DateTime GeneratedAtUtc { get; set; } = DateTime.UtcNow;
|
||||||
|
|||||||
@@ -12,5 +12,4 @@ public class CreateMissionModel
|
|||||||
public int ExperienceReward { get; set; }
|
public int ExperienceReward { get; set; }
|
||||||
public int ManaReward { get; set; }
|
public int ManaReward { get; set; }
|
||||||
public List<CompetencyRewardModel> CompetencyRewards { get; set; } = new();
|
public List<CompetencyRewardModel> CompetencyRewards { get; set; } = new();
|
||||||
public List<Guid> ArtifactRewardIds { get; set; } = new();
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using LctMonolith.Database.UnitOfWork;
|
|||||||
using LctMonolith.Models.Database;
|
using LctMonolith.Models.Database;
|
||||||
using LctMonolith.Services.Contracts;
|
using LctMonolith.Services.Contracts;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Serilog;
|
||||||
|
|
||||||
namespace LctMonolith.Services;
|
namespace LctMonolith.Services;
|
||||||
|
|
||||||
@@ -19,61 +20,49 @@ public class NotificationService : INotificationService
|
|||||||
|
|
||||||
public async Task<Notification> CreateAsync(Guid userId, string type, string title, string message, CancellationToken ct = default)
|
public async Task<Notification> CreateAsync(Guid userId, string type, string title, string message, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(type)) throw new ArgumentException("Required", nameof(type));
|
try
|
||||||
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,
|
if (string.IsNullOrWhiteSpace(type)) throw new ArgumentException("Required", nameof(type));
|
||||||
Type = type.Trim(),
|
if (string.IsNullOrWhiteSpace(title)) throw new ArgumentException("Required", nameof(title));
|
||||||
Title = title.Trim(),
|
if (string.IsNullOrWhiteSpace(message)) throw new ArgumentException("Required", nameof(message));
|
||||||
Message = message.Trim(),
|
|
||||||
CreatedAt = DateTime.UtcNow,
|
var n = new Notification
|
||||||
IsRead = false
|
{
|
||||||
};
|
UserId = userId,
|
||||||
await _uow.Notifications.AddAsync(n, ct);
|
Type = type.Trim(),
|
||||||
await _uow.SaveChangesAsync(ct);
|
Title = title.Trim(),
|
||||||
return n;
|
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<IEnumerable<Notification>> GetUnreadAsync(Guid userId, CancellationToken ct = default)
|
public async Task<IEnumerable<Notification>> GetUnreadAsync(Guid userId, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var query = _uow.Notifications.Query(n => n.UserId == userId && !n.IsRead, q => q.OrderByDescending(x => x.CreatedAt));
|
try { return await _uow.Notifications.Query(n => n.UserId == userId && !n.IsRead, q => q.OrderByDescending(x => x.CreatedAt)).Take(100).ToListAsync(ct); }
|
||||||
return await query.Take(100).ToListAsync(ct);
|
catch (Exception ex) { Log.Error(ex, "GetUnreadAsync failed {UserId}", userId); throw; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<IEnumerable<Notification>> GetAllAsync(Guid userId, int take = 100, CancellationToken ct = default)
|
public async Task<IEnumerable<Notification>> GetAllAsync(Guid userId, int take = 100, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
if (take <= 0) take = 1;
|
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); }
|
||||||
if (take > 500) take = 500;
|
catch (Exception ex) { Log.Error(ex, "GetAllAsync failed {UserId}", userId); throw; }
|
||||||
var query = _uow.Notifications.Query(n => n.UserId == userId, q => q.OrderByDescending(x => x.CreatedAt));
|
|
||||||
return await query.Take(take).ToListAsync(ct);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task MarkReadAsync(Guid userId, Guid notificationId, CancellationToken ct = default)
|
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)
|
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); } }
|
||||||
?? throw new KeyNotFoundException("Notification not found");
|
catch (Exception ex) { Log.Error(ex, "MarkReadAsync failed {NotificationId}", notificationId); throw; }
|
||||||
if (!notif.IsRead)
|
|
||||||
{
|
|
||||||
notif.IsRead = true;
|
|
||||||
notif.ReadAt = DateTime.UtcNow;
|
|
||||||
await _uow.SaveChangesAsync(ct);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<int> MarkAllReadAsync(Guid userId, CancellationToken ct = default)
|
public async Task<int> MarkAllReadAsync(Guid userId, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var unread = await _uow.Notifications.Query(n => n.UserId == userId && !n.IsRead).ToListAsync(ct);
|
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; }
|
||||||
if (unread.Count == 0) return 0;
|
catch (Exception ex) { Log.Error(ex, "MarkAllReadAsync failed {UserId}", userId); throw; }
|
||||||
var now = DateTime.UtcNow;
|
|
||||||
foreach (var n in unread)
|
|
||||||
{
|
|
||||||
n.IsRead = true;
|
|
||||||
n.ReadAt = now;
|
|
||||||
}
|
|
||||||
await _uow.SaveChangesAsync(ct);
|
|
||||||
return unread.Count;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
153
LctMonolith/Services/PlayerService.cs
Normal file
153
LctMonolith/Services/PlayerService.cs
Normal file
@@ -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<Player?> 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<Player> 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<Player> 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<Player> 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<Player> 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<IEnumerable<Player>> 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<Player> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
103
LctMonolith/Services/ProgressTrackingService.cs
Normal file
103
LctMonolith/Services/ProgressTrackingService.cs
Normal file
@@ -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<PlayerMission> 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<PlayerMission> 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<PlayerMission> 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<IEnumerable<PlayerMission>> 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<PlayerMission?> 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<PlayerProgress> 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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
75
LctMonolith/Services/RankService.cs
Normal file
75
LctMonolith/Services/RankService.cs
Normal file
@@ -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<Rank?> GetRankByIdAsync(Guid rankId)
|
||||||
|
{
|
||||||
|
try { return await _uow.Ranks.GetByIdAsync(rankId); }
|
||||||
|
catch (Exception ex) { Log.Error(ex, "GetRankByIdAsync failed {RankId}", rankId); throw; }
|
||||||
|
}
|
||||||
|
public async Task<Rank?> 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<IEnumerable<Rank>> GetAllRanksAsync()
|
||||||
|
{
|
||||||
|
try { return await _uow.Ranks.Query().OrderBy(r => r.ExpNeeded).ToListAsync(); }
|
||||||
|
catch (Exception ex) { Log.Error(ex, "GetAllRanksAsync failed"); throw; }
|
||||||
|
}
|
||||||
|
public async Task<Rank> 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<Rank> 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<bool> 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<bool> 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<Rank?> 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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
92
LctMonolith/Services/RewardService.cs
Normal file
92
LctMonolith/Services/RewardService.cs
Normal file
@@ -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<IEnumerable<MissionSkillReward>> 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<IEnumerable<MissionItemReward>> 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<bool> 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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
64
LctMonolith/Services/RuleValidationService.cs
Normal file
64
LctMonolith/Services/RuleValidationService.cs
Normal file
@@ -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<bool> 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<bool> 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<IEnumerable<MissionRankRule>> 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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
59
LctMonolith/Services/SkillService.cs
Normal file
59
LctMonolith/Services/SkillService.cs
Normal file
@@ -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<Skill?> GetSkillByIdAsync(Guid skillId)
|
||||||
|
{
|
||||||
|
try { return await _uow.Skills.GetByIdAsync(skillId); }
|
||||||
|
catch (Exception ex) { Log.Error(ex, "GetSkillByIdAsync failed {SkillId}", skillId); throw; }
|
||||||
|
}
|
||||||
|
public async Task<Skill?> 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<IEnumerable<Skill>> GetAllSkillsAsync()
|
||||||
|
{
|
||||||
|
try { return await _uow.Skills.Query().OrderBy(s => s.Title).ToListAsync(); }
|
||||||
|
catch (Exception ex) { Log.Error(ex, "GetAllSkillsAsync failed"); throw; }
|
||||||
|
}
|
||||||
|
public async Task<Skill> 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<Skill> 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<bool> 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<PlayerSkill> 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<IEnumerable<PlayerSkill>> 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<int> 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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,55 +21,42 @@ public class StoreService : IStoreService
|
|||||||
|
|
||||||
public async Task<IEnumerable<StoreItem>> GetActiveItemsAsync(CancellationToken ct = default)
|
public async Task<IEnumerable<StoreItem>> 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<UserInventoryItem> PurchaseAsync(Guid userId, Guid itemId, int quantity, CancellationToken ct = default)
|
public async Task<UserInventoryItem> PurchaseAsync(Guid userId, Guid itemId, int quantity, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
if (quantity <= 0) throw new ArgumentOutOfRangeException(nameof(quantity));
|
try
|
||||||
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)
|
|
||||||
{
|
{
|
||||||
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,
|
inv = new UserInventoryItem { UserId = userId, StoreItemId = itemId, Quantity = quantity, AcquiredAt = DateTime.UtcNow };
|
||||||
StoreItemId = itemId,
|
await _uow.UserInventoryItems.AddAsync(inv, ct);
|
||||||
Quantity = quantity,
|
}
|
||||||
AcquiredAt = DateTime.UtcNow
|
else inv.Quantity += quantity;
|
||||||
};
|
|
||||||
await _uow.UserInventoryItems.AddAsync(inv, ct);
|
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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ using LctMonolith.Services.Models;
|
|||||||
using Microsoft.AspNetCore.Identity;
|
using Microsoft.AspNetCore.Identity;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using Microsoft.IdentityModel.Tokens;
|
using Microsoft.IdentityModel.Tokens;
|
||||||
|
using Serilog;
|
||||||
|
|
||||||
namespace LctMonolith.Services;
|
namespace LctMonolith.Services;
|
||||||
|
|
||||||
@@ -34,54 +35,78 @@ public class TokenService : ITokenService
|
|||||||
|
|
||||||
public async Task<TokenPair> IssueAsync(AppUser user, CancellationToken ct = default)
|
public async Task<TokenPair> IssueAsync(AppUser user, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var now = DateTime.UtcNow;
|
try
|
||||||
var accessExp = now.AddMinutes(_options.AccessTokenMinutes);
|
|
||||||
var refreshExp = now.AddDays(_options.RefreshTokenDays);
|
|
||||||
var claims = new List<Claim>
|
|
||||||
{
|
{
|
||||||
new(JwtRegisteredClaimNames.Sub, user.Id.ToString()),
|
var now = DateTime.UtcNow;
|
||||||
new(ClaimTypes.NameIdentifier, user.Id.ToString()),
|
var accessExp = now.AddMinutes(_options.AccessTokenMinutes);
|
||||||
new(JwtRegisteredClaimNames.Email, user.Email ?? string.Empty),
|
var refreshExp = now.AddDays(_options.RefreshTokenDays);
|
||||||
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
|
var claims = new List<Claim>
|
||||||
};
|
{
|
||||||
var jwt = new JwtSecurityToken(
|
new(JwtRegisteredClaimNames.Sub, user.Id.ToString()),
|
||||||
issuer: _options.Issuer,
|
new(ClaimTypes.NameIdentifier, user.Id.ToString()),
|
||||||
audience: _options.Audience,
|
new(JwtRegisteredClaimNames.Email, user.Email ?? string.Empty),
|
||||||
claims: claims,
|
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
|
||||||
notBefore: now,
|
};
|
||||||
expires: accessExp,
|
var jwt = new JwtSecurityToken(
|
||||||
signingCredentials: _creds);
|
issuer: _options.Issuer,
|
||||||
var accessToken = new JwtSecurityTokenHandler().WriteToken(jwt);
|
audience: _options.Audience,
|
||||||
|
claims: claims,
|
||||||
|
notBefore: now,
|
||||||
|
expires: accessExp,
|
||||||
|
signingCredentials: _creds);
|
||||||
|
var accessToken = new JwtSecurityTokenHandler().WriteToken(jwt);
|
||||||
|
|
||||||
var refreshToken = GenerateSecureToken();
|
var refreshToken = GenerateSecureToken();
|
||||||
var rt = new RefreshToken
|
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,
|
Log.Error(ex, "IssueAsync failed for user {UserId}", user.Id);
|
||||||
UserId = user.Id,
|
throw;
|
||||||
ExpiresAt = refreshExp
|
}
|
||||||
};
|
|
||||||
await _uow.RefreshTokens.AddAsync(rt, ct);
|
|
||||||
await _uow.SaveChangesAsync(ct);
|
|
||||||
return new TokenPair(accessToken, accessExp, refreshToken, refreshExp);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<TokenPair> RefreshAsync(string refreshToken, CancellationToken ct = default)
|
public async Task<TokenPair> RefreshAsync(string refreshToken, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var token = await _uow.RefreshTokens.Query(r => r.Token == refreshToken).FirstOrDefaultAsync(ct);
|
try
|
||||||
if (token == null || token.IsRevoked || token.ExpiresAt < DateTime.UtcNow)
|
{
|
||||||
throw new SecurityTokenException("Invalid refresh token");
|
var token = await _uow.RefreshTokens.Query(r => r.Token == refreshToken).FirstOrDefaultAsync(ct);
|
||||||
var user = await _userManager.FindByIdAsync(token.UserId.ToString()) ?? throw new SecurityTokenException("User not found");
|
if (token == null || token.IsRevoked || token.ExpiresAt < DateTime.UtcNow)
|
||||||
token.IsRevoked = true; // rotate
|
throw new SecurityTokenException("Invalid refresh token");
|
||||||
await _uow.SaveChangesAsync(ct);
|
var user = await _userManager.FindByIdAsync(token.UserId.ToString()) ?? throw new SecurityTokenException("User not found");
|
||||||
return await IssueAsync(user, ct);
|
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)
|
public async Task RevokeAsync(string refreshToken, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var token = await _uow.RefreshTokens.Query(r => r.Token == refreshToken).FirstOrDefaultAsync(ct);
|
try
|
||||||
if (token == null) return; // idempotent
|
{
|
||||||
token.IsRevoked = true;
|
var token = await _uow.RefreshTokens.Query(r => r.Token == refreshToken).FirstOrDefaultAsync(ct);
|
||||||
await _uow.SaveChangesAsync(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()
|
private static string GenerateSecureToken()
|
||||||
|
|||||||
Reference in New Issue
Block a user