using System.Text.Json; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using RadicadeServer.DB; using RadicadeServer.Extensions; using RadicadeServer.RecNet.Keepsakes; namespace RadicadeServer.API.Keepsakes; [ApiController] [Route("api/keepsakes")] public sealed class KeepsakesAPI : ControllerBase { private readonly RecDBContext? db; public KeepsakesAPI(RecDBContext db) => this.db = db; private long? CallerAccountId => ControllerContext?.HttpContext?.User?.GetUserId(); [HttpGet("globalconfig")] public IActionResult GlobalConfig() { var enabled = KeepsakeFeature.Enabled; return Ok(new { KeepsakeFeatureEnabled = enabled, KeepsakeRoomLimit = enabled ? KeepsakeRoomConfig.Current.RoomLimit : 0, SocialXpBoostEnabled = false }); } [HttpGet("categories")] public IActionResult Categories() { var results = KeepsakeFeature.Enabled ? KeepsakeCatalog.Results() : Array.Empty(); return Ok(new { Results = results, TotalResults = results.Length }); } [HttpGet("rooms/{roomId:long}")] public async Task Room(long roomId) { if (db == null) return NoContent(); if (!KeepsakeFeature.Enabled) return NoContent(); var instances = await db.KeepsakeInstances .Where(i => i.RoomId == roomId && !i.Removed) .ToListAsync(); var keepsakeRooms = KeepsakeRoomConfig.Current; var isKeepsakeRoom = keepsakeRooms.IsKeepsakeRoom(roomId); if (instances.Count == 0 && !isKeepsakeRoom) return NoContent(); var accountId = CallerAccountId; var records = accountId == null ? new List() : await db.KeepsakeCollectionRecords .Where(r => r.AccountId == accountId && instances.Select(i => i.KeepsakeInstanceId).Contains(r.KeepsakeInstanceId)) .ToListAsync(); var collected = records.Select(r => r.KeepsakeInstanceId).ToHashSet(); var uncollected = instances .Where(i => !collected.Contains(i.KeepsakeInstanceId)) .Select(ToInstancePayload) .ToArray(); var collectionRecords = records .Select(r => new { AccountId = r.AccountId, KeepsakeInstanceId = r.KeepsakeInstanceId, KeepsakeCategoryConfigId = r.KeepsakeCategoryId, CollectedAt = r.CollectedAt }) .ToArray(); var payload = new Dictionary { ["Instances"] = instances.Select(ToInstancePayload).ToArray(), ["KeepsakeInstances"] = uncollected, ["Keepsakes"] = uncollected, ["KeepsakeCollectionRecords"] = collectionRecords, ["CollectionRecords"] = collectionRecords, ["KeepsakeProgressionEventIds"] = isKeepsakeRoom && keepsakeRooms.EventEnabled ? new[] { keepsakeRooms.EventId } : Array.Empty() }; if (isKeepsakeRoom) AddKeepsakeRoomFields(payload, roomId, keepsakeRooms, instances.Count); return Ok(payload); } private static void AddKeepsakeRoomFields(Dictionary payload, long roomId, KeepsakeRoomConfig keepsakeRooms, int placed) { var rooms = keepsakeRooms.AdvertisedRooms(roomId); payload["KeepsakeRoomId"] = roomId; payload["KeepsakeRoomLimit"] = keepsakeRooms.RoomLimit; payload["KeepsakeRoomListId"] = keepsakeRooms.RoomListId; payload["KeepsakeRooms"] = rooms; payload["KeepsakeRoomLists"] = keepsakeRooms.RoomListEntries(rooms); } [HttpPost("rooms/{roomId:long}")] public async Task Place(long roomId) => !KeepsakeFeature.Enabled ? NotFound() : await PlaceCore(roomId, await ReadRawBody()); [HttpPost] public async Task PlaceFromClient() { if (!KeepsakeFeature.Enabled) return NotFound(); var raw = await ReadRawBody(); var roomId = ReadLong(ParseJson(raw), "RoomId"); if (roomId is not > 0) return BadRequest(new { error = "RoomId is required." }); var result = await PlaceCore(roomId.Value, raw); if (result is not OkObjectResult ok) return result; return Ok(JsonSerializer.SerializeToElement(ok.Value).GetProperty("KeepsakeInstanceId").GetGuid()); } private async Task PlaceCore(long roomId, string raw) { if (!KeepsakeFeature.Enabled) { return NotFound(); } if (db == null) return NoContent(); var accountId = CallerAccountId; if (accountId == null) return Unauthorized(); var config = KeepsakeRoomConfig.Current; if (!config.IsKeepsakeRoom(roomId)) return BadRequest(new { error = "This room does not have keepsake permissions." }); var body = ParseJson(raw); var categoryId = ReadInt(body, "KeepsakeCategoryConfigId", "KeepsakeCategoryId", "KeepsakeCategory", "CategoryId"); if (categoryId == null || KeepsakeCatalog.Find(categoryId.Value) == null) { return BadRequest(new { error = "Unknown or missing keepsake category." }); } var instance = new KeepsakeInstance { KeepsakeInstanceId = ReadGuid(body, "KeepsakeInstanceId") ?? Guid.NewGuid(), RoomId = roomId, SubRoomId = ReadLong(body, "SubRoomId", "KeepsakeSubRoomId"), KeepsakeCategoryId = categoryId.Value, PlacedByAccountId = accountId.Value, PlacedAt = DateTime.UtcNow }; var existing = await db.KeepsakeInstances.FindAsync(instance.KeepsakeInstanceId); if (instance.KeepsakeInstanceId == Guid.Empty) return BadRequest(new { error = "A keepsake instance ID cannot be empty." }); if (existing != null && (existing.RoomId != roomId || existing.PlacedByAccountId != accountId)) return Conflict(new { error = "This keepsake instance belongs to another room or player." }); var otherInstances = db.KeepsakeInstances.Where(i => i.RoomId == roomId && !i.Removed && i.KeepsakeInstanceId != instance.KeepsakeInstanceId); if (await otherInstances.CountAsync() >= config.RoomLimit || await otherInstances.CountAsync(i => i.KeepsakeCategoryId == categoryId.Value) >= KeepsakeCatalog.Find(categoryId.Value)!.LimitPerRoom) return BadRequest(new { error = "Cannot place additional keepsakes in this room." }); if (existing == null) await db.KeepsakeInstances.AddAsync(instance); else { existing.RoomId = instance.RoomId; existing.SubRoomId = instance.SubRoomId; existing.KeepsakeCategoryId = instance.KeepsakeCategoryId; existing.Removed = false; } await db.SaveChangesAsync(); return Ok(ToInstancePayload(existing ?? instance)); } [HttpPost("rooms/{roomId:long}/collect")] public async Task Collect(long roomId) { if (!KeepsakeFeature.Enabled) { return NotFound(); } if (db == null) return NoContent(); var accountId = CallerAccountId; if (accountId == null) return Unauthorized(); var raw = await ReadRawBody(); var body = ParseJson(raw); var wanted = ReadGuidList(body, "KeepsakeInstanceIds", "InstanceIds", "KeepsakeInstanceId"); if (wanted.Count == 0) return BadRequest(new { error = "At least one keepsake instance ID is required." }); var instances = await db.KeepsakeInstances .Where(i => i.RoomId == roomId && !i.Removed && wanted.Contains(i.KeepsakeInstanceId)) .ToListAsync(); var mine = await db.KeepsakeCollectionRecords .Where(r => r.AccountId == accountId && wanted.Contains(r.KeepsakeInstanceId)) .Select(r => r.KeepsakeInstanceId) .ToListAsync(); foreach (var instance in instances.Where(i => !mine.Contains(i.KeepsakeInstanceId))) { await db.KeepsakeCollectionRecords.AddAsync(new KeepsakeCollectionRecord { AccountId = accountId.Value, KeepsakeInstanceId = instance.KeepsakeInstanceId, KeepsakeCategoryId = instance.KeepsakeCategoryId, CollectedAt = DateTime.UtcNow }); } await db.SaveChangesAsync(); return Ok(new { KeepsakeRoomId = roomId, KeepsakeInstanceIds = instances.Select(i => i.KeepsakeInstanceId).ToArray() }); } [HttpDelete("rooms/{roomId:long}/{instanceId:guid}")] public async Task Remove(long roomId, Guid instanceId) { if (!KeepsakeFeature.Enabled) return NotFound(); if (db == null) return NoContent(); var accountId = CallerAccountId; if (accountId == null) return Unauthorized(); var instance = await db.KeepsakeInstances.FindAsync(instanceId); if (instance == null || instance.RoomId != roomId) return NotFound(); if (instance.PlacedByAccountId != accountId) return Forbid(); instance.Removed = true; await db.SaveChangesAsync(); return NoContent(); } [HttpGet("events")] public IActionResult Events() => Ok(Array.Empty()); [HttpGet("events/{eventId:long}")] public async Task EventInstances(long eventId) { var config = KeepsakeRoomConfig.Current; if (!KeepsakeFeature.Enabled || !config.EventEnabled || eventId != config.EventId) return NotFound(); if (db == null) return Ok(new { Instances = Array.Empty(), CollectionRecords = Array.Empty() }); var instances = await db.KeepsakeInstances.Where(i => !i.Removed).ToListAsync(); instances = instances.Where(i => config.IsKeepsakeRoom(i.RoomId)).ToList(); var accountId = CallerAccountId; var collected = accountId == null ? new List() : await db.KeepsakeCollectionRecords .Where(r => r.AccountId == accountId).Select(r => r.KeepsakeInstanceId).ToListAsync(); object[] Groups(IEnumerable rows) => rows.GroupBy(i => i.RoomId) .Select(g => (object)new { RoomId = g.Key, KeepsakeInstanceIds = g.Select(i => i.KeepsakeInstanceId).ToArray() }).ToArray(); return Ok(new { Instances = Groups(instances), CollectionRecords = Groups(instances.Where(i => collected.Contains(i.KeepsakeInstanceId))) }); } [HttpDelete("{instanceId:guid}")] public async Task RemoveFromClient(Guid instanceId) { if (!KeepsakeFeature.Enabled) return NotFound(); if (CallerAccountId == null) return Unauthorized(); var instance = db == null ? null : await db.KeepsakeInstances.FindAsync(instanceId); return instance == null ? NotFound() : await Remove(instance.RoomId, instanceId); } [HttpPost("{instanceId:guid}/collect")] public async Task CollectFromClient(Guid instanceId) { if (!KeepsakeFeature.Enabled) return CollectionNotFound(instanceId, "Keepsakes are disabled on this server."); var accountId = CallerAccountId; if (accountId == null) return Unauthorized(); var instance = db == null ? null : await db.KeepsakeInstances.FindAsync(instanceId); if (instance == null) return CollectionNotFound(instanceId, "The keepsake instance is not registered on this server."); if (instance.Removed) return CollectionNotFound(instanceId, "The keepsake instance has been removed."); var newCollection = !await db!.KeepsakeCollectionRecords.AnyAsync(r => r.AccountId == accountId && r.KeepsakeInstanceId == instanceId); if (newCollection) { db.KeepsakeCollectionRecords.Add(new KeepsakeCollectionRecord { AccountId = accountId.Value, KeepsakeInstanceId = instanceId, KeepsakeCategoryId = instance.KeepsakeCategoryId, CollectedAt = DateTime.UtcNow }); await db.SaveChangesAsync(); } return Ok(new { TotalXp = newCollection ? KeepsakeCatalog.Find(instance.KeepsakeCategoryId)?.XpValue ?? 0 : 0, SocialBoostXp = 0 }); } private IActionResult CollectionNotFound(Guid instanceId, string reason) { Console.WriteLine($"[Keepsakes] collect instance {instanceId} REFUSED: {reason}"); return Problem(statusCode: StatusCodes.Status404NotFound, title: "Keepsake collection failed", detail: reason); } [HttpGet("{**rest}")] [HttpPost("{**rest}")] [HttpPut("{**rest}")] [HttpDelete("{**rest}")] [HttpPatch("{**rest}")] public async Task Unclaimed(string? rest) { var body = await ReadRawBody(); return NotFound(); } private static object ToInstancePayload(KeepsakeInstance instance) => new { KeepsakeInstanceId = instance.KeepsakeInstanceId, KeepsakeCategoryConfigId = instance.KeepsakeCategoryId, PlacedByAccountId = instance.PlacedByAccountId, RoomId = instance.RoomId, SubRoomId = instance.SubRoomId, KeepsakeRoomId = instance.RoomId, PlacedAt = instance.PlacedAt }; private async Task ReadRawBody() { using var reader = new StreamReader(Request.Body); var raw = await reader.ReadToEndAsync(); return raw; } private static string LogPreview(string raw) => raw.Length > 2048 ? raw[..2048] + "..." : raw; private static JsonElement ParseJson(string raw) { if (string.IsNullOrWhiteSpace(raw)) return default; try { using var document = JsonDocument.Parse(raw); return document.RootElement.Clone(); } catch (JsonException) { return default; } } private static bool TryProp(JsonElement root, out JsonElement value, params string[] names) { if (root.ValueKind == JsonValueKind.Object) { foreach (var property in root.EnumerateObject()) { foreach (var name in names) { if (string.Equals(property.Name, name, StringComparison.OrdinalIgnoreCase)) { value = property.Value; return true; } } } } value = default; return false; } private static int? ReadInt(JsonElement root, params string[] names) { if (!TryProp(root, out var value, names)) return null; return value.ValueKind switch { JsonValueKind.Number when value.TryGetInt32(out var number) => number, JsonValueKind.String when int.TryParse(value.GetString(), out var parsed) => parsed, _ => null }; } private static long? ReadLong(JsonElement root, params string[] names) { if (!TryProp(root, out var value, names)) return null; if (value.ValueKind == JsonValueKind.Null) return null; return value.ValueKind switch { JsonValueKind.Number when value.TryGetInt64(out var number) => number, JsonValueKind.String when long.TryParse(value.GetString(), out var parsed) => parsed, _ => null }; } private static Guid? ReadGuid(JsonElement root, params string[] names) { if (!TryProp(root, out var value, names)) return null; return value.ValueKind == JsonValueKind.String && Guid.TryParse(value.GetString(), out var parsed) ? parsed : null; } private static List ReadGuidList(JsonElement root, params string[] names) { var found = new List(); if (!TryProp(root, out var value, names)) return found; switch (value.ValueKind) { case JsonValueKind.Array: found.AddRange(value.EnumerateArray() .Where(item => item.ValueKind == JsonValueKind.String && Guid.TryParse(item.GetString(), out _)) .Select(item => Guid.Parse(item.GetString()!))); break; case JsonValueKind.String when Guid.TryParse(value.GetString(), out var single): found.Add(single); break; } return found; } }