using System.Globalization;
using System.Text.Json;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
namespace DorkNet.Server.Controllers.API.Comments;
///
/// Room comments ("feedback"/"idea" notes players leave in a room, shown by
/// the watch's RoomCommentsScreen / RoomCommentsPagedGrid).
///
/// Every route here is issued by RecNet.Runtime's room-comment service
/// OPELMBNJHNO against service host 12 = RoomComments
/// ( maps that to the roomcomments
/// subdomain). The five literals, with the BNDIAONDFFF ctor verb
/// ordinal moved into rdx immediately before the builder call
/// (BestHTTP.HTTPMethods: 0=GET, 2=POST, 3=PUT, 4=DELETE):
///
///
/// - OPELMBNJHNO.txt:485 "comments/get/{0}" — rdx=0 (GET), host [rdx+12]
/// - OPELMBNJHNO.txt:811 "comments/unreadcounts" — dynamic GET/POST
/// - OPELMBNJHNO.txt:1216 "comments/create/{0}" — rdx=2 (POST), host [r12+12]
/// - OPELMBNJHNO.txt:1627 "comments/read/{0}/{1}" — rdx=3 (PUT), host [rbx+12]
/// - OPELMBNJHNO.txt:1935 "comments/delete/{0}" — rdx=4 (DELETE), r8=12
///
///
/// STORAGE. There is no RoomCommentEntity and adding entities
/// needs a migration, so comments live in the
/// side-table exactly like the PlayerEvent extras
/// (PlayerEventsController.EventExtras): one row per comment with
/// PlayerId = the author, Key = roomcomment:{roomId}:{commentId}
/// and Value = the JSON payload. Per-player read state is a second,
/// disjoint namespace — roomcommentread:{roomId} holding the last-read
/// comment id — which is what finally lets
/// report real numbers instead of zeroes.
///
[ApiController]
[Authorize]
public class CommentsController(DorkNetDbContext db) : ControllerBase
{
private long Me => this.RequireCurrentPlayerId();
/// Key prefix for a stored comment: roomcomment:{roomId}:{commentId}.
private const string CommentPrefix = "roomcomment:";
/// Key prefix for per-player read state: roomcommentread:{roomId},
/// value = last-read comment id. Deliberately a different prefix from
/// (not roomcomment:read:…) so the
/// suffix match used by can never hit a read-state row.
private const string ReadPrefix = "roomcommentread:";
/// The RecNet.RoomComment members that are not already
/// implied by the storage row's key/owner. Position is three separate
/// nullable floats on the wire, not a Vector3: the client's
/// Nullable<Vector3> property is [IgnoreDataMember] and is
/// rebuilt after deserialisation from three internal
/// Nullable<float> fields (RecNet/RoomComment.txt:495
/// FKDDCNLJOLF reads the HasValue byte at [rbx+0x6C], i.e. the
/// first of the three at 0x68/0x70/0x78).
///
/// Public (not private) so System.Text.Json's reflection-based
/// (de)serialiser can see the type and its accessors.
public sealed class CommentPayload
{
public long? SubRoomId { get; set; }
public string Message { get; set; } = string.Empty;
/// RoomComment.EKHGDABMJOJ: Feedback=0, Idea=1, BugReport=2
/// (RecNet/RoomComment.cs enum member order).
public int Style { get; set; }
public float? X { get; set; }
public float? Y { get; set; }
public float? Z { get; set; }
public DateTime CreatedAt { get; set; }
}
// ── Reads ────────────────────────────────────────────────────────────
/// Comment list for a room.
///
/// OPELMBNJHNO.GMBPJHCBPDP(Int64 roomId, Int32 count, Int64 minId)
/// returns FGLDKEJLAKB<List<RecNet.RoomComment>>
/// (OPELMBNJHNO.txt:341), so the body is a bare JSON array — not a
/// paged {Results,TotalResults} container. Verb is GET (rdx=0 at
/// instr 044) and both parameters go on the query string, because
/// BNDIAONDFFF.AFGEDDANEKP only promotes fields to a form body for
/// non-GET verbs: literals "count" (:506) and "minId" (:518).
///
/// minId is a lower bound on the comment id (the only caller,
/// FeedbackManager.txt:948, passes count=100 and a carried-forward id), so
/// it is applied inclusively and the newest count matches are
/// returned newest-first — which is the order the paged grid renders.
///
/// Failure toast if this 404s or the body will not parse:
/// "Failed to get room comments for room: {0}" (:537).
[HttpGet("/comments/get/{roomId:long}")]
public async Task Get(long roomId, [FromQuery] int count = 100, [FromQuery] long minId = 0)
{
if (count <= 0) count = 100;
if (count > 500) count = 500;
var rows = await CommentRowsForRoomAsync(roomId);
var wire = rows
.Where(r => r.CommentId >= minId)
.OrderByDescending(r => r.CommentId)
.Take(count)
.Select(r => ToWire(r.CommentId, roomId, r.AuthorPlayerId, r.Payload, r.Unread))
.ToList();
return Ok(wire);
}
/// Room-comment unread counts. Client contract (RecNet.Runtime
/// OPELMBNJHNO.DJLNMANKMAA(IReadOnlyCollection<long>)).
///
/// The response is a JSON ARRAY, not an object. The method's return
/// type is Dictionary<long,uint>, but that dictionary is built
/// client-side: OPELMBNJHNO.txt:889 constructs a
/// Func<List<UnreadRoomComments>, Dictionary<long,uint>>
/// projection that runs over the deserialised payload. So the wire type is
/// List<RecNet.UnreadRoomComments> — each element an object with a
/// room id (Int64) and a count (UInt32), per the two properties on
/// RecNet/UnreadRoomComments.txt.
///
/// This previously returned an object map keyed by room id. Json.NET cannot
/// read a JSON object into a List<T>, so every call threw and the
/// client logged "Failed to get unread room comment counts"
/// (OPELMBNJHNO.txt:937) and fell back to an empty dictionary — the watch's
/// room-comment unread badge never appeared.
///
/// The element's exact property names are attribute-driven and are not
/// recoverable from the ISIL (see the note on key aliasing below), so each
/// element carries the plausible spellings side by side. Json.NET matches
/// case-insensitively and ignores unknown members, so the extra aliases are
/// inert whichever name the DTO actually declares.
///
/// Counts are real now that persists per-player read
/// state: a room's count is the number of comments authored by somebody
/// else whose id is above the caller's last-read id for that room.
///
[HttpPost("/comments/unreadcounts")]
[HttpGet("/comments/unreadcounts")]
[Consumes("application/json", "application/x-www-form-urlencoded", "multipart/form-data")]
public async Task UnreadCounts()
{
var roomIds = await ReadRoomIdsAsync();
if (roomIds.Count == 0) return Ok(new List>());
var me = Me;
var lastRead = await LastReadAsync(me);
var counts = new Dictionary();
foreach (var id in roomIds) counts[id] = 0u;
// One bounded query per requested room instead of materialising the
// entire comment keyspace: the room filter and the "your own notes
// are never unread" filter both run in SQL; only the numeric
// comment-id suffix still needs parsing here. Key + owner only: the
// payload column is irrelevant to counting.
foreach (var id in roomIds)
{
var prefix = $"{CommentPrefix}{id}:";
var keys = await db.PlayerSettings
.Where(s => s.Key.StartsWith(prefix) && s.PlayerId != me)
.Select(s => s.Key)
.ToListAsync();
var floor = lastRead.GetValueOrDefault(id);
foreach (var key in keys)
{
if (!TrySplitCommentKey(key, out var roomId, out var commentId)) continue;
if (roomId != id) continue;
if (commentId <= floor) continue;
counts[id]++;
}
}
var result = roomIds
.Select(id => new Dictionary
{
["RoomId"] = id,
["UnreadCount"] = counts[id],
["Count"] = counts[id],
})
.ToList();
return Ok(result);
}
// ── Writes ───────────────────────────────────────────────────────────
/// Posts a new comment into a room.
///
/// OPELMBNJHNO.JHABIKEMJHK(Int64 roomId, Int64 subRoomId, String message,
/// RoomComment/EKHGDABMJOJ style, Nullable<Vector3> position)
/// (OPELMBNJHNO.txt:940) returns FGLDKEJLAKB<RecNet.RoomComment> —
/// a single comment object, not a list and not a status envelope.
///
/// Verb is POST (rdx=2 at instr 069), so every AFGEDDANEKP field is
/// an application/x-www-form-urlencoded body field, never a query
/// param. Field literals, in emission order: "message" (:1234),
/// "subRoomId" (:1249, boxed Int64), "style" (:1261, boxed UInt32),
/// then — only when the Vector3? has a value (the
/// Compare [rdi+12], r12 / JumpIfEqual at :1268) — "positionX"
/// (:1292), "positionY" (:1314), "positionZ" (:1336), each formatted with
/// Single.ToString(CultureInfo.InvariantCulture) (:1285/1289), which
/// is why the three floats are parsed invariantly here rather than left to
/// model binding.
///
/// Failure toast: "Failed to Create Comment" (:1359).
[HttpPost("/comments/create/{roomId:long}")]
public async Task Create(
long roomId,
[FromForm] string? message,
[FromForm] long? subRoomId,
[FromForm] int style = 0,
[FromForm] string? positionX = null,
[FromForm] string? positionY = null,
[FromForm] string? positionZ = null)
{
if (!await db.Rooms.AnyAsync(r => r.Id == roomId)) return NotFound();
message = (message ?? string.Empty).Trim();
if (message.Length == 0) return BadRequest();
// PlayerSettingEntity.Value is MaxLength(1024) and also carries the
// rest of the payload; keep well clear of the column width.
if (message.Length > 700) message = message[..700];
var payload = new CommentPayload
{
SubRoomId = subRoomId is > 0 ? subRoomId : null,
Message = message,
Style = style,
X = ParseFloat(positionX),
Y = ParseFloat(positionY),
Z = ParseFloat(positionZ),
CreatedAt = DateTime.UtcNow,
};
// The client only ever sends all three or none; drop a partial set so
// the client's Vector3 reassembly (RoomComment.FKDDCNLJOLF) sees a
// consistent null.
if (payload.X is null || payload.Y is null || payload.Z is null)
payload.X = payload.Y = payload.Z = null;
var me = Me;
var commentId = await NextCommentIdAsync();
db.PlayerSettings.Add(new PlayerSettingEntity
{
PlayerId = me,
Key = $"{CommentPrefix}{roomId}:{commentId}",
Value = JsonSerializer.Serialize(payload),
});
await db.SaveChangesAsync();
// Authoring a comment implies you have read up to it.
await SetLastReadAsync(me, roomId, commentId);
await db.SaveChangesAsync();
return Ok(ToWire(commentId, roomId, me, payload, unread: false));
}
/// Records the newest comment the caller has read in a room, which
/// is what clears the watch's unread badge (and what makes
/// return non-zero numbers for everyone else).
///
/// OPELMBNJHNO.MCOIDIBFHIP(Int64 roomId, Int64 commentId)
/// (OPELMBNJHNO.txt:1375) returns the untyped LDGADANDBIO promise, but
/// it is built from the body parsed as a RecNetResult: the continuation
/// <UpdateLatestReadComment>b__9_1(DJMHAFPGLLN)
/// (OPELMBNJHNO_NestedType___c.txt:333) null-derefs an empty body and
/// rejects on Success=false — see . Verb is PUT
/// (rdx=3 at instr 059, OPELMBNJHNO.txt:1637); both ids are path segments
/// formatted into "comments/read/{0}/{1}" (:1627) and no field is added, so
/// the request carries no body at all.
///
/// Failure toast: "Failed to update latest read comment" (:1656).
[HttpPut("/comments/read/{roomId:long}/{commentId:long}")]
public async Task MarkRead(long roomId, long commentId)
{
await SetLastReadAsync(Me, roomId, commentId);
await db.SaveChangesAsync();
return RecNetResults.Ok();
}
/// Deletes a comment.
///
/// OPELMBNJHNO.NDLOEEHIDED(Int64 commentId) (OPELMBNJHNO.txt:1781)
/// returns the status-only LDGADANDBIO. Verb is DELETE
/// (rdx=4 at instr 046) and the host ordinal is the only immediate on the
/// builder call (r8=12, RoomComments) — see OPELMBNJHNO.txt:1944-1946.
/// No fields are attached: the comment id is the whole request.
///
/// Because the route carries no room id, the storage key is matched by its
/// :{commentId} suffix; read-state rows live under a different
/// prefix precisely so they cannot collide here. Only the author, the room
/// creator, or an accepted co-owner/moderator of the room may delete —
/// anyone else gets 403 and the client raises its "Failed to delete
/// comment" toast (:1962).
[HttpDelete("/comments/delete/{commentId:long}")]
public async Task Delete(long commentId)
{
var suffix = ":" + commentId.ToString(CultureInfo.InvariantCulture);
var row = await db.PlayerSettings
.Where(s => s.Key.StartsWith(CommentPrefix) && s.Key.EndsWith(suffix))
.FirstOrDefaultAsync();
if (row is null) return NotFound();
if (!TrySplitCommentKey(row.Key, out var roomId, out _)) return NotFound();
var me = Me;
if (row.PlayerId != me && !await CanModerateRoomAsync(roomId, me)) return Forbid();
db.PlayerSettings.Remove(row);
await db.SaveChangesAsync();
return Ok();
}
// ── Wire shape ───────────────────────────────────────────────────────
/// One RecNet.RoomComment. Member list and CLR types are
/// exact (RecNet/RoomComment.cs, cross-checked against the 2023.06.21
/// dump.cs:1241576): long comment id, long room id,
/// long? sub-room id, int? account id, DateTime
/// created-at, string message, the EKHGDABMJOJ style enum, a
/// settable bool, three internal float? position components,
/// and one computed get-only bool that is never on the wire.
///
/// KEY NAMES. The 2023 DTOs' JSON names live in DataMember attribute blobs
/// in global-metadata.dat and are not recoverable from Cpp2IL output, so
/// the names below are taken from the client's own request field
/// literals for the same values — "message", "subRoomId", "style",
/// "positionX", "positionY", "positionZ" (OPELMBNJHNO.txt:1234-1336) —
/// which mirror the server DTO they were serialised from. Json.NET matches
/// case-insensitively and ignores unknown members, so the aliases for the
/// two members with no request-side counterpart are inert.
///
/// The settable bool is the unread flag: its only mutator
/// RoomComment.MCCJIBCJEJC clears the byte at [rcx+0x64] and
/// fires the change event (RoomComment.txt:471-493), and the computed
/// get-only bool FOOFBPEMFCB is AccountId == localAccountId ||
/// !flag (:442-466, comparing against
/// OPMAPIOEIFG.GGAEMNHEHLO). Its declared name is unknown, hence the
/// three non-inverting spellings.
private static Dictionary ToWire(
long commentId, long roomId, long authorPlayerId, CommentPayload p, bool unread) => new(StringComparer.Ordinal)
{
["CommentId"] = commentId,
["RoomId"] = roomId,
["SubRoomId"] = p.SubRoomId,
["AccountId"] = (int)authorPlayerId,
["CreatedAt"] = p.CreatedAt,
["Message"] = p.Message,
["Style"] = p.Style,
["PositionX"] = p.X,
["PositionY"] = p.Y,
["PositionZ"] = p.Z,
["IsUnread"] = unread,
["Unread"] = unread,
["IsNew"] = unread,
};
// ── Storage helpers ──────────────────────────────────────────────────
private sealed record StoredComment(long CommentId, long AuthorPlayerId, CommentPayload Payload, bool Unread);
/// Every stored comment for a room, with the caller's unread flag
/// resolved against their read-state row.
private async Task> CommentRowsForRoomAsync(long roomId)
{
var me = Me;
var prefix = $"{CommentPrefix}{roomId}:";
var rows = await db.PlayerSettings
.Where(s => s.Key.StartsWith(prefix))
.Select(s => new { s.Key, s.PlayerId, s.Value })
.ToListAsync();
var lastRead = await LastReadAsync(me);
var seen = lastRead.GetValueOrDefault(roomId);
var list = new List(rows.Count);
foreach (var r in rows)
{
if (!long.TryParse(r.Key[prefix.Length..], NumberStyles.Integer, CultureInfo.InvariantCulture, out var commentId))
continue;
CommentPayload? payload;
try { payload = JsonSerializer.Deserialize(r.Value); }
catch (JsonException) { continue; }
if (payload is null) continue;
list.Add(new StoredComment(commentId, r.PlayerId, payload, r.PlayerId != me && commentId > seen));
}
return list;
}
/// Comment ids are handed out from the wall clock in milliseconds
/// so they stay globally unique and monotonically increasing across
/// restarts — the client's minId filter and the unread comparison
/// both rely on "higher id == newer".
private async Task NextCommentIdAsync()
{
var id = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
for (var attempt = 0; attempt < 64; attempt++)
{
var suffix = ":" + id.ToString(CultureInfo.InvariantCulture);
var taken = await db.PlayerSettings
.AnyAsync(s => s.Key.StartsWith(CommentPrefix) && s.Key.EndsWith(suffix));
if (!taken) break;
id++;
}
return id;
}
/// Last-read comment id per room for one player.
private async Task> LastReadAsync(long playerId)
{
var rows = await db.PlayerSettings
.Where(s => s.PlayerId == playerId && s.Key.StartsWith(ReadPrefix))
.Select(s => new { s.Key, s.Value })
.ToListAsync();
var map = new Dictionary();
foreach (var r in rows)
{
if (!long.TryParse(r.Key[ReadPrefix.Length..], NumberStyles.Integer, CultureInfo.InvariantCulture, out var roomId)) continue;
if (!long.TryParse(r.Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var commentId)) continue;
if (!map.TryGetValue(roomId, out var cur) || commentId > cur) map[roomId] = commentId;
}
return map;
}
/// Read state only ever moves forward — the watch re-sends older
/// ids as the grid scrolls back up.
private async Task SetLastReadAsync(long playerId, long roomId, long commentId)
{
var key = $"{ReadPrefix}{roomId}";
var row = await db.PlayerSettings.FirstOrDefaultAsync(s => s.PlayerId == playerId && s.Key == key);
if (row is null)
{
db.PlayerSettings.Add(new PlayerSettingEntity
{
PlayerId = playerId,
Key = key,
Value = commentId.ToString(CultureInfo.InvariantCulture),
});
return;
}
if (long.TryParse(row.Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var cur) && cur >= commentId) return;
row.Value = commentId.ToString(CultureInfo.InvariantCulture);
}
/// Room creator, or an accepted co-owner (Role 0) / moderator
/// (Role 1) — the same grant model
/// documents.
private async Task CanModerateRoomAsync(long roomId, long playerId)
{
if (await db.Rooms.AnyAsync(r => r.Id == roomId && r.CreatorPlayerId == playerId)) return true;
return await db.RoomRoles.AnyAsync(r =>
r.RoomId == roomId && r.PlayerId == playerId && r.Accepted && (r.Role == 0 || r.Role == 1));
}
/// Splits roomcomment:{roomId}:{commentId}.
private static bool TrySplitCommentKey(string key, out long roomId, out long commentId)
{
roomId = 0;
commentId = 0;
if (!key.StartsWith(CommentPrefix, StringComparison.Ordinal)) return false;
var rest = key[CommentPrefix.Length..];
var sep = rest.IndexOf(':');
if (sep <= 0) return false;
return long.TryParse(rest[..sep], NumberStyles.Integer, CultureInfo.InvariantCulture, out roomId)
&& long.TryParse(rest[(sep + 1)..], NumberStyles.Integer, CultureInfo.InvariantCulture, out commentId);
}
private static float? ParseFloat(string? s) =>
float.TryParse(s, NumberStyles.Float, CultureInfo.InvariantCulture, out var v) ? v : null;
private async Task> ReadRoomIdsAsync()
{
var ids = new HashSet();
void AddCsv(string? s)
{
if (string.IsNullOrWhiteSpace(s)) return;
foreach (var part in s.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
if (long.TryParse(part, out var v) && v > 0) ids.Add(v);
}
// Query / form: roomIds=1,2,3
foreach (var k in new[] { "roomIds", "roomId", "ids", "id" })
{
AddCsv(Request.Query[k].ToString());
if (Request.HasFormContentType) AddCsv(Request.Form[k].ToString());
}
// JSON body: either a bare array [1,2,3] or { "roomIds": [1,2,3] }.
if (!Request.HasFormContentType)
{
try
{
Request.EnableBuffering();
Request.Body.Position = 0;
using var doc = await JsonDocument.ParseAsync(Request.Body);
var root = doc.RootElement;
JsonElement arr = default;
var haveArr = false;
if (root.ValueKind == JsonValueKind.Array) { arr = root; haveArr = true; }
else if (root.ValueKind == JsonValueKind.Object)
{
foreach (var k in new[] { "roomIds", "RoomIds", "ids", "Ids" })
if (root.TryGetProperty(k, out arr) && arr.ValueKind == JsonValueKind.Array) { haveArr = true; break; }
}
if (haveArr)
foreach (var el in arr.EnumerateArray())
if (el.ValueKind == JsonValueKind.Number && el.TryGetInt64(out var v) && v > 0) ids.Add(v);
}
catch { /* non-JSON / empty body */ }
}
return ids;
}
}