using Microsoft.EntityFrameworkCore; using Nuuru.Server.Data; using Nuuru.Server.DTOs.Boints; using Nuuru.Server.Models; using Nuuru.Server.Services.ShopItems; namespace Nuuru.Server.Services { public interface IBointsService { Task CreditAsync(Guid userId, BointsReason reason, int amount, int? sourcePostId = null, int? sourceCommentId = null, int? sourceForumPostId = null, Guid? sourceUserId = null); Task DebitAsync(Guid userId, int amount, BointsReason reason, Guid? sourceUserId = null); Task RevokePostCreditsAsync(int postId); Task GetBalanceAsync(Guid userId); Task<(IEnumerable Items, int TotalCount)> GetLedgerAsync(Guid userId, int page, int pageSize); Task AdminAdjustAsync(Guid userId, int amount, Guid adminUserId); Task GetBalanceByUsernameAsync(string username); Task PurchaseItemAsync(Guid userId, string itemId, Guid? targetUserId = null, int? targetPostId = null, string? content = null); Task> GetShopItemsAsync(); Task> GetInventoryAsync(Guid userId); Task UseInventoryItemAsync(Guid userId, string itemId, Guid? targetUserId = null, int? targetPostId = null, string? content = null); Task> GetActiveEffectsAsync(); Task TransferAsync(Guid fromUserId, Guid toUserId, int amount); Task IsEnabledAsync(); Task IsClansEnabledAsync(); Task GetModeAsync(); } public class BointsService : IBointsService { private readonly ApplicationDbContext _context; private readonly ISiteSettingsService _siteSettings; private readonly ILogger _logger; private readonly Dictionary _shopItems; public BointsService( ApplicationDbContext context, ISiteSettingsService siteSettings, ILogger logger) { _context = context; _siteSettings = siteSettings; _logger = logger; // Register all shop items IShopItem[] items = [ new GoldenFrameItem(context), new ProfileBorderItem(context), new RenameUserItem(context), new RenameImmunityItem(context), new SiteBannerItem(context), new BoostPostItem(context), new SlotMachineItem(context), new PissItem(context), ]; _shopItems = items.ToDictionary(i => i.Id); } public async Task GetBalanceByUsernameAsync(string username) { return await _context.Users .Where(u => u.UserName == username) .Select(u => u.Boints) .FirstOrDefaultAsync(); } public async Task PurchaseItemAsync(Guid userId, string itemId, Guid? targetUserId = null, int? targetPostId = null, string? content = null) { if (!await IsEnabledAsync()) return ShopItemResult.Fail("Boints are not enabled."); if (!_shopItems.TryGetValue(itemId, out var shopItem)) return ShopItemResult.Fail("Item not found."); var mode = await GetModeAsync(); var def = shopItem.Definition; if (def.Mode == "chaos" && mode != "chaos") return ShopItemResult.Fail("Item only available in chaos mode."); var user = await _context.Users.FindAsync(userId); if (user == null) return ShopItemResult.Fail("User not found."); if (def.Cost > 0 && user.Boints < def.Cost) return ShopItemResult.Fail("Insufficient boints."); ShopItemResult? instantResult = null; if (def.SkipsInventory) { var ctx = new ShopItemContext { UserId = userId, TargetUserId = targetUserId, TargetPostId = targetPostId, Content = content, Mode = mode }; instantResult = await shopItem.ApplyAsync(ctx); if (!instantResult.Success) return instantResult; } else { // Inventory items: add to inventory var existing = await _context.UserInventoryItems .FirstOrDefaultAsync(i => i.UserId == userId && i.ItemId == itemId); if (existing != null) { existing.Quantity++; existing.UpdatedAt = DateTime.UtcNow; } else { _context.UserInventoryItems.Add(new UserInventoryItem { UserId = userId, ItemId = itemId }); } } if (def.Cost > 0) { user.Boints -= def.Cost; _context.BointsLedger.Add(new BointsLedger { UserId = userId, Amount = -def.Cost, Reason = BointsReason.Purchase, SourceUserId = targetUserId }); } await _context.SaveChangesAsync(); _logger.LogInformation("User {UserId} purchased {ItemId} for {Cost} boints", userId, itemId, def.Cost); return instantResult ?? ShopItemResult.Ok(new Dictionary { ["addedToInventory"] = true }); } public async Task> GetInventoryAsync(Guid userId) { var items = await _context.UserInventoryItems .Where(i => i.UserId == userId && i.Quantity > 0) .Select(i => new InventoryItemDto { ItemId = i.ItemId, Quantity = i.Quantity, AcquiredAt = i.AcquiredAt }) .ToListAsync(); foreach (var item in items) { if (_shopItems.TryGetValue(item.ItemId, out var shopItem)) { item.Name = shopItem.Definition.Name; item.Description = shopItem.Definition.Description; item.TargetType = shopItem.Definition.TargetType; } } return items; } public async Task UseInventoryItemAsync(Guid userId, string itemId, Guid? targetUserId = null, int? targetPostId = null, string? content = null) { if (!await IsEnabledAsync()) return ShopItemResult.Fail("Boints are not enabled."); if (!_shopItems.TryGetValue(itemId, out var shopItem)) return ShopItemResult.Fail("Item not found."); if (shopItem.Definition.SkipsInventory) return ShopItemResult.Fail("This item cannot be used from inventory."); var mode = await GetModeAsync(); if (shopItem.Definition.Mode == "chaos" && mode != "chaos") return ShopItemResult.Fail("Item only available in chaos mode."); var inventoryItem = await _context.UserInventoryItems .FirstOrDefaultAsync(i => i.UserId == userId && i.ItemId == itemId); if (inventoryItem == null || inventoryItem.Quantity <= 0) return ShopItemResult.Fail("You don't have this item."); var ctx = new ShopItemContext { UserId = userId, TargetUserId = targetUserId, TargetPostId = targetPostId, Content = content, Mode = mode }; var result = await shopItem.ApplyAsync(ctx); if (!result.Success) return result; inventoryItem.Quantity--; inventoryItem.UpdatedAt = DateTime.UtcNow; if (inventoryItem.Quantity <= 0) _context.UserInventoryItems.Remove(inventoryItem); await _context.SaveChangesAsync(); _logger.LogInformation("User {UserId} used {ItemId} from inventory", userId, itemId); return result; } public async Task> GetShopItemsAsync() { var mode = await GetModeAsync(); return _shopItems.Values .Select(i => i.Definition) .Where(d => d.Mode == "both" || d.Mode == mode) .ToList(); } public async Task> GetActiveEffectsAsync() { var now = DateTime.UtcNow; return await _context.SiteEffects .Where(e => e.ExpiresAt > now) .Select(e => new DTOs.Boints.SiteEffectDto { Type = e.Type.ToString(), Content = e.Content, ExpiresAt = e.ExpiresAt }) .ToListAsync(); } public async Task IsEnabledAsync() { return await _siteSettings.GetBoolAsync("boints.enabled", false); } public async Task IsClansEnabledAsync() { return await _siteSettings.GetBoolAsync("clans.enabled", false); } public async Task GetModeAsync() { return await _siteSettings.GetAsync("boints.mode") ?? "normal"; } public async Task TransferAsync(Guid fromUserId, Guid toUserId, int amount) { if (!await IsEnabledAsync()) return ServiceResult.Fail("Boints are not enabled."); if (amount <= 0) return ServiceResult.Fail("Amount must be positive."); if (fromUserId == toUserId) return ServiceResult.Fail("You cannot transfer boints to yourself."); var sender = await _context.Users.FindAsync(fromUserId); if (sender == null) return ServiceResult.Fail("Sender not found."); if (sender.Boints < amount) return ServiceResult.Fail("Insufficient boints."); var recipient = await _context.Users.FindAsync(toUserId); if (recipient == null) return ServiceResult.Fail("Recipient not found."); sender.Boints -= amount; recipient.Boints += amount; _context.BointsLedger.Add(new BointsLedger { UserId = fromUserId, Amount = -amount, Reason = BointsReason.Transfer, SourceUserId = toUserId }); _context.BointsLedger.Add(new BointsLedger { UserId = toUserId, Amount = amount, Reason = BointsReason.Transfer, SourceUserId = fromUserId }); await _context.SaveChangesAsync(); _logger.LogInformation("User {From} transferred {Amount} boints to {To}", fromUserId, amount, toUserId); return ServiceResult.Ok(); } public async Task CreditAsync(Guid userId, BointsReason reason, int amount, int? sourcePostId = null, int? sourceCommentId = null, int? sourceForumPostId = null, Guid? sourceUserId = null) { if (!await IsEnabledAsync()) return false; if (amount <= 0) return false; // Dedup check if (await IsDuplicateAsync(userId, reason, sourcePostId, sourceCommentId, sourceForumPostId, sourceUserId)) { _logger.LogDebug("Dedup: skipping credit for user {UserId} reason {Reason}", userId, reason); return false; } // Diminishing returns per user pair per 24h for reactions and favorites amount = await ApplyDiminishingReturnsAsync(userId, reason, amount, sourceUserId); if (amount <= 0) return false; // Apply clan tax with fractional accumulation var taxAmount = 0; var clanMember = await _context.ClanMembers .Include(m => m.Clan) .FirstOrDefaultAsync(m => m.UserId == userId); if (clanMember != null && clanMember.Clan.TaxRate > 0) { clanMember.TaxAccumulator += amount * clanMember.Clan.TaxRate / 100.0; if (clanMember.TaxAccumulator >= 1) { taxAmount = (int)Math.Floor(clanMember.TaxAccumulator); clanMember.TaxAccumulator -= taxAmount; clanMember.Clan.Treasury += taxAmount; } } var netAmount = amount - taxAmount; _context.BointsLedger.Add(new BointsLedger { UserId = userId, Amount = netAmount, Reason = reason, SourcePostId = sourcePostId, SourceCommentId = sourceCommentId, SourceForumPostId = sourceForumPostId, SourceUserId = sourceUserId }); var userEntity = await _context.Users.FindAsync(userId); if (userEntity != null) userEntity.Boints += netAmount; await _context.SaveChangesAsync(); _logger.LogInformation("Credited {Amount} boints to user {UserId} (reason: {Reason}, tax: {Tax})", netAmount, userId, reason, taxAmount); return true; } public async Task DebitAsync(Guid userId, int amount, BointsReason reason, Guid? sourceUserId = null) { if (amount <= 0) return false; var user = await _context.Users.FindAsync(userId); if (user == null) return false; // Clamp: don't go below 0 var actualDebit = Math.Min(amount, user.Boints); if (actualDebit <= 0) return true; // Nothing to debit _context.BointsLedger.Add(new BointsLedger { UserId = userId, Amount = -actualDebit, Reason = reason, SourceUserId = sourceUserId }); user.Boints -= actualDebit; await _context.SaveChangesAsync(); _logger.LogInformation("Debited {Amount} boints from user {UserId} (reason: {Reason})", actualDebit, userId, reason); return true; } public async Task RevokePostCreditsAsync(int postId) { if (!await IsEnabledAsync()) return; var credits = await _context.BointsLedger .Where(l => l.SourcePostId == postId && l.Amount > 0) .ToListAsync(); var userIds = credits.Select(c => c.UserId).Distinct().ToList(); var users = await _context.Users.Where(u => userIds.Contains(u.Id)).ToDictionaryAsync(u => u.Id); foreach (var credit in credits) { if (users.TryGetValue(credit.UserId, out var creditUser)) creditUser.Boints = Math.Max(0, creditUser.Boints - credit.Amount); _context.BointsLedger.Add(new BointsLedger { UserId = credit.UserId, Amount = -credit.Amount, Reason = BointsReason.PostTrashed, SourcePostId = postId }); } if (credits.Count > 0) { await _context.SaveChangesAsync(); _logger.LogInformation("Revoked {Count} credit entries for trashed post {PostId}", credits.Count, postId); } } public async Task GetBalanceAsync(Guid userId) { return await _context.Users .Where(u => u.Id == userId) .Select(u => u.Boints) .FirstOrDefaultAsync(); } public async Task<(IEnumerable Items, int TotalCount)> GetLedgerAsync(Guid userId, int page, int pageSize) { var query = _context.BointsLedger .Where(l => l.UserId == userId) .OrderByDescending(l => l.CreatedAt); var totalCount = await query.CountAsync(); var items = await query .Skip((page - 1) * pageSize) .Take(pageSize) .Select(l => new BointsLedgerDto { Id = l.Id, Amount = l.Amount, Reason = l.Reason.ToString(), CreatedAt = l.CreatedAt, SourcePostId = l.SourcePostId, SourceUserId = l.SourceUserId }) .ToListAsync(); return (items, totalCount); } public async Task AdminAdjustAsync(Guid userId, int amount, Guid adminUserId) { if (amount == 0) return false; var user = await _context.Users.FindAsync(userId); if (user == null) return false; if (amount < 0) { var actualDebit = Math.Min(Math.Abs(amount), user.Boints); user.Boints -= actualDebit; _context.BointsLedger.Add(new BointsLedger { UserId = userId, Amount = -actualDebit, Reason = BointsReason.AdminAdjustment, SourceUserId = adminUserId }); } else { user.Boints += amount; _context.BointsLedger.Add(new BointsLedger { UserId = userId, Amount = amount, Reason = BointsReason.AdminAdjustment, SourceUserId = adminUserId }); } await _context.SaveChangesAsync(); _logger.LogInformation("Admin {AdminId} adjusted user {UserId} boints by {Amount}", adminUserId, userId, amount); return true; } /// /// Diminishing returns for reactions and favorites per source user per 24h. /// 1st credit from user A: full amount. 2nd-3rd: half (rounded down). 4th+: 0. /// private async Task ApplyDiminishingReturnsAsync(Guid userId, BointsReason reason, int amount, Guid? sourceUserId) { if (reason is not (BointsReason.ReactionReceived or BointsReason.FavoriteReceived)) return amount; if (!sourceUserId.HasValue) return amount; var todayUtc = DateTime.UtcNow.Date; var creditsFromSameUser = await _context.BointsLedger.CountAsync(l => l.UserId == userId && l.Reason == reason && l.SourceUserId == sourceUserId && l.CreatedAt >= todayUtc); return creditsFromSameUser switch { 0 => amount, // 1st: full 1 or 2 => amount / 2, // 2nd-3rd: half (rounds down, so 1→0 for reactions, 3→1 for favorites) _ => 0 // 4th+: nothing }; } private async Task IsDuplicateAsync(Guid userId, BointsReason reason, int? sourcePostId, int? sourceCommentId, int? sourceForumPostId, Guid? sourceUserId) { return reason switch { // Reaction: one credit per reactor per 5 minutes BointsReason.ReactionReceived => await _context.BointsLedger.AnyAsync(l => l.UserId == userId && l.Reason == reason && l.SourceUserId == sourceUserId && l.CreatedAt >= DateTime.UtcNow.AddMinutes(-5)), // Favorite: one credit per source user per post BointsReason.FavoriteReceived => await _context.BointsLedger.AnyAsync(l => l.UserId == userId && l.Reason == reason && l.SourcePostId == sourcePostId && l.SourceUserId == sourceUserId), // Upload: one per hour BointsReason.Upload => await _context.BointsLedger.AnyAsync(l => l.UserId == userId && l.Reason == reason && l.CreatedAt >= DateTime.UtcNow.AddHours(-1)), // Daily login: one per day BointsReason.DailyLogin or BointsReason.LoginStreak => await _context.BointsLedger.AnyAsync(l => l.UserId == userId && l.Reason == reason && l.CreatedAt >= DateTime.UtcNow.Date), // First upload of day: one per day BointsReason.FirstUploadOfDay => await _context.BointsLedger.AnyAsync(l => l.UserId == userId && l.Reason == reason && l.CreatedAt >= DateTime.UtcNow.Date), // Report resolved: one per report (use SourcePostId as report proxy) BointsReason.ReportResolved => sourcePostId.HasValue && await _context.BointsLedger.AnyAsync(l => l.UserId == userId && l.Reason == reason && l.SourcePostId == sourcePostId), // Comment posted: one per 10 minutes BointsReason.CommentPosted => await _context.BointsLedger.AnyAsync(l => l.UserId == userId && l.Reason == reason && l.CreatedAt >= DateTime.UtcNow.AddMinutes(-10)), // Forum post created: one per 10 minutes BointsReason.ForumPostCreated => await _context.BointsLedger.AnyAsync(l => l.UserId == userId && l.Reason == reason && l.CreatedAt >= DateTime.UtcNow.AddMinutes(-10)), _ => false }; } } }