using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.API.Keepsakes;
[ApiController]
[Route("api/keepsakes")]
[Authorize]
public class KeepsakesController(DorkNetDbContext db, DomainConfig domain) : ControllerBase
{
private long Me => this.RequireCurrentPlayerId();
/// Marker category for collection rows; never surfaced as a keepsake instance.
private const string CollectionCategory = "collection";
///
/// Mirrors KeepsakeGlobalConfigDTO.SocialXpBoostEnabled below. The collect handler has
/// to agree with what globalconfig advertises, so the flag lives in one place.
///
private const bool SocialXpBoostEnabled = false;
private const int KeepsakeRoomLimit = 64;
[HttpGet]
public async Task Mine()
{
await EnsureLoginKeepsakeAsync(Me);
var rows = await db.Keepsakes
.Where(k => k.PlayerId == Me && k.Category != CollectionCategory)
.OrderByDescending(k => k.EarnedAt)
.ToListAsync();
return Ok(rows.Select(ToWire));
}
// KeepsakeCategoryConfigDTO = {KeepsakeCategoryId(enum), VisualId, LimitPerRoom, XpValue,
// IconOutlineImageName, IconFilledImageName} - dump.cs:1234842-1234878; the accepted wire key
// names are enumerated by its Utf8Json formatter (IsilDump/RecNet.Runtime/HIKOJENMDKE.txt:66-160
// - "KeepsakeCategoryId"/"VisualId"/"LimitPerRoom"/"XpValue" plus camel/lower aliases).
// KeepsakeCategoryId is the PIHCLHIKEPH enum (dump.cs:1199524: Explore=0, GreenPowerCore=1,
// Present=2, PurplePowerCore=3, UnnamedKeepsakeNumber1..5 = 4..8), and the client folds this
// list into a Dictionary (NCCLEJPIABA.DHMFBMMAGCL,
// NCCLEJPIABA.txt:4204) that every placed instance is looked up in. The previous three-entry
// list keyed by our internal account/event/room buckets covered ids 0-2 only, so six of the
// nine placeable categories had no config - and, since collect prices the reward from XpValue,
// no reward either.
private static readonly (int Id, string VisualId)[] CategoryConfigs =
[
(0, "Explore"),
(1, "GreenPowerCore"),
(2, "Present"),
(3, "PurplePowerCore"),
(4, "UnnamedKeepsakeNumber1"),
(5, "UnnamedKeepsakeNumber2"),
(6, "UnnamedKeepsakeNumber3"),
(7, "UnnamedKeepsakeNumber4"),
(8, "UnnamedKeepsakeNumber5"),
];
///
/// XP a single keepsake collection is worth. This is the one number both
/// api/keepsakes/categories (as XpValue) and
/// api/keepsakes/{id}/collect (as TotalXp) are derived from, so the toast the
/// client renders after a collect matches the config it already downloaded.
///
private const int KeepsakeXpValue = 25;
[HttpGet("categories")]
[AllowAnonymous]
public IActionResult Categories()
{
var results = CategoryConfigs
.Select(c => new
{
KeepsakeCategoryId = c.Id,
c.VisualId,
LimitPerRoom = KeepsakeRoomLimit,
XpValue = KeepsakeXpValue,
IconOutlineImageName = string.Empty,
IconFilledImageName = string.Empty,
})
.ToArray();
return Ok(new
{
Results = results,
TotalResults = results.Length,
});
}
[HttpGet("events")]
public async Task Events()
{
var rows = await db.Keepsakes
.Where(k => k.PlayerId == Me && k.Category == "event")
.OrderByDescending(k => k.EarnedAt)
.ToListAsync();
return Ok(rows.Select(ToWire));
}
[HttpGet("events/{eventId:long}")]
public async Task EventInstances(long eventId)
{
var eventKey = eventId.ToString(CultureInfo.InvariantCulture);
// NOT filtered by PlayerId: Instances is the whole event's placement set (the hunt map),
// CollectionRecords is only what the caller has picked up.
var rows = await db.Keepsakes
.Where(k => k.Category == "event"
&& (k.EventKey == eventKey
|| k.EventKey == $"event:{eventKey}"
|| k.EventKey == $"event/{eventKey}"
|| EF.Functions.Like(k.EventKey, $"event:{eventKey}:%")
|| EF.Functions.Like(k.EventKey, $"event/{eventKey}/%")))
.OrderByDescending(k => k.EarnedAt)
.ToListAsync();
// RecNet.KeepsakeProgressionEventInstancesDTO has exactly two members, both
// List (= {Int64, List}) - dump.cs:1234798-1234810,
// ctor(List, List) in
// IsilDump/RecNet.Runtime/RecNet/KeepsakeProgressionEventInstancesDTO.txt:14.
// Emitting the flat keepsake rows here left RoomId=0 / KeepsakeInstanceIds=null per entry.
// Key names follow the same RecNet naming the room endpoint below already uses; the literal
// JSON names live in Newtonsoft attribute metadata the dumps do not render, so they are
// unverified - Json.NET matching is case-insensitive and ignores unknown members.
//
// CollectionRecords is what the CALLER has picked up, which is a different set from what the
// caller PLACED: it is fed by the collect handler below (Category=CollectionCategory rows).
var eventInstanceIds = rows
.Select(r => TryParseInstanceKey(r.EventKey, out _, out _, out _, out var id) ? id : StableKeepsakeInstanceId(r.Id))
.ToHashSet();
var collected = (await LoadMyCollectionsAsync())
.Where(c => eventInstanceIds.Contains(c.InstanceId))
.GroupBy(c => c.RoomId)
.Select(group => (object)new
{
RoomId = group.Key,
KeepsakeInstanceIds = group.Select(c => c.InstanceId).Distinct().Select(id => id.ToString("D")).ToList(),
})
.ToList();
return Ok(new
{
Instances = GroupInstanceIdsByRoom(rows),
CollectionRecords = collected,
});
}
[HttpGet("rooms/{roomId:long}")]
public async Task RoomKeepsakes(long roomId)
{
var roomExists = await db.Rooms.AsNoTracking().AnyAsync(r => r.Id == roomId);
if (!roomExists) return NotFound();
var roomIdText = roomId.ToString(System.Globalization.CultureInfo.InvariantCulture);
var rows = await db.Keepsakes
.Where(k => k.Category == "room"
&& (k.EventKey == $"room:{roomIdText}"
|| k.EventKey == $"room/{roomIdText}"
|| k.EventKey == roomIdText
|| EF.Functions.Like(k.EventKey, $"room:{roomIdText}:%")
|| EF.Functions.Like(k.EventKey, $"room/{roomIdText}/%")))
.OrderByDescending(k => k.EarnedAt)
.ToListAsync();
var instances = rows.Select(row => ToRoomInstanceWire(row, roomId)).ToList();
// KeepsakeCollectionRecordDTO = {Int32 AccountId, Guid KeepsakeInstanceId, DateTime
// CollectedAt} - dump.cs:1234642-1234666, wire keys confirmed by its Utf8Json formatter
// (IsilDump/RecNet.Runtime/CNNAFLNKDCL.txt:48,75,99). Scoped to the caller: the DTO carries
// AccountId so the client can filter, but returning only the caller's rows is correct under
// either reading and keeps other players' pickups out of the payload.
var collectPrefix = $"collect:{roomIdText}:";
var me = Me;
var collectionRecords = await db.Keepsakes
.Where(k => k.PlayerId == me
&& k.Category == CollectionCategory
&& EF.Functions.Like(k.EventKey, collectPrefix + "%"))
.ToListAsync();
return Ok(new
{
Instances = instances,
CollectionRecords = collectionRecords
.Where(k => TryParseCollectionKey(k.EventKey, out _, out _))
.Select(k =>
{
TryParseCollectionKey(k.EventKey, out _, out var instanceId);
return (object)new
{
AccountId = k.PlayerId is > 0 and <= int.MaxValue ? (int)k.PlayerId : 0,
KeepsakeInstanceId = instanceId.ToString("D"),
CollectedAt = k.EarnedAt,
};
})
.ToList(),
KeepsakeProgressionEventIds = Array.Empty(),
});
}
///
/// Removes a placed keepsake instance. The 2023 client issues
/// DELETE api/keepsakes/{guid} - NCCLEJPIABA.JBKENCNIEPA(System.Guid) builds
/// String.Format("{0}/{1}", "api/keepsakes", guid) and passes verb 4 (DELETE) to the
/// BNDIAONDFFF ctor (NCCLEJPIABA.txt:1544, 1687-1698 - Move rdx, 4). Its return type is
/// the non-generic LDGADANDBIO promise, but that promise comes from
/// BFDGEKKNAPB.PEFKBAEAOPD (ExpandRecNetResult) — the body IS parsed, as a
/// {success,error} RecNetResult, and an empty 200 null-derefs into a failed delete.
/// See . Client-side the tool refuses unless the caller is at least a room co-owner
/// ("Must be at least a room co-owner to modify keepsakes.",
/// RecRoom.Keepsakes.Runtime/PDFJLLECNBE.txt:3363), which we re-check server-side.
///
[HttpDelete("{keepsakeInstanceId:guid}")]
public async Task Delete(Guid keepsakeInstanceId)
{
var instance = await FindInstanceAsync(keepsakeInstanceId);
if (instance is null) return NotFound();
var roomId = TryParseInstanceKey(instance.EventKey, out var parsedRoomId, out _, out _, out _)
? parsedRoomId
: 0;
if (!await CanModifyInstanceAsync(instance, roomId, Me)) return Forbid();
// Drop the collection rows for this instance too, or they linger as records pointing at a
// keepsake nobody can see and keep suppressing a future re-placement's collect.
var suffix = keepsakeInstanceId.ToString("N");
var orphanedCollections = await db.Keepsakes
.Where(k => k.Category == CollectionCategory && EF.Functions.Like(k.EventKey, $"%:{suffix}"))
.ToListAsync();
db.Keepsakes.Remove(instance);
db.Keepsakes.RemoveRange(orphanedCollections
.Where(k => TryParseCollectionKey(k.EventKey, out _, out var id) && id == keepsakeInstanceId));
await db.SaveChangesAsync();
return RecNetResults.Ok();
}
///
/// Records that the caller picked up a placed keepsake and reports the XP it was worth. The
/// 2023 client issues POST api/keepsakes/{guid}/collect -
/// NCCLEJPIABA.HFGKFAHFELM(System.Guid) formats "{0}/{1}/collect" over
/// "api/keepsakes" and passes verb 2 (POST) (NCCLEJPIABA.txt:1738, 1963-1974 -
/// Move rdx, 2), with no request body. The response type is
/// FGLDKEJLAKB<DHNBKMHDANK>; DHNBKMHDANK is a two-Int32 object whose Utf8Json
/// formatter writes "TotalXp" and "SocialBoostXp" (RecNet.Runtime/PKCMBJFBHBO.txt:42,69,100,121
/// - reader also accepts totalXp/socialBoostXp). The consumer computes
/// TotalXp - SocialBoostXp for the base figure and shows the boost separately
/// (PDFJLLECNBE_NestedType_LKIPMJFEAFK.txt:82-99), so TotalXp is the whole award, boost
/// included - not a running account total.
///
[HttpPost("{keepsakeInstanceId:guid}/collect")]
public async Task Collect(Guid keepsakeInstanceId)
{
var instance = await FindInstanceAsync(keepsakeInstanceId);
if (instance is null) return NotFound();
var parsed = TryParseInstanceKey(instance.EventKey, out var roomId, out _, out var categoryConfigId, out _);
if (!parsed)
{
roomId = 0;
categoryConfigId = CategoryId(instance.Category);
}
var me = Me;
var collectKey = BuildCollectionKey(roomId, keepsakeInstanceId);
var alreadyCollected = await db.Keepsakes
.AnyAsync(k => k.PlayerId == me && k.Category == CollectionCategory && k.EventKey == collectKey);
// Re-collecting is a no-op award rather than an error: the client only ever renders the two
// numbers, and paying out twice for one instance would be a fabricated reward.
if (alreadyCollected) return Ok(new { TotalXp = 0, SocialBoostXp = 0 });
db.Keepsakes.Add(new KeepsakeEntity
{
PlayerId = me,
Category = CollectionCategory,
EventKey = collectKey,
EarnedAt = DateTime.UtcNow,
});
await db.SaveChangesAsync();
return Ok(new
{
TotalXp = XpValueFor(categoryConfigId),
// SocialBoostXp is the boost slice *of* TotalXp - the client renders
// TotalXp - SocialBoostXp as the base figure - and globalconfig above advertises
// SocialXpBoostEnabled=false, so there is no slice and the whole award is base XP.
SocialBoostXp = 0,
});
}
[HttpGet("globalconfig")]
[AllowAnonymous]
public IActionResult GlobalConfig() => Ok(new
{
KeepsakeFeatureEnabled = true,
KeepsakeRoomLimit,
SocialXpBoostEnabled,
});
[HttpPost]
public async Task Create()
{
var req = await ReadRequestAsync();
// 2023 client: FGLDKEJLAKB OPPHCOJHEJI(HMECHOKOCBB category)
// (NCCLEJPIABA.txt:1146) POSTs a RecNet.AddKeepsakeInstanceRequest
// {Int64 RoomId, Int64? SubRoomId, Int32 KeepsakeCategory} - dump.cs:1234509-1234525,
// field offsets 0x10/0x18/0x28 in AddKeepsakeInstanceRequest.txt - and deserialises the
// response as a BARE Guid, not an object. The request is refused client-side for offline
// rooms ("Cannot add keepsakes in offline rooms", NCCLEJPIABA.txt:1533), so RoomId is
// always a real room here; its presence is what selects this path over the legacy DTO.
if (req.RoomId is > 0)
{
var roomId = req.RoomId.Value;
if (!await db.Rooms.AsNoTracking().AnyAsync(r => r.Id == roomId)) return NotFound();
var instanceId = Guid.NewGuid();
db.Keepsakes.Add(new KeepsakeEntity
{
PlayerId = Me,
Category = "room",
EventKey = BuildRoomInstanceKey(roomId, req.SubRoomId, req.KeepsakeCategory ?? 0, instanceId),
EarnedAt = DateTime.UtcNow,
});
await db.SaveChangesAsync();
return Ok(instanceId);
}
var key = Trim(req.EventKey, 128);
if (key.Length == 0) key = $"manual:{Guid.NewGuid():N}";
var category = Trim(req.Category, 64);
if (category.Length == 0) category = "event";
var existing = await db.Keepsakes.FirstOrDefaultAsync(k =>
k.PlayerId == Me && k.EventKey == key);
if (existing is null)
{
existing = new KeepsakeEntity
{
PlayerId = Me,
EventKey = key,
};
db.Keepsakes.Add(existing);
}
existing.Category = category;
existing.Title = Trim(req.Title, 128);
existing.Description = Trim(req.Description, 1024);
existing.ImageName = Trim(req.ImageName, 256);
existing.EarnedAt = req.EarnedAt ?? DateTime.UtcNow;
await db.SaveChangesAsync();
return Ok(ToWire(existing));
}
private async Task EnsureLoginKeepsakeAsync(long playerId)
{
const string key = "first-login";
if (await db.Keepsakes.AnyAsync(k => k.PlayerId == playerId && k.EventKey == key))
return;
db.Keepsakes.Add(new KeepsakeEntity
{
PlayerId = playerId,
Category = "account",
EventKey = key,
Title = "First Login",
Description = "Joined this server.",
});
await db.SaveChangesAsync();
}
private async Task ReadRequestAsync()
{
if (Request.HasFormContentType)
{
var form = await Request.ReadFormAsync();
return new KeepsakeRequest
{
RoomId = long.TryParse(form["RoomId"].FirstOrDefault() ?? form["roomId"].FirstOrDefault(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var formRoom) ? formRoom : null,
SubRoomId = long.TryParse(form["SubRoomId"].FirstOrDefault() ?? form["subRoomId"].FirstOrDefault(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var formSubRoom) ? formSubRoom : null,
KeepsakeCategory = int.TryParse(form["KeepsakeCategory"].FirstOrDefault() ?? form["keepsakeCategory"].FirstOrDefault(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var formCategory) ? formCategory : null,
Category = form["category"].FirstOrDefault() ?? form["Category"].FirstOrDefault(),
EventKey = form["eventKey"].FirstOrDefault() ?? form["EventKey"].FirstOrDefault(),
Title = form["title"].FirstOrDefault() ?? form["Title"].FirstOrDefault(),
Description = form["description"].FirstOrDefault() ?? form["Description"].FirstOrDefault(),
ImageName = form["imageName"].FirstOrDefault() ?? form["ImageName"].FirstOrDefault(),
EarnedAt = DateTime.TryParse(form["earnedAt"].FirstOrDefault() ?? form["EarnedAt"].FirstOrDefault(), out var earned) ? earned : null,
};
}
// The 2023 client sends a RAW JSON body, not a form: NCCLEJPIABA.txt:1458-1465 serialises the
// whole AddKeepsakeInstanceRequest in one call and hands the string to BNDIAONDFFF's body
// setter. Read it as a document rather than binding a DTO so the numeric KeepsakeCategory and
// the legacy string Category can share one wire slot, and so key-name aliases are cheap.
try
{
using var doc = await JsonDocument.ParseAsync(Request.Body);
var root = doc.RootElement;
return new KeepsakeRequest
{
RoomId = ReadInt64(root, "RoomId"),
SubRoomId = ReadInt64(root, "SubRoomId"),
// "Category" is listed as an alias only for the numeric form; when it carries the
// legacy string ("event"/"room") ReadInt64 declines it and ReadString picks it up.
KeepsakeCategory = (int?)ReadInt64(root, "KeepsakeCategory", "KeepsakeCategoryConfigId", "Category"),
Category = ReadString(root, "Category"),
EventKey = ReadString(root, "EventKey"),
Title = ReadString(root, "Title"),
Description = ReadString(root, "Description"),
ImageName = ReadString(root, "ImageName"),
EarnedAt = DateTime.TryParse(ReadString(root, "EarnedAt"), out var earnedAt) ? earnedAt : null,
};
}
catch (JsonException)
{
return new KeepsakeRequest();
}
}
private static bool TryFindMember(JsonElement root, string[] names, out JsonElement value)
{
if (root.ValueKind == JsonValueKind.Object)
{
foreach (var prop in root.EnumerateObject())
{
foreach (var name in names)
{
if (string.Equals(prop.Name, name, StringComparison.OrdinalIgnoreCase))
{
value = prop.Value;
return true;
}
}
}
}
value = default;
return false;
}
private static long? ReadInt64(JsonElement root, params string[] names)
{
if (!TryFindMember(root, names, out var el)) return null;
if (el.ValueKind == JsonValueKind.Number && el.TryGetInt64(out var number)) return number;
if (el.ValueKind == JsonValueKind.String
&& long.TryParse(el.GetString(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed)) return parsed;
return null;
}
private static string? ReadString(JsonElement root, params string[] names)
=> TryFindMember(root, names, out var el) && el.ValueKind == JsonValueKind.String ? el.GetString() : null;
private object ToWire(KeepsakeEntity row) => new
{
row.Id,
row.PlayerId,
row.Category,
row.EventKey,
row.Title,
row.Description,
row.ImageName,
ImageUrl = string.IsNullOrWhiteSpace(row.ImageName) ? string.Empty : $"https://{domain.Sub("cdn")}/{row.ImageName}",
row.EarnedAt,
};
private static object ToRoomInstanceWire(KeepsakeEntity row, long roomId)
{
var placedByAccountId = row.PlayerId is > 0 and <= int.MaxValue
? (int)row.PlayerId
: 0;
var parsed = TryParseInstanceKey(row.EventKey, out var keyRoomId, out var subRoomId, out var categoryConfigId, out var instanceId);
return new
{
KeepsakeInstanceId = (parsed ? instanceId : StableKeepsakeInstanceId(row.Id)).ToString("D"),
// KeepsakeCategory is the visual config id (enum PIHCLHIKEPH: Explore=0, GreenPowerCore=1,
// Present=2, PurplePowerCore=3, ... - dump.cs:1199524), NOT our account/event/room bucket,
// so replay what the client sent; CategoryId() is only the fallback for legacy rows.
KeepsakeCategoryConfigId = parsed ? categoryConfigId : CategoryId(row.Category),
PlacedByAccountId = placedByAccountId,
RoomId = parsed ? keyRoomId : roomId,
SubRoomId = subRoomId,
};
}
private static List