mirror of
https://github.com/DorkSquadRR/DorkNet.git
synced 2026-10-26 07:28:45 -07:00
6ee1d6f9b3
Every change below was checked against the 2023-03-21 client disassembly;
file:line evidence is in the code comments and in
docs/recroom-2023-client-api-complete.md.
Presence
- matchmake/none means "online, in no room" (its only caller is
TryGoToOfflineRoom, which ignores the reply). The server used to keep the
old room or invent a dorm, so the next heartbeat looked out of sync and
the client cancelled its load to chase that room.
- The join watchdog no longer clears presence 30 s into a long room load;
it is a 3 minute backstop that drops the room only.
- One fan-out (PresenceBroadcastService) for every transition: deferred
joins, matchmake/none, logout, failed joins, visibility changes, SignalR
connect/disconnect. isOnline is sent on its own; pushes never go to the
subject, because a self push moves the player.
- Online-status visibility (Public / Friends / Favorites / Appear offline)
is stored and honoured by GET /player and by the live pushes.
Profile
- PUT account/me/{emoji,personalpronouns,identityflags,bannerimage} were
404; added with the client's real form keys and persisted (new Players
columns plus migration). Every account read now returns them, and edits
push AccountUpdate to everyone.
Outfit menu
- Saved outfits kept OutfitSelectionsV2 and CustomAvatarItems, so loading a
slot restores what was saved instead of a stale outfit.
- isCreationAllowedForAccount answers the {Success, Error} envelope the
client parses; minPriceForPublicItem is a bare int; gifts/generate accepts
form bodies; lockeditems returns the full owned-item shape; colourways no
longer share their base item's AvatarItemId.
RecNetResult writes
- About 40 writes look status-only but parse the body as {Success, Error}.
An empty 200 showed "Failed to ..." after the server applied the change,
and a body without Success threw "Error message cannot be null". Clubs,
announcements, keepsakes, comments/read, subscriptions, purchase offers
and privacy settings now answer RecNetResult.
- Login token and device-code grants are real; remoteauth approves the
device code; createlogintoken returns a bare string.
- New: incentivizedreferrals/referrals, account/bulk/{email,phone},
GET strings, account/me/confirmphone, PlayMenuCarouselOrdering_1.
Route inventory and coverage test extended to match.
README now describes the 2023 branch.
523 lines
25 KiB
C#
523 lines
25 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// 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
|
|
/// <c>OPELMBNJHNO</c> against service host <c>12 = RoomComments</c>
|
|
/// (<see cref="Services.ConfigService"/> maps that to the <c>roomcomments</c>
|
|
/// subdomain). The five literals, with the <c>BNDIAONDFFF</c> ctor verb
|
|
/// ordinal moved into <c>rdx</c> immediately before the builder call
|
|
/// (BestHTTP.HTTPMethods: 0=GET, 2=POST, 3=PUT, 4=DELETE):
|
|
///
|
|
/// <list type="bullet">
|
|
/// <item><c>OPELMBNJHNO.txt:485</c> "comments/get/{0}" — rdx=0 (GET), host [rdx+12]</item>
|
|
/// <item><c>OPELMBNJHNO.txt:811</c> "comments/unreadcounts" — dynamic GET/POST</item>
|
|
/// <item><c>OPELMBNJHNO.txt:1216</c> "comments/create/{0}" — rdx=2 (POST), host [r12+12]</item>
|
|
/// <item><c>OPELMBNJHNO.txt:1627</c> "comments/read/{0}/{1}" — rdx=3 (PUT), host [rbx+12]</item>
|
|
/// <item><c>OPELMBNJHNO.txt:1935</c> "comments/delete/{0}" — rdx=4 (DELETE), r8=12</item>
|
|
/// </list>
|
|
///
|
|
/// STORAGE. There is no <c>RoomCommentEntity</c> and adding entities
|
|
/// needs a migration, so comments live in the <see cref="PlayerSettingEntity"/>
|
|
/// side-table exactly like the PlayerEvent extras
|
|
/// (<c>PlayerEventsController.EventExtras</c>): one row per comment with
|
|
/// <c>PlayerId</c> = the author, <c>Key = roomcomment:{roomId}:{commentId}</c>
|
|
/// and <c>Value</c> = the JSON payload. Per-player read state is a second,
|
|
/// disjoint namespace — <c>roomcommentread:{roomId}</c> holding the last-read
|
|
/// comment id — which is what finally lets
|
|
/// <see cref="UnreadCounts"/> report real numbers instead of zeroes.
|
|
/// </summary>
|
|
[ApiController]
|
|
[Authorize]
|
|
public class CommentsController(DorkNetDbContext db) : ControllerBase
|
|
{
|
|
private long Me => this.RequireCurrentPlayerId();
|
|
|
|
/// <summary>Key prefix for a stored comment: <c>roomcomment:{roomId}:{commentId}</c>.</summary>
|
|
private const string CommentPrefix = "roomcomment:";
|
|
|
|
/// <summary>Key prefix for per-player read state: <c>roomcommentread:{roomId}</c>,
|
|
/// value = last-read comment id. Deliberately a different prefix from
|
|
/// <see cref="CommentPrefix"/> (not <c>roomcomment:read:…</c>) so the
|
|
/// suffix match used by <see cref="Delete"/> can never hit a read-state row.</summary>
|
|
private const string ReadPrefix = "roomcommentread:";
|
|
|
|
/// <summary>The <c>RecNet.RoomComment</c> members that are not already
|
|
/// implied by the storage row's key/owner. <c>Position</c> is three separate
|
|
/// nullable floats on the wire, not a Vector3: the client's
|
|
/// <c>Nullable<Vector3></c> property is <c>[IgnoreDataMember]</c> and is
|
|
/// rebuilt after deserialisation from three internal
|
|
/// <c>Nullable<float></c> fields (RecNet/RoomComment.txt:495
|
|
/// <c>FKDDCNLJOLF</c> reads the HasValue byte at <c>[rbx+0x6C]</c>, 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.</summary>
|
|
public sealed class CommentPayload
|
|
{
|
|
public long? SubRoomId { get; set; }
|
|
public string Message { get; set; } = string.Empty;
|
|
/// <summary><c>RoomComment.EKHGDABMJOJ</c>: Feedback=0, Idea=1, BugReport=2
|
|
/// (RecNet/RoomComment.cs enum member order).</summary>
|
|
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 ────────────────────────────────────────────────────────────
|
|
|
|
/// <summary>Comment list for a room.
|
|
///
|
|
/// <c>OPELMBNJHNO.GMBPJHCBPDP(Int64 roomId, Int32 count, Int64 minId)</c>
|
|
/// returns <c>FGLDKEJLAKB<List<RecNet.RoomComment>></c>
|
|
/// (OPELMBNJHNO.txt:341), so the body is a bare JSON <b>array</b> — not a
|
|
/// paged <c>{Results,TotalResults}</c> container. Verb is GET (rdx=0 at
|
|
/// instr 044) and both parameters go on the query string, because
|
|
/// <c>BNDIAONDFFF.AFGEDDANEKP</c> only promotes fields to a form body for
|
|
/// non-GET verbs: literals "count" (:506) and "minId" (:518).
|
|
///
|
|
/// <c>minId</c> 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 <c>count</c> 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).</summary>
|
|
[HttpGet("/comments/get/{roomId:long}")]
|
|
public async Task<IActionResult> 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);
|
|
}
|
|
|
|
/// <summary>Room-comment unread counts. Client contract (RecNet.Runtime
|
|
/// <c>OPELMBNJHNO.DJLNMANKMAA(IReadOnlyCollection<long>)</c>).
|
|
///
|
|
/// The response is a JSON <b>ARRAY</b>, not an object. The method's return
|
|
/// type is <c>Dictionary<long,uint></c>, but that dictionary is built
|
|
/// client-side: OPELMBNJHNO.txt:889 constructs a
|
|
/// <c>Func<List<UnreadRoomComments>, Dictionary<long,uint>></c>
|
|
/// projection that runs over the deserialised payload. So the wire type is
|
|
/// <c>List<RecNet.UnreadRoomComments></c> — 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 <c>List<T></c>, 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 <see cref="MarkRead"/> 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.
|
|
/// </summary>
|
|
[HttpPost("/comments/unreadcounts")]
|
|
[HttpGet("/comments/unreadcounts")]
|
|
[Consumes("application/json", "application/x-www-form-urlencoded", "multipart/form-data")]
|
|
public async Task<IActionResult> UnreadCounts()
|
|
{
|
|
var roomIds = await ReadRoomIdsAsync();
|
|
if (roomIds.Count == 0) return Ok(new List<Dictionary<string, object>>());
|
|
|
|
var me = Me;
|
|
var lastRead = await LastReadAsync(me);
|
|
|
|
var counts = new Dictionary<long, uint>();
|
|
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<string, object>
|
|
{
|
|
["RoomId"] = id,
|
|
["UnreadCount"] = counts[id],
|
|
["Count"] = counts[id],
|
|
})
|
|
.ToList();
|
|
return Ok(result);
|
|
}
|
|
|
|
// ── Writes ───────────────────────────────────────────────────────────
|
|
|
|
/// <summary>Posts a new comment into a room.
|
|
///
|
|
/// <c>OPELMBNJHNO.JHABIKEMJHK(Int64 roomId, Int64 subRoomId, String message,
|
|
/// RoomComment/EKHGDABMJOJ style, Nullable<Vector3> position)</c>
|
|
/// (OPELMBNJHNO.txt:940) returns <c>FGLDKEJLAKB<RecNet.RoomComment></c> —
|
|
/// a <b>single</b> comment object, not a list and not a status envelope.
|
|
///
|
|
/// Verb is POST (rdx=2 at instr 069), so every <c>AFGEDDANEKP</c> field is
|
|
/// an <c>application/x-www-form-urlencoded</c> body field, never a query
|
|
/// param. Field literals, in emission order: "message" (:1234),
|
|
/// "subRoomId" (:1249, boxed Int64), "style" (:1261, boxed <b>UInt32</b>),
|
|
/// then — only when the <c>Vector3?</c> has a value (the
|
|
/// <c>Compare [rdi+12], r12 / JumpIfEqual</c> at :1268) — "positionX"
|
|
/// (:1292), "positionY" (:1314), "positionZ" (:1336), each formatted with
|
|
/// <c>Single.ToString(CultureInfo.InvariantCulture)</c> (: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).</summary>
|
|
[HttpPost("/comments/create/{roomId:long}")]
|
|
public async Task<IActionResult> 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));
|
|
}
|
|
|
|
/// <summary>Records the newest comment the caller has read in a room, which
|
|
/// is what clears the watch's unread badge (and what makes
|
|
/// <see cref="UnreadCounts"/> return non-zero numbers for everyone else).
|
|
///
|
|
/// <c>OPELMBNJHNO.MCOIDIBFHIP(Int64 roomId, Int64 commentId)</c>
|
|
/// (OPELMBNJHNO.txt:1375) returns the untyped <c>LDGADANDBIO</c> promise, but
|
|
/// it is built from the body parsed as a RecNetResult: the continuation
|
|
/// <c><UpdateLatestReadComment>b__9_1(DJMHAFPGLLN)</c>
|
|
/// (OPELMBNJHNO_NestedType___c.txt:333) null-derefs an empty body and
|
|
/// rejects on <c>Success</c>=false — see <see cref="RecNetResults"/>. Verb is <b>PUT</b>
|
|
/// (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).</summary>
|
|
[HttpPut("/comments/read/{roomId:long}/{commentId:long}")]
|
|
public async Task<IActionResult> MarkRead(long roomId, long commentId)
|
|
{
|
|
await SetLastReadAsync(Me, roomId, commentId);
|
|
await db.SaveChangesAsync();
|
|
return RecNetResults.Ok();
|
|
}
|
|
|
|
/// <summary>Deletes a comment.
|
|
///
|
|
/// <c>OPELMBNJHNO.NDLOEEHIDED(Int64 commentId)</c> (OPELMBNJHNO.txt:1781)
|
|
/// returns the status-only <c>LDGADANDBIO</c>. Verb is <b>DELETE</b>
|
|
/// (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
|
|
/// <c>:{commentId}</c> 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).</summary>
|
|
[HttpDelete("/comments/delete/{commentId:long}")]
|
|
public async Task<IActionResult> 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 ───────────────────────────────────────────────────────
|
|
|
|
/// <summary>One <c>RecNet.RoomComment</c>. Member list and CLR types are
|
|
/// exact (RecNet/RoomComment.cs, cross-checked against the 2023.06.21
|
|
/// dump.cs:1241576): <c>long</c> comment id, <c>long</c> room id,
|
|
/// <c>long?</c> sub-room id, <c>int?</c> account id, <c>DateTime</c>
|
|
/// created-at, <c>string</c> message, the <c>EKHGDABMJOJ</c> style enum, a
|
|
/// settable <c>bool</c>, three internal <c>float?</c> position components,
|
|
/// and one computed get-only <c>bool</c> 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 <b>request</b> 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
|
|
/// <c>RoomComment.MCCJIBCJEJC</c> clears the byte at <c>[rcx+0x64]</c> and
|
|
/// fires the change event (RoomComment.txt:471-493), and the computed
|
|
/// get-only bool <c>FOOFBPEMFCB</c> is <c>AccountId == localAccountId ||
|
|
/// !flag</c> (:442-466, comparing against
|
|
/// <c>OPMAPIOEIFG.GGAEMNHEHLO</c>). Its declared name is unknown, hence the
|
|
/// three non-inverting spellings.</summary>
|
|
private static Dictionary<string, object?> 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);
|
|
|
|
/// <summary>Every stored comment for a room, with the caller's unread flag
|
|
/// resolved against their read-state row.</summary>
|
|
private async Task<List<StoredComment>> 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<StoredComment>(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<CommentPayload>(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;
|
|
}
|
|
|
|
/// <summary>Comment ids are handed out from the wall clock in milliseconds
|
|
/// so they stay globally unique and monotonically increasing across
|
|
/// restarts — the client's <c>minId</c> filter and the unread comparison
|
|
/// both rely on "higher id == newer".</summary>
|
|
private async Task<long> 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;
|
|
}
|
|
|
|
/// <summary>Last-read comment id per room for one player.</summary>
|
|
private async Task<Dictionary<long, long>> 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<long, long>();
|
|
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;
|
|
}
|
|
|
|
/// <summary>Read state only ever moves forward — the watch re-sends older
|
|
/// ids as the grid scrolls back up.</summary>
|
|
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);
|
|
}
|
|
|
|
/// <summary>Room creator, or an accepted co-owner (Role 0) / moderator
|
|
/// (Role 1) — the same grant model
|
|
/// <see cref="Data.Entities.RoomRoleEntity"/> documents.</summary>
|
|
private async Task<bool> 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));
|
|
}
|
|
|
|
/// <summary>Splits <c>roomcomment:{roomId}:{commentId}</c>.</summary>
|
|
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<HashSet<long>> ReadRoomIdsAsync()
|
|
{
|
|
var ids = new HashSet<long>();
|
|
|
|
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;
|
|
}
|
|
}
|