feat: implement the client routes that had no handler

Sixteen agents, one controller each, over every route the coverage test
reported as missing or wrong-verb. Route+verb coverage is now 516/522 (98.9%),
up from 90.8%.

Each handler was written against binary evidence rather than the audit's prose:
verb from the BestHTTP.HTTPMethods ordinal at the call site, response shape from
the issuing method's return type, request shape from whether the client uses
form fields, a raw JsonUtility body, or query params. Agents were told to skip
rather than stub, and several did.

New surface worth noting:

- Link host: actionlink create/get/consume and datalink get/put, previously
  unrouted entirely.
- Club roster reads: members, banned, requests, requests/search, members/search,
  each with the client's real sort/paging params and the wire row shape its
  reader expects (the banned row has no MembershipType field, so it isn't sent).
- Home clubhouse set/clear, club disband (soft, via the existing State column),
  and DELETE mainimage as its own action rather than a verb alias — the client
  parses the details envelope from it, so it is not status-only.
- Club chat threads, comment CRUD, keepsake collect/delete, progression-event
  xp boosts, image report and cheered/bulk, iOS StoreKit product reporting.

The six routes still missing all need storage that does not exist yet
(per-role club permissions, club additional-image slots, MinLevel,
ClubChatEnabled). Those are left for a single schema change rather than faked.

Also excludes DELETE /account/me from the endpoint contract probe. That route is
now a real, correctly-guarded account purge, and the probe walks every route
with one test player — so calling it destroyed the probe's own session and
turned one real event into 49 cascading foreign-key and 401 failures.
This commit is contained in:
Alexa
2026-07-28 09:11:26 +03:00
parent 90e069ee4b
commit 4b5086cdf1
21 changed files with 4474 additions and 370 deletions
@@ -18,7 +18,9 @@ public sealed class GameClientEndpointContractTests :
[Fact] [Fact]
public async Task Game_client_contract_probe_reaches_every_controller_route() public async Task Game_client_contract_probe_reaches_every_controller_route()
{ {
var routes = EndpointContractDiscovery.Discover(); var routes = EndpointContractDiscovery.Discover()
.Where(r => !DestroysTheProbeSession(r))
.ToList();
Assert.NotEmpty(routes); Assert.NotEmpty(routes);
using var client = _factory.CreateClient(new() using var client = _factory.CreateClient(new()
@@ -48,6 +50,26 @@ public sealed class GameClientEndpointContractTests :
+ string.Join(Environment.NewLine, failures)); + string.Join(Environment.NewLine, failures));
} }
/// <summary>Routes this probe must not call, because they destroy the very
/// session it authenticates with.
///
/// The probe walks every route in order using one test player. Account
/// deletion is a genuine, correctly-guarded purge — but once it runs, that
/// player row is gone, so every later probe fails on a foreign-key
/// violation or a 401. Excluding these keeps the failure list meaningful
/// instead of one real event cascading into dozens of phantom failures.
///
/// This is deliberately narrow: only self-destruct routes belong here, not
/// anything merely inconvenient.</summary>
private static bool DestroysTheProbeSession(EndpointContract route)
{
if (!string.Equals(route.Method, "DELETE", StringComparison.OrdinalIgnoreCase))
return false;
var template = route.Template.Trim('/');
return template is "account/me" or "account/v1/me" or "account/v2/me";
}
private static async Task<EndpointProbeResult> ProbeAsync( private static async Task<EndpointProbeResult> ProbeAsync(
HttpClient client, HttpClient client,
EndpointContract route, EndpointContract route,
@@ -1,59 +1,470 @@
using System.Globalization;
using System.Text.Json; using System.Text.Json;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using DorkNet.Server.Auth; using DorkNet.Server.Auth;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
namespace DorkNet.Server.Controllers.API.Comments; namespace DorkNet.Server.Controllers.API.Comments;
/// <summary> /// <summary>
/// Room-comment unread counts. Client contract (RecNet.Runtime /// Room comments ("feedback"/"idea" notes players leave in a room, shown by
/// <c>OPELMBNJHNO.DJLNMANKMAA(IReadOnlyCollection&lt;long&gt;)</c>). /// the watch's RoomCommentsScreen / RoomCommentsPagedGrid).
/// ///
/// The response is a JSON <b>ARRAY</b>, not an object. The method's return /// Every route here is issued by RecNet.Runtime's room-comment service
/// type is <c>Dictionary&lt;long,uint&gt;</c>, but that dictionary is built /// <c>OPELMBNJHNO</c> against service host <c>12 = RoomComments</c>
/// client-side: OPELMBNJHNO.txt:889 constructs a /// (<see cref="Services.ConfigService"/> maps that to the <c>roomcomments</c>
/// <c>Func&lt;List&lt;UnreadRoomComments&gt;, Dictionary&lt;long,uint&gt;&gt;</c> /// subdomain). The five literals, with the <c>BNDIAONDFFF</c> ctor verb
/// projection that runs over the deserialised payload. So the wire type is /// ordinal moved into <c>rdx</c> immediately before the builder call
/// <c>List&lt;RecNet.UnreadRoomComments&gt;</c> — each element an object with a /// (BestHTTP.HTTPMethods: 0=GET, 2=POST, 3=PUT, 4=DELETE):
/// 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 /// <list type="bullet">
/// read a JSON object into a <c>List&lt;T&gt;</c>, so every call threw and the /// <item><c>OPELMBNJHNO.txt:485</c> "comments/get/{0}" — rdx=0 (GET), host [rdx+12]</item>
/// client logged "Failed to get unread room comment counts" /// <item><c>OPELMBNJHNO.txt:811</c> "comments/unreadcounts" — dynamic GET/POST</item>
/// (OPELMBNJHNO.txt:937) and fell back to an empty dictionary — the watch's /// <item><c>OPELMBNJHNO.txt:1216</c> "comments/create/{0}" — rdx=2 (POST), host [r12+12]</item>
/// room-comment unread badge never appeared. /// <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>
/// ///
/// The element's exact property names are attribute-driven and are not /// STORAGE. There is no <c>RoomCommentEntity</c> and adding entities
/// recoverable from the ISIL (see the note on key aliasing below), so each /// needs a migration, so comments live in the <see cref="PlayerSettingEntity"/>
/// element carries the plausible spellings side by side. Json.NET matches /// side-table exactly like the PlayerEvent extras
/// case-insensitively and ignores unknown members, so the extra aliases are /// (<c>PlayerEventsController.EventExtras</c>): one row per comment with
/// inert whichever name the DTO actually declares. /// <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,
/// Counts are all zero until per-player comment read-state is tracked; the /// disjoint namespace — <c>roomcommentread:{roomId}</c> holding the last-read
/// shape is what matters for the client not to throw. /// comment id — which is what finally lets
/// <see cref="UnreadCounts"/> report real numbers instead of zeroes.
/// </summary> /// </summary>
[ApiController] [ApiController]
[Authorize] [Authorize]
public class CommentsController : ControllerBase 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&lt;Vector3&gt;</c> property is <c>[IgnoreDataMember]</c> and is
/// rebuilt after deserialisation from three internal
/// <c>Nullable&lt;float&gt;</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&lt;List&lt;RecNet.RoomComment&gt;&gt;</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&lt;long&gt;)</c>).
///
/// The response is a JSON <b>ARRAY</b>, not an object. The method's return
/// type is <c>Dictionary&lt;long,uint&gt;</c>, but that dictionary is built
/// client-side: OPELMBNJHNO.txt:889 constructs a
/// <c>Func&lt;List&lt;UnreadRoomComments&gt;, Dictionary&lt;long,uint&gt;&gt;</c>
/// projection that runs over the deserialised payload. So the wire type is
/// <c>List&lt;RecNet.UnreadRoomComments&gt;</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&lt;T&gt;</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")] [HttpPost("/comments/unreadcounts")]
[HttpGet("/comments/unreadcounts")] [HttpGet("/comments/unreadcounts")]
[Consumes("application/json", "application/x-www-form-urlencoded", "multipart/form-data")] [Consumes("application/json", "application/x-www-form-urlencoded", "multipart/form-data")]
public async Task<IActionResult> UnreadCounts() public async Task<IActionResult> UnreadCounts()
{ {
var roomIds = await ReadRoomIdsAsync(); var roomIds = await ReadRoomIdsAsync();
if (roomIds.Count == 0) return Ok(new List<Dictionary<string, object>>());
var me = Me;
var lastRead = await LastReadAsync(me);
// Key + owner only: the payload column is irrelevant to counting.
var keys = await db.PlayerSettings
.Where(s => s.Key.StartsWith(CommentPrefix))
.Select(s => new { s.Key, s.PlayerId })
.ToListAsync();
var counts = new Dictionary<long, uint>();
foreach (var id in roomIds) counts[id] = 0u;
foreach (var k in keys)
{
if (!TrySplitCommentKey(k.Key, out var roomId, out var commentId)) continue;
if (!counts.ContainsKey(roomId)) continue;
if (k.PlayerId == me) continue; // your own notes are never unread
if (commentId <= lastRead.GetValueOrDefault(roomId)) continue;
counts[roomId]++;
}
var result = roomIds var result = roomIds
.Select(id => new Dictionary<string, object> .Select(id => new Dictionary<string, object>
{ {
["RoomId"] = id, ["RoomId"] = id,
["UnreadCount"] = 0u, ["UnreadCount"] = counts[id],
["Count"] = 0u, ["Count"] = counts[id],
}) })
.ToList(); .ToList();
return Ok(result); 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&lt;Vector3&gt; position)</c>
/// (OPELMBNJHNO.txt:940) returns <c>FGLDKEJLAKB&lt;RecNet.RoomComment&gt;</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 status-only <c>LDGADANDBIO</c>, not a
/// DTO — the body is never parsed, only the status code. 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 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() private async Task<HashSet<long>> ReadRoomIdsAsync()
{ {
var ids = new HashSet<long>(); var ids = new HashSet<long>();
@@ -1,6 +1,7 @@
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using System.Globalization;
using System.Security.Cryptography; using System.Security.Cryptography;
using System.Text.Json; using System.Text.Json;
using DorkNet.Server.Auth; using DorkNet.Server.Auth;
@@ -109,6 +110,109 @@ public class CompatibilityFeatureController(DorkNetDbContext db) : ControllerBas
public IActionResult Ps5RecRoomPlusEnabledForAllPlayers() public IActionResult Ps5RecRoomPlusEnabledForAllPlayers()
=> Content("true", "application/json"); => Content("true", "application/json");
/// <summary>The 2023-03-21 client POSTs this — the request is built at
/// RecNet.Runtime/GAAPALMODOL.txt:104-106, where the route literal
/// <c>"api/AppIntegrity/v1/iosproducts"</c> goes into <c>r9</c> and the
/// verb register <c>rdx=2</c> = <c>BestHTTP.HTTPMethods.Post</c> (host
/// <c>r8=1</c>). The payload is a RAW JSON body — the serialized list is
/// handed to <c>BNDIAONDFFF.FJLLPHFOOJJ</c> (:138), not the
/// form-parameter helper — holding
/// <c>List&lt;GAAPALMODOL/KOLPHINEHDD&gt;</c>, i.e. a top-level array of
/// {Name:String, Price:Single, ProductId:String} (getters at
/// GAAPALMODOL_NestedType_KOLPHINEHDD.txt:3/25/45; formatter key table,
/// which also accepts camel/lower spellings, at KAONMCEONPF.txt:255-306).
/// The issuing method's return type is
/// <c>FGLDKEJLAKB&lt;PHMHCPEMABG&gt;</c> (:3) and PHMHCPEMABG is
/// {Boolean, String} (PHMHCPEMABG.txt:3/23) keyed
/// <c>Success</c>/<c>Message</c> (GBPDOLJBABB.txt:191-218).
///
/// <see cref="DorkNet.Server.Controllers.API.AppIntegrity.AppIntegrityController"/>
/// registers the same
/// path GET-only, so the client's POST hit a 405 and the StoreKit
/// price report was dropped. Registering only the POST verb here leaves
/// that GET (and its consumers) untouched — the two never collide
/// because the method constraints are disjoint.
///
/// The reported catalogue is what Apple's StoreKit resolved on the
/// device, so it is inherently per-account/per-storefront: it is
/// persisted as the reporting player's <c>appintegrity:iosproducts</c>
/// setting (same PlayerSettings convention as the ban-appeal handler
/// above), replacing any earlier report. Only a malformed body answers
/// Success=false; the client shows nothing on success and PHMHCPEMABG's
/// Message is left empty.</summary>
[HttpPost("api/AppIntegrity/v1/iosproducts")]
[Authorize]
public async Task<IActionResult> IosProducts()
{
var pid = this.RequireCurrentPlayerId();
List<IosProduct>? products;
try
{
products = await JsonSerializer.DeserializeAsync<List<IosProduct>>(
Request.Body,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
}
catch (JsonException)
{
return IosProductsResult(false, "malformed_product_list");
}
if (products is null) return IosProductsResult(false, "malformed_product_list");
// "{productId}={price}" pairs rather than the raw JSON: the
// localized Name adds nothing a server-side reader needs and
// PlayerSettingEntity.Value caps at 1024 chars, so entries are
// dropped (never truncated mid-pair) once the budget is spent.
var parts = new List<string>();
var length = 0;
foreach (var p in products)
{
var productId = p.ProductId?.Trim();
if (string.IsNullOrEmpty(productId)) continue;
var pair = $"{productId}={p.Price.ToString(CultureInfo.InvariantCulture)}";
var cost = parts.Count == 0 ? pair.Length : pair.Length + 1;
if (length + cost > 1024) break;
parts.Add(pair);
length += cost;
}
const string key = "appintegrity:iosproducts";
var row = await db.PlayerSettings
.FirstOrDefaultAsync(s => s.PlayerId == pid && s.Key == key);
if (row is null)
{
db.PlayerSettings.Add(new PlayerSettingEntity
{
PlayerId = pid,
Key = key,
Value = string.Join(';', parts),
});
}
else
{
row.Value = string.Join(';', parts);
}
await db.SaveChangesAsync();
return IosProductsResult(true, string.Empty);
}
/// <summary>PHMHCPEMABG accepts PascalCase, camelCase and all-lowercase
/// key spellings (GBPDOLJBABB.txt:191-218), so either casing alone would
/// satisfy it; both are emitted for the same reason the CampusCard
/// payload above does. Dictionary keys go out verbatim — the global
/// serializer sets PropertyNamingPolicy = null
/// (Startup/ServiceCollectionExtensions.cs:381).</summary>
private IActionResult IosProductsResult(bool success, string message)
=> Ok(new Dictionary<string, object?>
{
["Success"] = success,
["Message"] = message,
["success"] = success,
["message"] = message,
});
[HttpPost("api/clubreporting/v1/report")] [HttpPost("api/clubreporting/v1/report")]
[Authorize] [Authorize]
[Consumes("application/json", "application/x-www-form-urlencoded", "multipart/form-data")] [Consumes("application/json", "application/x-www-form-urlencoded", "multipart/form-data")]
@@ -192,6 +296,18 @@ public class CompatibilityFeatureController(DorkNetDbContext db) : ControllerBas
public long PlatformAccountSubscribedPlayerId { get; set; } public long PlatformAccountSubscribedPlayerId { get; set; }
} }
/// <summary>One entry of the client's raw JSON array —
/// GAAPALMODOL/KOLPHINEHDD, whose three properties are String, Single,
/// String in that field order (offsets 0x10/0x18/0x20 in
/// GAAPALMODOL_NestedType_KOLPHINEHDD.txt) and whose formatter writes
/// the keys Name / Price / ProductId (KAONMCEONPF.txt:321-360).</summary>
private sealed class IosProduct
{
public string? Name { get; set; }
public float Price { get; set; }
public string? ProductId { get; set; }
}
private sealed class ClubReportRequest private sealed class ClubReportRequest
{ {
public long ClubId { get; set; } public long ClubId { get; set; }
@@ -590,6 +590,236 @@ public class CustomAvatarItemsController(
return Ok(new { Success = true, Balance = newBalance, Item = ToWire(item) }); return Ok(new { Success = true, Balance = newBalance, Item = ToWire(item) });
} }
/// <summary>POST api/customAvatarItems/v1/{id}/report — file a moderation
/// report against another player's custom shirt. No report route was
/// registered anywhere on the server, so the report dialog's "Submit"
/// button 404'd and the client toasted "Unable to report custom avatar
/// item {0}".
///
/// Verb ordinal <c>dl=2</c> = POST, on the URL
/// String.Format("{0}/v1/{1}/report") over "api/customAvatarItems"
/// (RecNet.Runtime/MPBLNLMCEDL.txt:4261-4275; the failure literal is
/// :4301). The body is RAW JSON — JsonConvert.SerializeObject of a
/// JEHCPEEKMML handed straight to BNDIAONDFFF.FJLLPHFOOJJ (:4252-4256,
/// :4282), NOT form-urlencoded — so the body is read here instead of
/// model-bound, exactly like PUT {id} above.
///
/// The issuing method IALGIAGBJMA (:4079) returns LDGADANDBIO — status
/// only. Its continuation chain (0x181F2F300 → 0x1836E6740 →
/// BFDGEKKNAPB.PEFKBAEAOPD, :4285-4294) is byte-for-byte the one the
/// DELETE {id} path uses (:2206-2215), and that path is satisfied by a
/// bare 200, so an empty 200 is the whole response contract here too.
///
/// JEHCPEEKMML has exactly three members, pinned by field offset from the
/// accessor bodies (JEHCPEEKMML.txt:6/86/108) and by the arg-store
/// sequence that fills it (:4239-4247): +0x10 the reason enum, +0x18 the
/// free-text detail, +0x20 a Nullable&lt;Int32&gt;. Their JSON KEY names
/// live in attribute metadata Cpp2IL doesn't render — obfuscated in the
/// 2023.06 dump too (EFPKFBKGLCD, dump.cs:1256458-1256469) — so each is
/// matched against an alias list, with a positional fallback in
/// declaration order for anything unrecognised (Newtonsoft writes members
/// in declaration order and the DTO has exactly these three).
///
/// The reason enum IS readable: LGEPGEHKNAG.KFCMACCLDEJ in the 2023.06
/// dump (dump.cs:1250039-1250044) — CoC_Discriminatory=0, CoC_Sexual=1,
/// CoC_Trolling=2, Misleading=3, Other=4, InappropriateClothing=5. That is
/// a DIFFERENT numbering from <see cref="ReportEntity.Category"/>'s
/// RoomieReportCategory convention (Harassment=1 … Other=5), so the
/// ordinal is stored verbatim and the readable name is written into the
/// message — otherwise a moderator reading the shared queue cannot tell a
/// CoC_Sexual=1 clothing report from a Harassment=1 player report.
///
/// <see cref="ReportEntity"/> has no custom-avatar-item column and adding
/// one is a migration, so the reported item's guid is carried in the
/// stored text — the same thing the sibling screen-share report handler
/// does with its image/instance evidence. That marker prefix is also what
/// makes the duplicate check below work.</summary>
[HttpPost("{id:guid}/report")]
[Authorize]
public async Task<IActionResult> Report(Guid id)
{
var pid = this.RequireCurrentPlayerId();
var item = await db.CustomAvatarItems.FirstOrDefaultAsync(i => i.PublicId == id);
if (item is null) return NotFound();
// The report button only renders on someone else's item, so this is an
// impossible UI state; reject rather than pollute the queue.
if (item.CreatorPlayerId == pid) return BadRequest(new { error = "cannot_report_own_item" });
var req = await ReadReportRequestAsync();
// The dialog refuses to submit without a category ("Cannot report
// custom clothing without a report category." —
// Assembly-CSharp/RRUI/Data/CustomAvatarItemReportModel.txt:1941), so
// a missing reason means a non-stock caller; bucket it as Other=4.
var reason = req.Reason ?? 4;
var context = new List<string> { $"reason={ReportReasonName(reason)}({reason})" };
// The only in-game caller passes null for the Nullable<Int32>
// (CustomAvatarItemReportModel.txt:2008/2011 push rbx=0 for both it and
// the trailing bool), so its meaning is unproven — keep whatever a
// client does send rather than silently dropping evidence.
if (req.Context is { } ctx) context.Add($"ctx={ctx}");
var details = (req.Details ?? string.Empty).Trim();
var message = $"{ReportMarker(id)}{string.Join(' ', context)}] {details}".Trim();
if (message.Length > 1000) message = message[..1000];
// One open report per (reporter, item): the dialog is re-openable and
// the client retries on a failed submit, so without this a single
// annoyed player can flood the moderation queue.
var marker = ReportMarker(id);
var duplicate = await db.Reports.AnyAsync(r =>
r.ReporterPlayerId == pid && r.ResolvedAt == null && r.Message.StartsWith(marker));
if (duplicate)
{
logger.LogInformation(
"[customAvatarItems] duplicate report of {PublicId} by {Pid} ignored", id, pid);
return Ok();
}
db.Reports.Add(new ReportEntity
{
ReporterPlayerId = pid,
TargetPlayerId = item.CreatorPlayerId,
Category = reason,
Message = message,
});
await db.SaveChangesAsync();
logger.LogInformation(
"[customAvatarItems] reported {PublicId} (creator {Creator}) by {Pid} reason={Reason}",
id, item.CreatorPlayerId, pid, ReportReasonName(reason));
return Ok();
}
/// <summary>Prefix every stored custom-item report with the item guid so
/// the row is attributable without a dedicated column, and so the
/// duplicate check can find prior reports with a plain LIKE.</summary>
private static string ReportMarker(Guid id) => $"[customAvatarItem {id:D} ";
/// <summary>LGEPGEHKNAG.KFCMACCLDEJ, verbatim from the 2023.06 dump
/// (dump.cs:1250039-1250044). Index IS the wire ordinal.</summary>
private static readonly string[] ReportReasonNames =
{
"CoC_Discriminatory", "CoC_Sexual", "CoC_Trolling",
"Misleading", "Other", "InappropriateClothing",
};
private static string ReportReasonName(int reason) =>
reason >= 0 && reason < ReportReasonNames.Length ? ReportReasonNames[reason] : "Unknown";
/// <summary>Read the report body. The stock client sends raw JSON, but
/// form encodings are accepted too so an admin/tooling caller can post the
/// same three fields flat.</summary>
private async Task<ReportRequest> ReadReportRequestAsync()
{
if (Request.HasFormContentType)
{
var form = await Request.ReadFormAsync();
string? Field(params string[] keys)
{
foreach (var key in keys)
if (form[key].FirstOrDefault() is { } v) return v;
return null;
}
return new ReportRequest
{
Reason = ParseReportReason(Field("reason", "Reason", "reportReason", "ReportReason",
"category", "Category", "reportCategory", "ReportCategory", "type", "Type")),
Details = Field("details", "Details", "description", "Description",
"message", "Message", "comment", "Comment"),
Context = int.TryParse(
Field("contextId", "ContextId", "instanceId", "InstanceId",
"roomInstanceId", "RoomInstanceId", "roomId", "RoomId",
"accountId", "AccountId", "playerId", "PlayerId"),
out var ctx)
? ctx
: null,
};
}
using var reader = new StreamReader(Request.Body);
return ParseReportJson(await reader.ReadToEndAsync());
}
/// <summary>Lenient JSON → <see cref="ReportRequest"/>. Alias-matched
/// first; anything left over is assigned positionally in JEHCPEEKMML's
/// declaration order (reason, detail, nullable int) so an unguessed key
/// spelling still lands in the right slot.</summary>
private static ReportRequest ParseReportJson(string json)
{
var req = new ReportRequest();
if (string.IsNullOrWhiteSpace(json)) return req;
try
{
using var doc = JsonDocument.Parse(json);
if (doc.RootElement.ValueKind != JsonValueKind.Object) return req;
var unmatched = new List<JsonElement>();
foreach (var prop in doc.RootElement.EnumerateObject())
{
switch (prop.Name.ToLowerInvariant())
{
case "reason" or "reportreason" or "category" or "reportcategory" or "type" or "reporttype":
req.Reason ??= ParseReportReason(prop.Value);
break;
case "details" or "detail" or "description" or "reportdescription"
or "message" or "comment" or "text":
req.Details ??= AsString(prop.Value);
break;
case "contextid" or "instanceid" or "roominstanceid" or "roomid"
or "accountid" or "playerid" or "reportedplayerid":
req.Context ??= AsInt(prop.Value);
break;
default:
unmatched.Add(prop.Value);
break;
}
}
foreach (var value in unmatched)
{
if (req.Reason is null && ParseReportReason(value) is { } reason)
{
req.Reason = reason;
continue;
}
if (req.Details is null && value.ValueKind == JsonValueKind.String)
{
req.Details = value.GetString();
continue;
}
req.Context ??= AsInt(value);
}
}
catch (JsonException)
{
}
return req;
}
/// <summary>Accept the reason as an ordinal or as one of the enum's own
/// names — Newtonsoft writes an int by default, but a StringEnumConverter
/// on the DTO (an attribute the dump doesn't render) would write the name.</summary>
private static int? ParseReportReason(JsonElement value) => value.ValueKind switch
{
JsonValueKind.Number => value.TryGetInt32(out var n) ? n : null,
JsonValueKind.String => ParseReportReason(value.GetString()),
_ => null,
};
private static int? ParseReportReason(string? value)
{
if (string.IsNullOrWhiteSpace(value)) return null;
if (int.TryParse(value, out var ordinal)) return ordinal;
var index = Array.FindIndex(
ReportReasonNames,
n => string.Equals(n, value, StringComparison.OrdinalIgnoreCase));
return index >= 0 ? index : null;
}
private async Task EnsureOwnershipAsync(long playerId, long itemId) private async Task EnsureOwnershipAsync(long playerId, long itemId)
{ {
if (await db.CustomAvatarItemOwnership.AnyAsync(o => if (await db.CustomAvatarItemOwnership.AnyAsync(o =>
@@ -971,6 +1201,18 @@ public class CustomAvatarItemsController(
public bool Deleted { get; set; } public bool Deleted { get; set; }
} }
/// <summary>The three members of the client's report DTO JEHCPEEKMML,
/// in its declaration order: the reason enum (+0x10), the free-text
/// detail (+0x18) and an unidentified Nullable&lt;Int32&gt; (+0x20) that
/// the stock report dialog always leaves null. All nullable so
/// "not sent" is distinguishable from "sent empty".</summary>
public sealed class ReportRequest
{
public int? Reason { get; set; }
public string? Details { get; set; }
public int? Context { get; set; }
}
public sealed class DesignRequest public sealed class DesignRequest
{ {
public Guid? Id { get; set; } public Guid? Id { get; set; }
@@ -322,6 +322,41 @@ public class ImagesController(
return Ok(BuildImageInfo(photo, photo.UploaderPlayerId)); return Ok(BuildImageInfo(photo, photo.UploaderPlayerId));
} }
/// <summary>GET <c>api/images/v6/{id}</c> — single photo by numeric id.
/// The 2023-03-21 client builds the path with
/// <c>String.Format("{0}v6/{1}", "api/images/", id)</c>
/// (<c>KLJOGJHBONK.txt:2694-2698</c>) and passes verb 0 = GET into the
/// request-builder ctor (<c>:2702 Move rdx, 0</c> → <c>:2708 Call
/// 0x1830036A0</c>); no query fields are attached.
///
/// The issuing method <c>KLJOGJHBONK.HLANOFILAEO(System.Int64)</c> returns
/// <c>FGLDKEJLAKB&lt;IReadOnlyList&lt;Int32&gt;&gt;</c>, but that is a
/// CLIENT-SIDE projection: a
/// <c>Func&lt;LGLCPNPJCEC, IReadOnlyList&lt;Int32&gt;&gt;</c>
/// (<c>:2756</c>) reduces the wire object to its tagged-player ids. So the
/// body must be ONE <c>LGLCPNPJCEC</c> object — an array breaks the reader —
/// with the 12 keys its generated serializer registers
/// (<c>IBILPLGNAJE.txt:819-1078</c>: Id, ImageName, PlayerId, RoomId,
/// PlayerEventId, Accessibility, AccessibilityLocked, Type, CreatedAt,
/// TaggedPlayerIds, CheerCount, CommentCount), all of which
/// <see cref="BuildImageInfo"/> already emits.
///
/// Without this route the request fell through to
/// <c>api/images/{*path}</c>, which rejects "123" as a non-image filename →
/// 404 on every photo-detail open. Visibility matches the by-name lookup:
/// public photos to anyone, private ones only to their uploader.</summary>
[HttpGet("api/images/v6/{id:long}")]
[AllowAnonymous]
public async Task<IActionResult> ImageByIdV6(long id)
{
var pid = this.CurrentPlayerId();
var photo = await db.Photos.FirstOrDefaultAsync(p =>
p.Id == id && p.DeletedAt == null &&
(p.IsPublic || (pid != null && p.UploaderPlayerId == pid.Value)));
if (photo is null) return NotFound();
return Ok(BuildImageInfo(photo, photo.UploaderPlayerId));
}
/// <summary>GET <c>api/images/v2/named</c> — the watch's /// <summary>GET <c>api/images/v2/named</c> — the watch's
/// <c>Images.DownloadNamedImageMappings</c> endpoint. Returns a /// <c>Images.DownloadNamedImageMappings</c> endpoint. Returns a
/// list of <c>NamedImageDTO</c> entries; an empty list is a valid /// list of <c>NamedImageDTO</c> entries; an empty list is a valid
@@ -522,6 +557,51 @@ public class ImagesController(
return Ok(new { deleted = id }); return Ok(new { deleted = id });
} }
/// <summary>POST <c>api/images/v1/{id}/report</c> — file a moderation
/// report against a photo from the in-game photo viewer.
/// <c>KLJOGJHBONK.IDAJEMBKAGF(System.Int64)</c> formats the path as
/// <c>String.Format("{0}v1/{1}/report", "api/images/", id)</c>
/// (<c>KLJOGJHBONK.txt:4010-4014</c>) and moves verb 2 = POST into the
/// builder ctor (<c>:4021 Move rdx, 2</c>, host 1 at <c>:4020</c>).
///
/// The request carries NO fields and NO body — no category, no free text —
/// and the reply is dispatched through <c>BNDIAONDFFF.KDOPJCNKOOK</c>
/// (<c>:4031</c>), which never deserialises it: the call is
/// fire-and-forget, so the client cannot tell a 404 from a 200 and every
/// report was being dropped silently. Everything actionable therefore has
/// to be derived server-side from the photo id, which is why the reported
/// photo's uploader becomes the report target and the photo's room the
/// context. Category 5 = "Other", matching the screenshare reporter's
/// default for reports that arrive without one.</summary>
[HttpPost("api/images/v1/{id:long}/report")]
[Authorize]
public async Task<IActionResult> ReportPhoto(long id)
{
var reporter = this.RequireCurrentPlayerId();
var photo = await db.Photos.FirstOrDefaultAsync(p => p.Id == id && p.DeletedAt == null);
if (photo is null) return NotFound();
// ReportEntity has no TargetPhotoId column, so the photo id + blob name
// ride in the message text the same way club reports carry "[club {id}]"
// — a moderator needs the actual image to act on the report.
var message = $"[photo {photo.Id} {photo.BlobName}] reported from the in-game photo viewer";
db.Reports.Add(new ReportEntity
{
ReporterPlayerId = reporter,
TargetPlayerId = photo.UploaderPlayerId,
RoomId = photo.RoomId,
Category = 5,
Message = message[..Math.Min(1000, message.Length)],
});
await db.SaveChangesAsync();
logger.LogInformation(
"[images] photo {PhotoId} reported by {Reporter} (uploader {Uploader}, room {RoomId})",
photo.Id, reporter, photo.UploaderPlayerId, photo.RoomId);
return Ok(new RecNetResult { Success = true, Error = string.Empty });
}
// ── Legacy v1 ops (real persistence, was Ack-only stubs) ───────── // ── Legacy v1 ops (real persistence, was Ack-only stubs) ─────────
public sealed class CheerImageRequest public sealed class CheerImageRequest
@@ -530,23 +610,48 @@ public class ImagesController(
public long PhotoId { get; set; } public long PhotoId { get; set; }
} }
public sealed class BulkCheeredRequest /// <summary>Field names the cheer-state batch accepts. The 2023-03-21
{ /// client sends the singular <c>"id"</c>
public List<long>? SavedImageIds { get; set; } /// (<c>KLJOGJHBONK.txt:3763 Move rdx, "id"</c>); the older spellings are
public List<long>? ImageIds { get; set; } /// kept so the 2020.12 watch and our own tooling keep working.</summary>
} private static readonly string[] CheerBulkIdKeys =
{ "id", "ids", "SavedImageId", "SavedImageIds", "ImageId", "ImageIds" };
/// <summary>GET — and POST once the id list reaches 100 — for
/// <c>api/images/v{4,5}/cheered/bulk</c>: the caller's cheer state for a
/// batch of photos (the hearts on the photo feed).
///
/// VERB: the client picks it at runtime.
/// <c>KLJOGJHBONK.JCLGICHPPGB(IEnumerable&lt;Int64&gt;)</c> calls
/// <c>ALHIJCJOLCB.JIECAFGCODK(count, 100)</c> and moves the result straight
/// into the request-builder's verb register
/// (<c>KLJOGJHBONK.txt:3747 Call ALHIJCJOLCB.JIECAFGCODK</c> →
/// <c>:3755 Move rdx, rdi</c> → <c>:3753 Move r9,
/// "api/images/v5/cheered/bulk"</c>) — GET (0) for short lists, POST (2) for
/// long ones. Registering POST only meant the common case fell into the
/// <c>api/images/{*path}</c> byte catch-all and 404'd, so cheer state never
/// loaded.
///
/// REQUEST: one field, <c>"id"</c>, added via
/// <c>BNDIAONDFFF.AFGEDDANEKP("id", ids)</c> (<c>KLJOGJHBONK.txt:3763</c>).
/// The builder emits fields as query values on GET and form values on POST —
/// never as a JSON body — so the ids are read straight off the request
/// instead of through <c>[FromBody]</c>, which would 415 the form POST
/// before the action ran.
///
/// RESPONSE: <c>List&lt;LAKGLIDCEDE&gt;</c>, i.e.
/// <c>[{"SavedImageId":Int64,"IsCheered":Boolean}]</c> — key literals in the
/// generated serializer at <c>KJEIGGBIIHP.txt:215,242</c>. One entry per
/// requested id so the client's cache has no holes.</summary>
[HttpGet("api/images/v4/cheered/bulk")]
[HttpGet("api/images/v5/cheered/bulk")]
[HttpPost("api/images/v4/cheered/bulk")] [HttpPost("api/images/v4/cheered/bulk")]
[HttpPost("api/images/v5/cheered/bulk")] [HttpPost("api/images/v5/cheered/bulk")]
[Authorize] [Authorize]
public async Task<IActionResult> CheeredBulk([FromBody] BulkCheeredRequest? body) public async Task<IActionResult> CheeredBulk()
{ {
var pid = this.RequireCurrentPlayerId(); var pid = this.RequireCurrentPlayerId();
var ids = (body?.SavedImageIds ?? body?.ImageIds ?? new()) var ids = await ReadCheerBulkIdsAsync();
.Where(id => id > 0)
.Distinct()
.Take(200)
.ToList();
if (ids.Count == 0) return Ok(Array.Empty<object>()); if (ids.Count == 0) return Ok(Array.Empty<object>());
var cheered = await db.Cheers var cheered = await db.Cheers
@@ -561,6 +666,82 @@ public class ImagesController(
})); }));
} }
/// <summary>Collect the cheer-state batch ids from wherever this client
/// put them: query values on the GET path, form values on the POST path
/// (both produced by <c>BNDIAONDFFF</c>'s field list), or a JSON body for
/// non-game callers. Repeated fields and comma-joined values are both
/// accepted because the builder's query assembler emits one
/// <c>&amp;id=</c> per element while our own admin tooling sends a single
/// comma-separated value.</summary>
private async Task<List<long>> ReadCheerBulkIdsAsync()
{
var ids = new List<long>();
void Collect(string? raw)
{
if (string.IsNullOrWhiteSpace(raw)) return;
foreach (var part in raw.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
if (long.TryParse(part, out var id) && id > 0) ids.Add(id);
}
foreach (var key in CheerBulkIdKeys)
foreach (var value in Request.Query[key])
Collect(value);
if (Request.HasFormContentType)
{
var form = await Request.ReadFormAsync();
foreach (var key in CheerBulkIdKeys)
foreach (var value in form[key])
Collect(value);
}
else if (Request.ContentLength is > 0)
{
try
{
Request.EnableBuffering();
Request.Body.Position = 0;
using var doc = await JsonDocument.ParseAsync(Request.Body, cancellationToken: HttpContext.RequestAborted);
Request.Body.Position = 0;
CollectJsonIds(doc.RootElement, Collect);
}
catch (JsonException)
{
// Not JSON — the query/form pass above is authoritative.
}
}
return ids.Distinct().Take(500).ToList();
}
/// <summary>Pull ids out of a JSON body that is either a bare array of
/// numbers or an object keyed by one of <see cref="CheerBulkIdKeys"/>.</summary>
private static void CollectJsonIds(JsonElement root, Action<string?> collect)
{
switch (root.ValueKind)
{
case JsonValueKind.Array:
foreach (var item in root.EnumerateArray()) CollectJsonIds(item, collect);
break;
case JsonValueKind.Object:
foreach (var key in CheerBulkIdKeys)
{
foreach (var prop in root.EnumerateObject())
{
if (!string.Equals(prop.Name, key, StringComparison.OrdinalIgnoreCase)) continue;
CollectJsonIds(prop.Value, collect);
}
}
break;
case JsonValueKind.Number:
collect(root.GetRawText());
break;
case JsonValueKind.String:
collect(root.GetString());
break;
}
}
/// <summary>POST <c>api/images/v1/cheer</c> — cheer a photo. /// <summary>POST <c>api/images/v1/cheer</c> — cheer a photo.
/// Was previously Ack-only; now persists a <c>CheerEntity</c> row /// Was previously Ack-only; now persists a <c>CheerEntity</c> row
/// keyed on <c>TargetPhotoId</c> + bumps <c>Photos.CheerCount</c>. /// keyed on <c>TargetPhotoId</c> + bumps <c>Photos.CheerCount</c>.
@@ -1144,6 +1144,222 @@ public class InventionsController(
return Ok(new { Success = true, Message = string.Empty }); return Ok(new { Success = true, Message = string.Empty });
} }
// ── Rec Room Studio remote run ───────────────────────────────────────
/// <summary>One uploaded blob as the client describes it: three strings.
/// Wire keys are literal in the generated serializer — "Filename", "Hash",
/// "OwnershipProof" (FLELPHJDLNG.txt:255/274/290, with camelCase and
/// all-lowercase aliases registered alongside each). The backing type is
/// <c>CFPEPOJAMHH/IFIBBJDIHJH</c>, three <c>System.String</c> accessors
/// (CFPEPOJAMHH_NestedType_IFIBBJDIHJH.txt:3/25/47). Same triple the room
/// save commit sends on <c>rooms/{id}/subrooms/{id}/data</c>; declared here
/// so this controller binds its own body.</summary>
public sealed class StudioBlobRef
{
public string? Filename { get; set; }
public string? Hash { get; set; }
public string? OwnershipProof { get; set; }
}
/// <summary>Body of <c>remote-run/push-to-studio</c> — RecNet type
/// <c>FNAGBPCAGJD</c>. Field order/types come from the request builder
/// (EHIOLHBGODG.txt:045-070 writes +16 String, +24 Int64, +32 Int64,
/// +40 String, +48/+56 the two blob refs, +64 a <c>Nullable&lt;Int32&gt;</c>)
/// and the property accessors on FNAGBPCAGJD.txt:3-141. The JSON key for
/// each is literal in the generated serializer OFMNNCMPEPA.txt:535-698:
/// <c>SessionId, RoomId, SubRoomId, UnityAssetId, RoomData, SubRoomData,
/// SavedByAccountId</c> (camel/lower aliases registered too).</summary>
public sealed class PushToStudioRequest
{
public string? SessionId { get; set; }
public long RoomId { get; set; }
public long SubRoomId { get; set; }
public string? UnityAssetId { get; set; }
public StudioBlobRef? RoomData { get; set; }
public StudioBlobRef? SubRoomData { get; set; }
public int? SavedByAccountId { get; set; }
}
/// <summary>POST <c>remote-run/push-to-studio</c> — hands the room the
/// player just uploaded to the paired Rec Room Studio session, WITHOUT
/// committing it as the room's live save (that is the separate
/// <c>rooms/{id}/subrooms/{id}/data</c> path; the client's two flows are
/// sibling methods OGPDOMCNIFM.txt:271 vs :449).
///
/// <para><b>Verb.</b> POST. The request builder at EHIOLHBGODG.txt:411-417
/// moves the route literal <c>"remote-run/push-to-studio"</c> into r9 and
/// <c>2</c> (BestHTTP HTTPMethods.Post) into rdx before
/// <c>BNDIAONDFFF..ctor(verb, host, route)</c>. No cmov — one verb only.
/// The body is RAW JSON, not form fields: :433 hands the serialized DTO to
/// <c>BNDIAONDFFF.FJLLPHFOOJJ</c> (RawJsonForm, <c>application/json</c>),
/// so this binds via <see cref="Binding.FormOrJsonModelBinder"/> — the
/// explicit [ModelBinder] is required or [ApiController] re-infers
/// [FromBody] and 415s a form-shaped caller.</para>
///
/// <para><b>Response.</b> <c>FGLDKEJLAKB&lt;CEELGOLBHIL&gt;</c>
/// (EHIOLHBGODG.txt:217) — a single RemoteRunDetails object, EIGHT keys,
/// all literal in its generated reader PPHLKNGCGOE.txt:599-786:
/// <c>SessionId</c> (String), <c>RoomId</c> and <c>SubRoomId</c>
/// (<c>Nullable&lt;Int64&gt;</c> — CEELGOLBHIL.txt:25/51), then
/// <c>UnityAssetId, RoomDataFilename, RoomDataHash, SubRoomDataFilename,
/// SubRoomDataHash</c> (String). Note the response FLATTENS the two blob
/// refs the request nests, and drops OwnershipProof. A failed/malformed
/// reply surfaces the client's "Failed to push to Rec Room Studio" toast
/// (EHIOLHBGODG.txt:445).</para>
///
/// <para><b>What it persists.</b> The pushed blobs were already stored by
/// <c>/upload</c> (every FileType there inserts a
/// <see cref="RoomDataBlobEntity"/>), so the push is a registration step:
/// it stamps the sub-room save blob with the room + sub-room it belongs to
/// — that is what makes it appear in the per-sub-room
/// <c>datahistory</c>/"Restore to old version" list, which the FileType=6
/// room-metadata upload path (RoomId=0, SubRoomId=null) cannot do on its
/// own — writes the session id into
/// <see cref="RoomEntity.StudioSessionId"/> (the column that exists for
/// exactly this and is echoed in room details), records the pushed Unity
/// asset id on the sub-room's <see cref="RoomSceneEntity"/>, and keeps the
/// whole push under <c>remoterun:{SessionId}</c> in
/// <see cref="PlayerSettingEntity"/> so the details survive for the paired
/// Studio session to resolve. <see cref="RoomEntity.IsRoomLinkedToRecRoomStudio"/>
/// is deliberately NOT flipped: it changes in-room MakerPen UI and the
/// client has no unlink call, so a push must not one-way-toggle it.</para>
///
/// <para><b>Not implemented:</b> live relay of the details to a second
/// logged-in session. The client's receive side
/// (<c>EHIOLHBGODG.MCPBJLHFOBC(CEELGOLBHIL)</c> → the
/// <c>Action&lt;CEELGOLBHIL&gt;</c> that MBOJJFBIAGE.txt:366/978 subscribes)
/// has no call site anywhere in the IsilDump, so the notification id that
/// carries it cannot be established from the binary — inventing one would
/// be a guess. The HTTP round-trip is complete and the push is durable
/// either way.</para></summary>
[HttpPost("remote-run/push-to-studio")]
[HttpPost("roomserver/remote-run/push-to-studio")]
[Authorize]
public async Task<ActionResult> PushToStudio(
[ModelBinder(typeof(Binding.FormOrJsonModelBinder))] PushToStudioRequest req)
{
var pid = CurrentPlayerId;
var sessionId = Clamp((req.SessionId ?? string.Empty).Trim(), 100);
if (sessionId.Length == 0)
return BadRequest(new { Error = "missing SessionId" });
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == req.RoomId);
if (room is null) return NotFound();
// Same gate the room-save upload uses (StorageController.UploadRoomSave):
// creator, admin, or an accepted CoOwner (Role 0).
var canPush = room.CreatorPlayerId == pid
|| await db.Players.AnyAsync(p => p.Id == pid && p.IsAdmin)
|| await db.RoomRoles.AnyAsync(r =>
r.RoomId == room.Id && r.PlayerId == pid && r.Accepted && r.Role == 0);
if (!canPush) return Forbid();
var subRoomId = req.SubRoomId;
var scene = await db.RoomScenes
.FirstOrDefaultAsync(s => s.RoomId == room.Id && s.OrderIndex == subRoomId);
var subRoomDataFilename = Clamp((req.SubRoomData?.Filename ?? string.Empty).Trim(), 128);
if (subRoomDataFilename.Length == 0)
return BadRequest(new { Error = "missing SubRoomData.Filename" });
// The push names blobs the client uploaded moments ago; if the row is
// absent the bytes are not on this server and Studio could never fetch
// them. Fail loudly rather than answering with a filename that 404s.
var subRoomBlob = await db.RoomDataBlobs
.FirstOrDefaultAsync(b => b.BlobName == subRoomDataFilename);
if (subRoomBlob is null)
return BadRequest(new { Error = "unknown SubRoomData.Filename" });
if (subRoomBlob.RoomId == 0) subRoomBlob.RoomId = room.Id;
subRoomBlob.SubRoomId ??= subRoomId;
var roomDataFilename = Clamp((req.RoomData?.Filename ?? string.Empty).Trim(), 128);
if (roomDataFilename.Length > 0)
{
// FileType=6 (RoomMetadata) uploads land with RoomId=0 — the push
// is the first point at which the owning room is known.
var roomBlob = await db.RoomDataBlobs
.FirstOrDefaultAsync(b => b.BlobName == roomDataFilename);
if (roomBlob is null)
return BadRequest(new { Error = "unknown RoomData.Filename" });
if (roomBlob.RoomId == 0) roomBlob.RoomId = room.Id;
}
var unityAssetId = Clamp((req.UnityAssetId ?? string.Empty).Trim(), 64);
if (unityAssetId.Length == 0)
{
unityAssetId = scene?.StudioUnityAssetId ?? string.Empty;
}
else if (scene is not null && scene.StudioUnityAssetId != unityAssetId)
{
scene.StudioUnityAssetId = unityAssetId;
scene.DataModifiedAt = DateTime.UtcNow;
}
var roomDataHash = Clamp((req.RoomData?.Hash ?? string.Empty).Trim(), 128);
var subRoomDataHash = Clamp((req.SubRoomData?.Hash ?? string.Empty).Trim(), 128);
room.StudioSessionId = sessionId;
var record = System.Text.Json.JsonSerializer.Serialize(new
{
RoomId = room.Id,
SubRoomId = subRoomId,
UnityAssetId = unityAssetId,
RoomDataFilename = roomDataFilename,
RoomDataHash = roomDataHash,
SubRoomDataFilename = subRoomDataFilename,
SubRoomDataHash = subRoomDataHash,
SavedByAccountId = req.SavedByAccountId ?? (int)pid,
PushedAt = DateTime.UtcNow,
});
if (record.Length > 1024)
{
// PlayerSettingEntity.Value is varchar(1024); truncating JSON would
// store an unparseable fragment, so drop the optional hashes first.
record = System.Text.Json.JsonSerializer.Serialize(new
{
RoomId = room.Id,
SubRoomId = subRoomId,
UnityAssetId = unityAssetId,
RoomDataFilename = roomDataFilename,
SubRoomDataFilename = subRoomDataFilename,
SavedByAccountId = req.SavedByAccountId ?? (int)pid,
PushedAt = DateTime.UtcNow,
});
}
var key = $"remoterun:{sessionId}";
var setting = await db.PlayerSettings
.FirstOrDefaultAsync(s => s.PlayerId == pid && s.Key == key);
if (setting is null)
db.PlayerSettings.Add(new PlayerSettingEntity
{
PlayerId = pid, Key = key, Value = record,
});
else
setting.Value = record;
await db.SaveChangesAsync();
return Ok(new
{
SessionId = sessionId,
RoomId = (long?)room.Id,
SubRoomId = (long?)subRoomId,
UnityAssetId = unityAssetId,
RoomDataFilename = roomDataFilename,
RoomDataHash = roomDataHash,
SubRoomDataFilename = subRoomDataFilename,
SubRoomDataHash = subRoomDataHash,
});
}
/// <summary>Trim a wire string to a column's length. Every string that
/// reaches a MaxLength column here comes straight off the request.</summary>
private static string Clamp(string value, int max)
=> value.Length <= max ? value : value[..max];
// ── Wire serializers ───────────────────────────────────────────────── // ── Wire serializers ─────────────────────────────────────────────────
private static int ClampInventionPermission(int v) => v switch private static int ClampInventionPermission(int v) => v switch
@@ -19,27 +19,75 @@ public class KeepsakesController(DorkNetDbContext db, DomainConfig domain) : Con
{ {
private long Me => this.RequireCurrentPlayerId(); private long Me => this.RequireCurrentPlayerId();
/// <summary>Marker category for collection rows; never surfaced as a keepsake instance.</summary>
private const string CollectionCategory = "collection";
/// <summary>
/// Mirrors <c>KeepsakeGlobalConfigDTO.SocialXpBoostEnabled</c> below. The collect handler has
/// to agree with what <c>globalconfig</c> advertises, so the flag lives in one place.
/// </summary>
private const bool SocialXpBoostEnabled = false;
private const int KeepsakeRoomLimit = 64;
[HttpGet] [HttpGet]
public async Task<IActionResult> Mine() public async Task<IActionResult> Mine()
{ {
await EnsureLoginKeepsakeAsync(Me); await EnsureLoginKeepsakeAsync(Me);
var rows = await db.Keepsakes var rows = await db.Keepsakes
.Where(k => k.PlayerId == Me) .Where(k => k.PlayerId == Me && k.Category != CollectionCategory)
.OrderByDescending(k => k.EarnedAt) .OrderByDescending(k => k.EarnedAt)
.ToListAsync(); .ToListAsync();
return Ok(rows.Select(ToWire)); 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<PIHCLHIKEPH, KeepsakeCategoryConfigDTO> (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"),
];
/// <summary>
/// XP a single keepsake collection is worth. This is the one number both
/// <c>api/keepsakes/categories</c> (as <c>XpValue</c>) and
/// <c>api/keepsakes/{id}/collect</c> (as <c>TotalXp</c>) are derived from, so the toast the
/// client renders after a collect matches the config it already downloaded.
/// </summary>
private const int KeepsakeXpValue = 25;
[HttpGet("categories")] [HttpGet("categories")]
[AllowAnonymous] [AllowAnonymous]
public IActionResult Categories() public IActionResult Categories()
{ {
var results = new[] var results = CategoryConfigs
{ .Select(c => new
new { KeepsakeCategoryId = 0, VisualId = "account", LimitPerRoom = 0, XpValue = 0, IconOutlineImageName = string.Empty, IconFilledImageName = string.Empty }, {
new { KeepsakeCategoryId = 1, VisualId = "event", LimitPerRoom = 0, XpValue = 0, IconOutlineImageName = string.Empty, IconFilledImageName = string.Empty }, KeepsakeCategoryId = c.Id,
new { KeepsakeCategoryId = 2, VisualId = "room", LimitPerRoom = 64, XpValue = 0, IconOutlineImageName = string.Empty, IconFilledImageName = string.Empty }, c.VisualId,
}; LimitPerRoom = KeepsakeRoomLimit,
XpValue = KeepsakeXpValue,
IconOutlineImageName = string.Empty,
IconFilledImageName = string.Empty,
})
.ToArray();
return Ok(new return Ok(new
{ {
Results = results, Results = results,
@@ -81,10 +129,26 @@ public class KeepsakesController(DorkNetDbContext db, DomainConfig domain) : Con
// Key names follow the same RecNet naming the room endpoint below already uses; the literal // 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 // 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. // 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 return Ok(new
{ {
Instances = GroupInstanceIdsByRoom(rows), Instances = GroupInstanceIdsByRoom(rows),
CollectionRecords = GroupInstanceIdsByRoom(rows.Where(r => r.PlayerId == Me)), CollectionRecords = collected,
}); });
} }
@@ -105,21 +169,136 @@ public class KeepsakesController(DorkNetDbContext db, DomainConfig domain) : Con
.OrderByDescending(k => k.EarnedAt) .OrderByDescending(k => k.EarnedAt)
.ToListAsync(); .ToListAsync();
var instances = rows.Select(row => ToRoomInstanceWire(row, roomId)).ToList(); 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 return Ok(new
{ {
Instances = instances, Instances = instances,
CollectionRecords = Array.Empty<object>(), 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<long>(), KeepsakeProgressionEventIds = Array.Empty<long>(),
}); });
} }
/// <summary>
/// Removes a placed keepsake instance. The 2023 client issues
/// <c>DELETE api/keepsakes/{guid}</c> - <c>NCCLEJPIABA.JBKENCNIEPA(System.Guid)</c> builds
/// <c>String.Format("{0}/{1}", "api/keepsakes", guid)</c> and passes verb 4 (DELETE) to the
/// BNDIAONDFFF ctor (NCCLEJPIABA.txt:1544, 1687-1698 - <c>Move rdx, 4</c>). Its return type is
/// the non-generic <c>LDGADANDBIO</c> promise, so no response body is deserialised; a bare 200
/// is enough. 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.
/// </summary>
[HttpDelete("{keepsakeInstanceId:guid}")]
public async Task<IActionResult> 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 Ok();
}
/// <summary>
/// Records that the caller picked up a placed keepsake and reports the XP it was worth. The
/// 2023 client issues <c>POST api/keepsakes/{guid}/collect</c> -
/// <c>NCCLEJPIABA.HFGKFAHFELM(System.Guid)</c> formats <c>"{0}/{1}/collect"</c> over
/// "api/keepsakes" and passes verb 2 (POST) (NCCLEJPIABA.txt:1738, 1963-1974 -
/// <c>Move rdx, 2</c>), with no request body. The response type is
/// <c>FGLDKEJLAKB&lt;DHNBKMHDANK&gt;</c>; 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
/// <c>TotalXp - SocialBoostXp</c> 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.
/// </summary>
[HttpPost("{keepsakeInstanceId:guid}/collect")]
public async Task<IActionResult> 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")] [HttpGet("globalconfig")]
[AllowAnonymous] [AllowAnonymous]
public IActionResult GlobalConfig() => Ok(new public IActionResult GlobalConfig() => Ok(new
{ {
KeepsakeFeatureEnabled = true, KeepsakeFeatureEnabled = true,
KeepsakeRoomLimit = 64, KeepsakeRoomLimit,
SocialXpBoostEnabled = false, SocialXpBoostEnabled,
}); });
[HttpPost] [HttpPost]
@@ -353,6 +532,100 @@ public class KeepsakesController(DorkNetDbContext db, DomainConfig domain) : Con
return true; return true;
} }
// A collection record is stored as its own keepsake row so no schema change is needed:
// Category = "collection", EventKey = "collect:{roomId}:{instanceGuid:N}"
// The room id is baked in so the room endpoint can filter by prefix and the event endpoint can
// group by room without re-reading the instance rows. "collect:" shares no prefix with the
// "room:"/"event:" instance keys, so collection rows never leak into an instance query, and
// Mine() filters the category out of the legacy keepsake list.
private static string BuildCollectionKey(long roomId, Guid instanceId)
=> $"collect:{roomId.ToString(CultureInfo.InvariantCulture)}:{instanceId:N}";
private static bool TryParseCollectionKey(string? eventKey, out long roomId, out Guid instanceId)
{
roomId = 0;
instanceId = Guid.Empty;
var parts = (eventKey ?? string.Empty).Split(':');
if (parts.Length != 3 || !string.Equals(parts[0], "collect", StringComparison.OrdinalIgnoreCase)) return false;
if (!long.TryParse(parts[1], NumberStyles.Integer, CultureInfo.InvariantCulture, out roomId)) return false;
return Guid.TryParseExact(parts[2], "N", out instanceId);
}
private async Task<List<(long RoomId, Guid InstanceId)>> LoadMyCollectionsAsync()
{
var me = Me;
var rows = await db.Keepsakes
.Where(k => k.PlayerId == me && k.Category == CollectionCategory)
.Select(k => k.EventKey)
.ToListAsync();
var results = new List<(long RoomId, Guid InstanceId)>(rows.Count);
foreach (var key in rows)
{
if (TryParseCollectionKey(key, out var roomId, out var instanceId))
results.Add((roomId, instanceId));
}
return results;
}
/// <summary>
/// Resolves the guid the client holds (from <c>api/keepsakes/rooms/{roomId}</c>, or the guid
/// this server handed back from POST <c>api/keepsakes</c>) to the row that encodes it.
/// </summary>
private async Task<KeepsakeEntity?> FindInstanceAsync(Guid keepsakeInstanceId)
{
var suffix = keepsakeInstanceId.ToString("N");
var candidates = await db.Keepsakes
.Where(k => (k.Category == "room" || k.Category == "event")
&& EF.Functions.Like(k.EventKey, $"%:{suffix}"))
.ToListAsync();
var match = candidates.FirstOrDefault(k =>
TryParseInstanceKey(k.EventKey, out _, out _, out _, out var id) && id == keepsakeInstanceId);
if (match is not null) return match;
// Rows written before the instance id was encoded into EventKey are reported to the client
// with the MD5-derived StableKeepsakeInstanceId, which no LIKE can match. Resolve those by
// id only - the projection keeps it to one column over the short-key rows.
var legacyIds = await db.Keepsakes
.Where(k => (k.Category == "room" || k.Category == "event")
&& !EF.Functions.Like(k.EventKey, "%:%:%:%"))
.Select(k => k.Id)
.ToListAsync();
var legacyId = legacyIds.FirstOrDefault(id => StableKeepsakeInstanceId(id) == keepsakeInstanceId);
return legacyId == 0 ? null : await db.Keepsakes.FirstOrDefaultAsync(k => k.Id == legacyId);
}
/// <summary>
/// Placement and removal are gated client-side on room co-ownership
/// ("Must be at least a room co-owner to modify keepsakes.", PDFJLLECNBE.txt:3363); the placer
/// and server admins are allowed through as well.
/// </summary>
private async Task<bool> CanModifyInstanceAsync(KeepsakeEntity instance, long roomId, long playerId)
{
if (instance.PlayerId == playerId) return true;
if (roomId > 0)
{
var creatorId = await db.Rooms.AsNoTracking()
.Where(r => r.Id == roomId)
.Select(r => (long?)r.CreatorPlayerId)
.FirstOrDefaultAsync();
if (creatorId == playerId) return true;
var isCoOwner = await db.RoomRoles.AnyAsync(r =>
r.RoomId == roomId && r.PlayerId == playerId && r.Role == 0 && r.Accepted);
if (isCoOwner) return true;
}
return await db.Players
.Where(p => p.Id == playerId)
.Select(p => p.IsAdmin)
.FirstOrDefaultAsync();
}
private static int XpValueFor(int keepsakeCategoryConfigId)
=> CategoryConfigs.Any(c => c.Id == keepsakeCategoryConfigId) ? KeepsakeXpValue : 0;
private static int CategoryId(string? category) => private static int CategoryId(string? category) =>
string.Equals(category, "event", StringComparison.OrdinalIgnoreCase) ? 1 : string.Equals(category, "event", StringComparison.OrdinalIgnoreCase) ? 1 :
string.Equals(category, "room", StringComparison.OrdinalIgnoreCase) ? 2 : string.Equals(category, "room", StringComparison.OrdinalIgnoreCase) ? 2 :
@@ -1,3 +1,5 @@
using System.Text;
using System.Text.Json;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@@ -240,6 +242,318 @@ public class PhotosController(
return Ok(new { deleted = id }); return Ok(new { deleted = id });
} }
// ── 2023 home feed (Discovery host) ───────────────────────────────
/// <summary>The only <c>FeedItemType</c> (client enum
/// <c>DAAEHNDDCCH</c>) the 2023 feed list can render: an image.
/// Two independent confirmations in the binary — the list spawner
/// builds one <c>RRUI.Data.ImageFeedItemModel</c> per returned item
/// (<c>Assembly-CSharp/RRUI/Data/FeedListModelController_NestedType_FeedListSpawnerImpl.txt:294</c>
/// "044 Call ImageFeedItemModel.Set"), and the share deep-link
/// handler maps the path segment "image" + an Int64 id onto an
/// injected feed item constructed with the enum literal 0
/// (<c>Assembly-CSharp/RecRoom/Sharing/LinkManager.txt:5646</c>
/// Move rdx,"image" … :5665 Move rdx,0 … :5668 Call
/// EKLOCIPFHII..ctor). FeedItemId is therefore an image id, which on
/// DorkNet is <see cref="PhotoEntity.Id"/> — the same id space
/// <c>api/images/v5/bulk</c> resolves.</summary>
private const int FeedItemTypeImage = 0;
/// <summary>Ranking revision stamped on the envelope and on every
/// item. The client never interprets it: it copies the Int16 off the
/// response into the list model and replays it in the impression
/// telemetry it later posts back (<c>FeedListModel_NestedType_OLMJPGEMAKM.txt</c>
/// "031 Move rax,[rdi+40] / 032 Move [rcx+280],rax" then
/// "040 Call FeedListModel.LMDNOKJCBGE"). Bump it when the ordering
/// below changes so the telemetry can tell the cohorts apart.</summary>
private const short FeedAlgoVersion = 1;
/// <summary>POST /feed/query — the 2023 client's personalised home
/// feed. Host is Discovery (host enum 19) and the verb is POST:
/// <c>RecNet.Runtime/KGLCPCEGCFF.txt:166-178</c> loads
/// r9 = "feed/query", r8 = 19, rdx = 2 (BestHTTP POST) into the
/// shared BNDIAONDFFF request ctor. The body is a RAW JSON document
/// (the issuing method takes an already-serialised
/// <c>System.String</c> and hands it to BNDIAONDFFF.FJLLPHFOOJJ),
/// NOT form fields — so it is read straight off Request.Body.
///
/// Request keys (reader BAEBDMPPKKE.txt:331-414, each registered in
/// PascalCase/camelCase/lowercase): Take:Int32,
/// ContinuationToken:String?, InjectedFeedItems:[{FeedItemType,
/// FeedItemId}], FeedInstanceId:String?.
///
/// Response is the generic result envelope AECMPGPHAII&lt;T&gt;
/// {Success, Error, Value} (keys at AEGADMDIKNE.txt:243-286) wrapping
/// BDIFPHKLFKD {Items, FeedInstanceId, FeedContext,
/// FeedAlgorithmVersion} (keys at EHKEOCKBCDH.txt:331-414). Anything
/// else — including a 404 — rejects the promise and the home tab
/// renders its failure state.
///
/// FeedContext is the continuation cursor, not a display string: the
/// RefreshPage continuation reads field +32 (FeedContext) as the
/// third element of its (count, items, nextToken) tuple and feeds it
/// back as the next request's ContinuationToken
/// (<c>FeedListModel_NestedType_OLMJPGEMAKM.txt</c> "062 Move r9,
/// [rdi+32]"). An empty FeedContext therefore ends pagination.
///
/// Anonymous-safe like the other feed reads; when the caller is
/// authenticated the ordering is personalised (friends/subscriptions
/// first) and blocked/ignored uploaders are filtered out.</summary>
[HttpPost("/feed/query")]
[AllowAnonymous]
public async Task<ActionResult> FeedQuery()
{
var take = 20;
string? continuationToken = null;
string? requestedInstanceId = null;
var requestedInjected = new List<long>();
try
{
using var doc = await JsonDocument.ParseAsync(Request.Body);
if (doc.RootElement.ValueKind == JsonValueKind.Object)
{
var root = doc.RootElement;
if (TryFeedProp(root, "Take", out var takeEl) &&
takeEl.ValueKind == JsonValueKind.Number && takeEl.TryGetInt32(out var t))
take = t;
if (TryFeedProp(root, "ContinuationToken", out var ctEl) &&
ctEl.ValueKind == JsonValueKind.String)
continuationToken = ctEl.GetString();
if (TryFeedProp(root, "FeedInstanceId", out var fiEl) &&
fiEl.ValueKind == JsonValueKind.String)
requestedInstanceId = fiEl.GetString();
if (TryFeedProp(root, "InjectedFeedItems", out var injEl) &&
injEl.ValueKind == JsonValueKind.Array)
{
foreach (var entry in injEl.EnumerateArray())
{
if (entry.ValueKind != JsonValueKind.Object) continue;
// Non-image injected items can't be rendered by the
// 2023 list, so echoing them back would produce a
// blank card. Drop them.
var type = TryFeedProp(entry, "FeedItemType", out var tyEl) &&
tyEl.ValueKind == JsonValueKind.Number &&
tyEl.TryGetInt32(out var ty)
? ty : FeedItemTypeImage;
if (type != FeedItemTypeImage) continue;
if (TryFeedProp(entry, "FeedItemId", out var idEl) &&
idEl.ValueKind == JsonValueKind.Number &&
idEl.TryGetInt64(out var id) && id > 0)
requestedInjected.Add(id);
}
}
}
}
catch (JsonException)
{
// Unparseable/empty body — treat as a default first-page pull
// rather than 400, so a malformed retry still shows content.
}
take = Math.Clamp(take, 1, 50);
var me = this.CurrentPlayerId();
// Uploaders the caller can't see: anyone they blocked or ignored,
// plus anyone who blocked them.
var hidden = new List<long>();
var affinity = new List<long>();
if (me is long meId)
{
hidden = await db.Relationships
.Where(r => (r.RequesterId == meId &&
(r.Status == RelationshipStatus.Blocked || r.Ignored)) ||
(r.TargetId == meId && r.Status == RelationshipStatus.Blocked))
.Select(r => r.RequesterId == meId ? r.TargetId : r.RequesterId)
.Distinct()
.ToListAsync();
var friends = await db.Relationships
.Where(r => r.Status == RelationshipStatus.Friend &&
(r.RequesterId == meId || r.TargetId == meId))
.Select(r => r.RequesterId == meId ? r.TargetId : r.RequesterId)
.ToListAsync();
var follows = await db.Subscriptions
.Where(s => s.SubscriberPlayerId == meId)
.Select(s => s.TargetPlayerId)
.ToListAsync();
affinity = friends.Concat(follows)
.Where(id => id > 0 && !hidden.Contains(id))
.Distinct()
.ToList();
}
var resumed = TryDecodeFeedCursor(continuationToken,
out var phase, out var cursorTicks, out var cursorId, out var carriedInjected);
// Injected items are pinned to the head of the FIRST page only, in
// the order the client asked for them (they come from a share
// deep-link the player just followed). Their ids ride along in the
// cursor so later pages don't repeat them organically.
var injectedIds = resumed ? carriedInjected : requestedInjected.Distinct().ToArray();
var itemIds = new List<long>();
if (!resumed && injectedIds.Length > 0)
{
var pinned = await VisibleFeedPhotos(hidden)
.Where(p => injectedIds.Contains(p.Id))
.Select(p => p.Id)
.ToListAsync();
itemIds.AddRange(injectedIds.Where(pinned.Contains));
}
DateTime? cutoffAt = resumed && cursorTicks > 0 && cursorTicks <= DateTime.MaxValue.Ticks
? new DateTime(cursorTicks)
: (DateTime?)null;
var cutoffId = resumed ? cursorId : 0L;
if (phase < 0) phase = 0;
// Phase 0 = photos from people the caller follows or is friends
// with; phase 1 = everyone else. Both are newest-first, and the
// cursor is (CreatedAt, Id) so a photo posted mid-scroll can never
// shift a row onto a page the client already consumed.
while (itemIds.Count < take && phase <= 1)
{
if (phase == 0 && affinity.Count == 0)
{
phase = 1;
cutoffAt = null;
cutoffId = 0;
continue;
}
var need = take - itemIds.Count;
var q = VisibleFeedPhotos(hidden);
if (injectedIds.Length > 0)
q = q.Where(p => !injectedIds.Contains(p.Id));
q = phase == 0
? q.Where(p => affinity.Contains(p.UploaderPlayerId))
: q.Where(p => !affinity.Contains(p.UploaderPlayerId));
if (cutoffAt is DateTime at)
{
var atId = cutoffId;
q = q.Where(p => p.CreatedAt < at || (p.CreatedAt == at && p.Id < atId));
}
var batch = await q
.OrderByDescending(p => p.CreatedAt)
.ThenByDescending(p => p.Id)
.Take(need)
.Select(p => new { p.Id, p.CreatedAt })
.ToListAsync();
itemIds.AddRange(batch.Select(b => b.Id));
if (batch.Count < need)
{
phase++;
cutoffAt = null;
cutoffId = 0;
}
else
{
cutoffAt = batch[^1].CreatedAt;
cutoffId = batch[^1].Id;
}
}
// Empty FeedContext = end of the feed; the client stops paging.
var nextContext = phase > 1
? string.Empty
: EncodeFeedCursor(phase, cutoffAt?.Ticks ?? 0, cutoffId, injectedIds);
return Ok(new
{
Success = true,
Error = (string?)null,
Value = new
{
Items = itemIds.Select(id => new
{
FeedItemType = FeedItemTypeImage,
FeedItemId = id,
FeedAlgorithmVersion = FeedAlgoVersion,
}).ToList(),
// The client sends the instance id it generated for this
// scroll session and stores whatever comes back; echoing it
// keeps its impression telemetry stitched to one session.
FeedInstanceId = string.IsNullOrWhiteSpace(requestedInstanceId)
? Guid.NewGuid().ToString("N")
: requestedInstanceId,
FeedContext = nextContext,
FeedAlgorithmVersion = FeedAlgoVersion,
},
});
}
/// <summary>Photos eligible for the public home feed: live, public,
/// and not from an uploader the caller blocked/ignored.</summary>
private IQueryable<PhotoEntity> VisibleFeedPhotos(List<long> hidden)
{
var q = db.Photos.Where(p => p.IsPublic && p.DeletedAt == null);
if (hidden.Count > 0)
q = q.Where(p => !hidden.Contains(p.UploaderPlayerId));
return q;
}
/// <summary>Case-insensitive property lookup — the 2023 generated
/// writers emit PascalCase but their readers register all three
/// casings, so tolerate whatever the client sends.</summary>
private static bool TryFeedProp(JsonElement obj, string name, out JsonElement value)
{
foreach (var prop in obj.EnumerateObject())
{
if (string.Equals(prop.Name, name, StringComparison.OrdinalIgnoreCase))
{
value = prop.Value;
return true;
}
}
value = default;
return false;
}
/// <summary>Opaque base64url cursor handed to the client as
/// FeedContext. Carries the phase, the (CreatedAt, Id) keyset
/// position within that phase, and the pinned injected ids so they
/// stay suppressed on later pages.</summary>
private static string EncodeFeedCursor(int phase, long ticks, long lastId, IReadOnlyList<long> injected)
{
var raw = $"1|{phase}|{ticks}|{lastId}|{string.Join(',', injected)}";
return Convert.ToBase64String(Encoding.UTF8.GetBytes(raw))
.TrimEnd('=').Replace('+', '-').Replace('/', '_');
}
private static bool TryDecodeFeedCursor(string? token, out int phase,
out long ticks, out long lastId, out long[] injected)
{
phase = 0;
ticks = 0;
lastId = 0;
injected = Array.Empty<long>();
if (string.IsNullOrWhiteSpace(token)) return false;
try
{
var b64 = token.Replace('-', '+').Replace('_', '/');
b64 = b64.PadRight(b64.Length + ((4 - (b64.Length % 4)) % 4), '=');
var parts = Encoding.UTF8.GetString(Convert.FromBase64String(b64)).Split('|');
if (parts.Length != 5 || parts[0] != "1") return false;
if (!int.TryParse(parts[1], out phase)) return false;
if (!long.TryParse(parts[2], out ticks)) return false;
if (!long.TryParse(parts[3], out lastId)) return false;
injected = parts[4]
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(s => long.TryParse(s, out var v) ? v : 0L)
.Where(v => v > 0)
.ToArray();
return true;
}
catch (FormatException)
{
// Garbage/stale cursor — restart the feed from the top instead
// of failing the whole pull.
return false;
}
}
// ── Helpers ────────────────────────────────────────────────────── // ── Helpers ──────────────────────────────────────────────────────
/// <summary>Trim/dedupe tagged ids and drop the uploader's own id. /// <summary>Trim/dedupe tagged ids and drop the uploader's own id.
@@ -33,7 +33,8 @@ namespace DorkNet.Server.Controllers.API.PlayerEvents;
[Authorize] [Authorize]
public class PlayerEventsController( public class PlayerEventsController(
DorkNetDbContext db, DorkNetDbContext db,
NotificationService notifications) : ControllerBase NotificationService notifications,
PlayerPresenceService presence) : ControllerBase
{ {
private long Me => this.RequireCurrentPlayerId(); private long Me => this.RequireCurrentPlayerId();
@@ -351,6 +352,101 @@ public class PlayerEventsController(
return Ok(rows.Select(ResponseWire).ToList()); return Ok(rows.Select(ResponseWire).ToList());
} }
// ── Event instance browser ───────────────────────────────────────────
/// <summary>GET <c>event/{eventId}/instances</c> — the "which instance of
/// this event are my friends in" browser behind the event card's join
/// button. Served from the MATCHMAKING-HOST root (no <c>api/</c> prefix),
/// which is why it lives on an absolute route here even though the feature
/// is a player-event one.
///
/// Binary evidence: <c>Matchmaking+&lt;GetEventInstanceBrowser&gt;b__0</c>
/// (<c>Matchmaking_NestedType_CBMHJMNIHNN.txt:14</c>) formats the literal
/// <c>"event/{0}/instances"</c> at <c>:296</c> with <c>[rdi+16]</c> — the
/// PlayerEventId off the HPIOAGDJHDH the lambda closes over — then news up
/// BNDIAONDFFF with <c>rdx = 0</c> at <c>:124</c>, i.e.
/// <c>BestHTTP.HTTPMethods.Get</c>. The declared return type is
/// <c>FGLDKEJLAKB&lt;List&lt;PNDCMIMEJLD&gt;&gt;</c>, so the body is a BARE
/// JSON ARRAY. PNDCMIMEJLD's getters (<c>PNDCMIMEJLD.txt:3-197</c>) are, in
/// order, Int64/Int64/Int64/Boolean/DateTime/List&lt;Int32&gt;/String/Int32/
/// Boolean/String = RoomInstanceId, RoomId, SubRoomId, IsFull, CreatedAt,
/// PlayerIds, SubroomName, PlayerCount, HasModPresent, HashedInstanceId —
/// byte-for-byte the SimpleRoomInstance the <c>room/{id}/instances</c>
/// browser already returns (<c>Matchmaking.txt:14237</c> issues the room
/// variant through the identical BNDIAONDFFF/verb-0 sequence). The last
/// four are filled in locally on the watch and are not read back off the
/// wire, so we emit the same six camelCase keys
/// (<c>Controllers/Match/MatchController.cs:545-553</c>); the 2023 Utf8Json
/// formatters carry a camelCase variant per key, so both client
/// generations parse it.
///
/// The event itself only supplies the room to browse: an event is pinned to
/// one RoomId, plus the SubRoomId the host picked in the v2 create/edit body
/// (persisted in <see cref="EventExtras"/>). Sub-room hops allocate their
/// own instance ids and photon rooms, so when the event names a sub-room we
/// only surface instances sitting in it.</summary>
[AllowAnonymous]
[HttpGet("/event/{eventId:long}/instances")]
public async Task<IActionResult> EventInstances(long eventId)
{
var evt = await db.PlayerEvents.FirstOrDefaultAsync(e => e.Id == eventId);
if (evt is null) return NotFound();
var extras = (await LoadExtrasAsync(new[] { evt }))[evt.Id];
var subRoomId = extras.SubRoomId is long sub && sub > 0 ? sub : (long?)null;
// Same sourcing as the room browser: presence rows are the live truth
// (one instance per distinct PhotonRoomId), and the caller's OWN private
// instances are merged in so the host can still see the instance they
// created before anyone has joined it. Another player's private
// PhotonRoomId is never listed.
var pid = this.CurrentPlayerId() ?? 0;
var privates = pid == 0
? new List<PrivateInstanceEntity>()
: await db.PrivateInstances
.Where(p => p.RoomId == evt.RoomId && p.OwnerPlayerId == pid)
.ToListAsync();
var byKey = new Dictionary<string, object>(StringComparer.Ordinal);
foreach (var (room, playerIds) in presence.EnumerateActiveInstances(evt.RoomId))
{
if (string.IsNullOrEmpty(room.PhotonRoomId)) continue;
if (subRoomId is long want && room.SubRoomId != want) continue;
byKey[room.PhotonRoomId] = new
{
roomInstanceId = room.RoomInstanceId,
roomId = room.RoomId,
subRoomId = room.SubRoomId,
// MaxCapacity is the per-instance cap the matchmaker handed
// out, so this is a real answer rather than a constant false.
isFull = room.MaxCapacity > 0 && playerIds.Count >= room.MaxCapacity,
// Presence rows are a TTL cache with no creation timestamp —
// there is no stored "instance opened at" for a live instance,
// so report the observation time. Private-instance rows below
// do carry a real CreatedAt and use it.
createdAt = DateTime.UtcNow,
playerIds = playerIds.Select(p => (int)p).ToArray(),
};
}
foreach (var p in privates)
{
if (string.IsNullOrEmpty(p.PhotonRoomId) || byKey.ContainsKey(p.PhotonRoomId)) continue;
if (subRoomId is long want && p.SubRoomId != want) continue;
byKey[p.PhotonRoomId] = new
{
roomInstanceId = p.Id,
roomId = p.RoomId,
subRoomId = p.SubRoomId,
isFull = false,
createdAt = DateTime.SpecifyKind(p.CreatedAt, DateTimeKind.Utc),
playerIds = Array.Empty<int>(),
};
}
return Ok(byKey.Values.ToList());
}
// ── Wire shape ─────────────────────────────────────────────────────── // ── Wire shape ───────────────────────────────────────────────────────
/// <summary>The PlayerEvent fields the 2023 client carries that /// <summary>The PlayerEvent fields the 2023 client carries that
@@ -1,15 +1,24 @@
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using DorkNet.Server.Auth; using DorkNet.Server.Auth;
using DorkNet.Server.Data; using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
using DorkNet.Server.Services; using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.API.ProgressionEvents; namespace DorkNet.Server.Controllers.API.ProgressionEvents;
[ApiController] [ApiController]
public class ProgressionEventsController(DorkNetDbContext db, ServerSettingsService serverSettings) : ControllerBase public class ProgressionEventsController(
DorkNetDbContext db,
ServerSettingsService serverSettings,
IConfiguration config) : ControllerBase
{ {
/// <summary>CurrencyType enum value the rest of the server uses for
/// RecCenter tokens (mirrors <c>ProgressionController.RecCenterTokens</c>).</summary>
private const int RecCenterTokens = 2;
[HttpGet("api/progressionEvents")] [HttpGet("api/progressionEvents")]
[Authorize] [Authorize]
public async Task<IActionResult> Progress() public async Task<IActionResult> Progress()
@@ -39,30 +48,29 @@ public class ProgressionEventsController(DorkNetDbContext db, ServerSettingsServ
var weekly = await serverSettings.GetWeeklyChallengesAsync(); var weekly = await serverSettings.GetWeeklyChallengesAsync();
var (start, end) = CurrentWeekWindow(); var (start, end) = CurrentWeekWindow();
// The boost fields must agree with api/progressionEvents/{id}/xpboosts:
// ProgressionEventsPurchasableXpBoostModel.Set() takes the guid the
// event advertises here and looks it up in the xpboosts list, and
// ProgressionEventsModel.GetPurchasableXpBoostId() gates the whole
// boost sheet on PurchasableXpBoostId being non-null. Deriving both
// from one catalogue keeps them from drifting.
var boosts = BoostCatalogue(activeId);
var headline = boosts.Count > 0 ? boosts[0] : null;
return Ok(new return Ok(new
{ {
ProgressionEventId = activeId, ProgressionEventId = activeId,
Name = "Weekly Progression", Name = "Weekly Progression",
Rewards = new[] Rewards = BuildRewards(weekly.Reward, activeId),
{
new
{
ProgressionEventRewardId = activeId * 1000L,
GiftDropId = weekly.Reward.GiftDropId != 0 ? weekly.Reward.GiftDropId : activeId,
ImageName = string.Empty,
Xp = weekly.Reward.Xp,
RewardIndex = 0,
IsBonus = false,
},
},
KeepsakeRoomLists = Array.Empty<object>(), KeepsakeRoomLists = Array.Empty<object>(),
StartTime = start, StartTime = start,
EndTime = end, EndTime = end,
CollectionEndTime = end, CollectionEndTime = end,
UsesBoost = false, UsesBoost = headline is not null,
BoostDailyGameplayMinutesLimit = 0, BoostDailyGameplayMinutesLimit = 0,
BoostXpMultiplier = 1.0, // ProgressionEventDTO.BoostXpMultiplier is a float at +0x50
PurchasableXpBoostId = (Guid?)null, // (dump.cs:1198957) — emit a real number, not an integer token.
BoostXpMultiplier = (double)(headline?.XpMultiplier ?? 1),
PurchasableXpBoostId = headline?.Id,
ActiveExperiment = string.Empty, ActiveExperiment = string.Empty,
ChallengesIconImageName = string.Empty, ChallengesIconImageName = string.Empty,
RewardsPipImageName = string.Empty, RewardsPipImageName = string.Empty,
@@ -88,24 +96,349 @@ public class ProgressionEventsController(DorkNetDbContext db, ServerSettingsServ
{ {
var me = this.RequireCurrentPlayerId(); var me = this.RequireCurrentPlayerId();
// Count this player's completed progression-event objectives so the // Count the reward-claim sentinels this player owns FOR THIS EVENT
// record reflects real progress rather than a flat zero. // (written by Collect below). The previous query counted every
var completed = await db.ObjectiveProgress // `progressionEvent:` row, which also matches the xpBoost purchase
// sentinels StorefrontsBuyController writes — a boost purchase
// silently advanced the claimed-reward pip.
var claimPrefix = RewardClaimPrefix(progressionEventId);
var collected = await db.ObjectiveProgress
.CountAsync(o => o.PlayerId == me .CountAsync(o => o.PlayerId == me
&& o.IsCompleted && o.IsCompleted
&& o.Key.StartsWith("progressionEvent:")); && o.Key.StartsWith(claimPrefix));
return Ok(new return Ok(new
{ {
ProgressionEventId = progressionEventId, ProgressionEventId = progressionEventId,
Xp = 0, Xp = 0,
ClaimedRewardIndex = completed > 0 ? completed - 1 : -1, ClaimedRewardIndex = collected > 0 ? collected - 1 : -1,
PurchasedXpBoostCount = 0, PurchasedXpBoostCount = 0,
DailyBoostGameplayMinutes = 0, DailyBoostGameplayMinutes = 0,
XpBoostExpiresAt = (DateTime?)null, XpBoostExpiresAt = (DateTime?)null,
}); });
} }
// ── Reward collection ────────────────────────────────────────────────
/// <summary>
/// POST <c>api/progressionEvents/collect/{eventId}/{rewardIndex}</c> —
/// claim one reward chest off the progression track.
///
/// Binary evidence: <c>RecNet.Runtime/CDBFONFHJDO.txt:1208</c>
/// (<c>GKANPKEGBGE(Int64 eventId, Int32 rewardIndex)</c>) formats
/// <c>"{0}/collect/{1}/{2}"</c> against <c>"api/progressionEvents"</c>
/// (ISIL 041/043) and moves <c>2</c> = <c>HTTPMethods.Post</c> into the
/// verb argument at ISIL 055. No request body is passed — the two route
/// segments are the whole request.
///
/// The return type is <c>FGLDKEJLAKB&lt;FFFIMAGLKEG/FHMABOHAEED&gt;</c>,
/// and <c>FHMABOHAEED</c> is the client's GiftPackage DTO — the very
/// same object <c>GiftManager.RunConsumeGift</c> /
/// <c>GiftManager.DequeueGift</c> take
/// (<c>Assembly-CSharp/GiftManager.txt:3226,3395</c>), whose wire keys are
/// Id / FromPlayerId / ConsumableItemDesc / AvatarItemType /
/// AvatarItemDesc / EquipmentPrefabName / EquipmentModificationGuid /
/// CurrencyType / Currency / Xp / GiftContext / GiftRarity / Message /
/// Platform / PlatformsToSpawnOn / BalanceType
/// (<c>RecNet.Runtime/FKANCKLCCDI.txt:1051-1390</c>).
///
/// That means the client will POST the returned <c>Id</c> back to
/// <c>api/avatar/v2/gifts/consume</c>, so this MUST persist a real
/// <see cref="GiftPackageEntity"/> rather than synthesise a wire blob —
/// an unpersisted Id 404s on consume and the player loses the reward.
/// The payload itself comes from the admin-configured weekly reward
/// (<see cref="WeeklyChallengeReward"/>), i.e. exactly what
/// <see cref="Event"/> advertises on the track, so the chest hands over
/// what the UI promised. XP / tokens / level ride on the package and are
/// granted by the existing consume path, not here — granting in both
/// places would double-pay.
///
/// Idempotent: the claim sentinel is an <see cref="ObjectiveProgressEntity"/>
/// whose Key embeds the created gift id, so a retried or duplicated POST
/// returns the original package instead of minting a second one.
/// No <c>GiftPackageReceived</c> push is sent (unlike
/// <c>avatar/v2/gifts/generate</c>): the RRUI reward flow already spawns
/// the box from this response, and a push would spawn a second one.
/// </summary>
[HttpPost("api/progressionEvents/collect/{eventId:long}/{rewardIndex:int}")]
[Authorize]
public async Task<IActionResult> Collect(long eventId, int rewardIndex)
{
var me = this.RequireCurrentPlayerId();
// Only the live event's track is claimable — the client only ever
// renders the active event, and letting an arbitrary id through
// would let a replayed request re-roll last week's chest.
var activeId = CurrentProgressionEventId();
if (eventId != activeId) return NotFound();
var weekly = await serverSettings.GetWeeklyChallengesAsync();
var rewards = BuildRewards(weekly.Reward, activeId);
if (rewardIndex < 0 || rewardIndex >= rewards.Count) return NotFound();
// Already claimed? The sentinel key carries the gift id so we can
// hand back the same package (the client may retry after a dropped
// response, and it needs a consumable Id either way).
var sentinelPrefix = $"{RewardClaimPrefix(eventId)}{rewardIndex}:gift:";
var sentinel = await db.ObjectiveProgress
.FirstOrDefaultAsync(o => o.PlayerId == me && o.Key.StartsWith(sentinelPrefix));
if (sentinel is not null)
{
if (long.TryParse(sentinel.Key[sentinelPrefix.Length..], out var priorId))
{
var prior = await db.GiftPackages
.FirstOrDefaultAsync(g => g.Id == priorId && g.RecipientPlayerId == me);
if (prior is not null)
return Ok(global::DorkNet.Server.Controllers.API.Avatar.V2.AvatarGiftsController.ToWire(prior));
}
// Sentinel exists but its package was purged — drop the stale
// marker and fall through to re-issue, otherwise the player is
// permanently locked out of a reward they never received.
db.ObjectiveProgress.Remove(sentinel);
await db.SaveChangesAsync();
}
var reward = weekly.Reward;
var gift = new GiftPackageEntity
{
RecipientPlayerId = me,
FromPlayerId = null,
// The weekly reward config carries a raw avatar desc, which is
// always an outfit item (AvatarItemType 0); hair dyes are
// configured through ConsumableItemDesc instead. Leave the type
// null when there is no avatar payload so the client doesn't
// render an empty outfit tile.
AvatarItemType = string.IsNullOrWhiteSpace(reward.AvatarItemDesc) ? (int?)null : 0,
AvatarItemDescOrHairDyeDesc = reward.AvatarItemDesc,
ConsumableItemDesc = reward.ConsumableItemDesc,
EquipmentPrefabName = reward.EquipmentPrefabName,
EquipmentModificationGuid = reward.EquipmentModificationGuid,
CurrencyType = reward.Tokens > 0 ? RecCenterTokens : 0,
Currency = reward.Tokens,
Xp = reward.Xp,
Level = reward.Level,
GiftContext = reward.GiftContext,
GiftRarity = reward.GiftRarity,
Message = $"Progression event reward {rewardIndex + 1}",
Platform = -1,
PackageVariant = "Standard",
PackageMaterial = string.Empty,
IsValid = true,
SupportsCurrentPlatform = true,
};
db.GiftPackages.Add(gift);
await db.SaveChangesAsync();
db.ObjectiveProgress.Add(new ObjectiveProgressEntity
{
PlayerId = me,
Key = $"{sentinelPrefix}{gift.Id}",
IsCompleted = true,
ClearedAt = DateTime.UtcNow,
});
await db.SaveChangesAsync();
return Ok(global::DorkNet.Server.Controllers.API.Avatar.V2.AvatarGiftsController.ToWire(gift));
}
// ── Purchasable XP boosts ────────────────────────────────────────────
/// <summary>
/// GET <c>api/progressionEvents/{eventId}/xpboosts</c> — the boost
/// offers the client's purchase sheet renders.
///
/// Binary evidence: <c>RecNet.Runtime/CDBFONFHJDO.txt:1465</c>
/// (<c>HKMBEFABIBD(Int64 eventId)</c>) formats
/// <c>"{0}/{1}/xpboosts"</c> against <c>"api/progressionEvents"</c>
/// (ISIL 026/028) and moves <c>0</c> = <c>HTTPMethods.Get</c> into the
/// verb argument at ISIL 034. The declared return type is
/// <c>List&lt;ProgressionEventPurchasableXpBoostDTO&gt;</c>, i.e. a bare
/// JSON array — no paged container.
///
/// Element keys and their order come from the generated Utf8Json
/// formatter <c>RecNet.Runtime/OFGIPBHIAGF.txt:605-698</c>:
/// ProgressionEventPurchasableXpBoostId (Guid), Cost (Int32),
/// XpMultiplier (Int32 — <c>get_XpMultiplierFromBoost</c> reads the DTO's
/// +0x24 int field, NOT a float), XpCap (Int32), LookbackDurationTicks
/// (Int64), CooldownDurationTicks (Int64).
///
/// The boost id must be STABLE across calls: the client caches it from
/// here, passes it to <c>previewEarnedXp</c>, and posts it as
/// <c>purchasableXpBoostId</c> to
/// <c>api/storefronts/v1/buyProgressionEventXpBoost</c> (which keys its
/// purchase sentinel on it). See <see cref="OfferId"/>.
/// </summary>
[HttpGet("api/progressionEvents/{eventId:long}/xpboosts")]
[AllowAnonymous]
public IActionResult XpBoosts(long eventId) =>
Ok(BoostCatalogue(eventId).Select(b => new
{
ProgressionEventPurchasableXpBoostId = b.Id,
b.Cost,
b.XpMultiplier,
b.XpCap,
b.LookbackDurationTicks,
b.CooldownDurationTicks,
}));
/// <summary>
/// GET <c>api/progressionEvents/{eventId}/xpboosts/{boostId}/previewEarnedXp</c>
/// — how much XP buying this boost right now would hand the caller.
///
/// Binary evidence: <c>RecNet.Runtime/CDBFONFHJDO.txt:1612</c>
/// (<c>GBNFGMOGLNG(Int64 eventId, Guid boostId)</c>) formats
/// <c>"{0}/{1}/xpboosts/{2}/previewEarnedXp"</c> against
/// <c>"api/progressionEvents"</c> (ISIL 036/038) and moves <c>0</c> =
/// <c>HTTPMethods.Get</c> into the verb argument at ISIL 049. The return
/// type is <c>FGLDKEJLAKB&lt;System.Int32&gt;</c> — a BARE integer body,
/// not an object; the value lands in
/// <c>ProgressionEventsPurchasableXpBoostModel.get_XpEarnedFromBoost</c>
/// and is echoed back as the <c>expectedXp</c> form field of
/// <c>buyProgressionEventXpBoost</c>, which grants exactly that much.
///
/// Cooldown is real state: <c>buyProgressionEventXpBoost</c> stamps
/// <c>progressionEvent:{eventId}:xpBoost:{guid:N}</c> in ObjectiveProgress
/// on every purchase, so a boost still inside its
/// <c>CooldownDurationTicks</c> window previews as 0 and the client's
/// <c>get_XPBoostIsAvailableForPurchase</c> keeps the button disabled.
///
/// Fidelity note: live Rec Room multiplied the XP the player actually
/// earned during <c>LookbackDurationTicks</c>. DorkNet has no timestamped
/// XP ledger (<see cref="LevelService.AwardXpAsync"/> mutates
/// <c>PlayerEntity.XP</c> in place), so a boost here pays its configured
/// <c>XpCap</c> — the cap is the payout, and <c>XpMultiplier</c> is the
/// figure the sheet renders as "Nx".
/// </summary>
[HttpGet("api/progressionEvents/{eventId:long}/xpboosts/{boostId:guid}/previewEarnedXp")]
[Authorize]
public async Task<IActionResult> PreviewEarnedXp(long eventId, Guid boostId)
{
var me = this.RequireCurrentPlayerId();
var offer = BoostCatalogue(eventId).FirstOrDefault(b => b.Id == boostId);
if (offer is null) return Ok(0);
// Same key StorefrontsBuyController.BuyProgressionEventXpBoost writes.
var purchaseKey = $"progressionEvent:{eventId}:xpBoost:{boostId:N}";
var lastPurchase = await db.ObjectiveProgress
.Where(o => o.PlayerId == me && o.Key == purchaseKey)
.Select(o => o.ClearedAt)
.FirstOrDefaultAsync();
if (offer.CooldownDurationTicks > 0
&& lastPurchase is DateTime at
&& DateTime.UtcNow - at < TimeSpan.FromTicks(offer.CooldownDurationTicks))
{
return Ok(0);
}
return Ok(offer.XpCap);
}
// ── Shared shape helpers ─────────────────────────────────────────────
/// <summary>One pip on the progression track. Field types are taken from
/// <c>ProgressionEventRewardDTO</c> (dump.cs:1199338): ProgressionEventRewardId
/// is Int64 at +0x10 but <b>GiftDropId is Int32</b> at +0x18 (the audit doc
/// says Int64 — the binary disagrees), then string ImageName, Int32 Xp,
/// Int32 RewardIndex, bool IsBonus.</summary>
public sealed record EventReward(
long ProgressionEventRewardId,
int GiftDropId,
string ImageName,
int Xp,
int RewardIndex,
bool IsBonus);
/// <summary>The progression track's reward list. One source of truth for
/// <see cref="Event"/> (which renders the pips) and <see cref="Collect"/>
/// (which bounds-checks the claimed index), so the two can't drift into a
/// state where the client can see a chest the server refuses to open.</summary>
private static IReadOnlyList<EventReward> BuildRewards(WeeklyChallengeReward reward, long activeId) =>
[
new EventReward(
ProgressionEventRewardId: activeId * 1000L,
// yyyyMMdd fits Int32 comfortably, so the event id doubles as a
// stable placeholder drop id when no reward drop is configured.
GiftDropId: reward.GiftDropId != 0 ? reward.GiftDropId : (int)activeId,
ImageName: string.Empty,
Xp: reward.Xp,
RewardIndex: 0,
IsBonus: false),
];
private static string RewardClaimPrefix(long eventId) => $"progressionEvent:{eventId}:reward:";
// ── XP-boost catalogue ───────────────────────────────────────────────
public sealed record XpBoostOffer(
Guid Id,
int Cost,
int XpMultiplier,
int XpCap,
long LookbackDurationTicks,
long CooldownDurationTicks);
/// <summary>The boosts this deployment sells for a given event.
///
/// Configured under <c>ProgressionEvents:XpBoosts</c> as an array of
/// <c>{Cost, XpMultiplier, XpCap, LookbackMinutes, CooldownMinutes}</c>;
/// with nothing configured we fall back to a single built-in offer (same
/// pattern as <see cref="ServerSettingsService.DefaultWeeklyChallenges"/>
/// and the built-in NUX checklist). Set
/// <c>ProgressionEvents:EnableXpBoosts</c> to false to sell none — the
/// event then reports <c>UsesBoost=false</c> and the sheet stays shut.</summary>
private IReadOnlyList<XpBoostOffer> BoostCatalogue(long eventId)
{
if (!config.GetValue("ProgressionEvents:EnableXpBoosts", true))
return [];
var configured = config.GetSection("ProgressionEvents:XpBoosts").GetChildren().ToList();
if (configured.Count == 0)
{
return
[
new XpBoostOffer(
Id: OfferId(eventId, 0),
Cost: 250,
XpMultiplier: 2,
XpCap: 500,
LookbackDurationTicks: TimeSpan.FromHours(1).Ticks,
CooldownDurationTicks: TimeSpan.FromHours(24).Ticks),
];
}
var offers = new List<XpBoostOffer>(configured.Count);
for (var i = 0; i < configured.Count; i++)
{
var section = configured[i];
var cap = Math.Max(0, section.GetValue("XpCap", 500));
offers.Add(new XpBoostOffer(
Id: OfferId(eventId, i),
Cost: Math.Max(0, section.GetValue("Cost", 250)),
XpMultiplier: Math.Max(1, section.GetValue("XpMultiplier", 2)),
XpCap: cap,
LookbackDurationTicks: TimeSpan.FromMinutes(
Math.Max(0, section.GetValue("LookbackMinutes", 60))).Ticks,
CooldownDurationTicks: TimeSpan.FromMinutes(
Math.Max(0, section.GetValue("CooldownMinutes", 1440))).Ticks));
}
return offers;
}
/// <summary>Deterministic per-(event, slot) boost id. The client caches
/// the guid from <see cref="XpBoosts"/> and replays it to
/// <see cref="PreviewEarnedXp"/> and to
/// <c>storefronts/v1/buyProgressionEventXpBoost</c>, whose purchase /
/// cooldown sentinel is keyed on it — a freshly generated guid per request
/// would make every preview miss its own catalogue entry and reset the
/// cooldown on every call. Derived rather than stored because the boost
/// catalogue itself lives in configuration, not the database.</summary>
private static Guid OfferId(long eventId, int index) =>
new(System.Security.Cryptography.SHA256.HashData(
System.Text.Encoding.UTF8.GetBytes($"dorknet:progressionEventXpBoost:{eventId}:{index}"))
.AsSpan(0, 16));
private static long CurrentProgressionEventId() private static long CurrentProgressionEventId()
{ {
var (start, _) = CurrentWeekWindow(); var (start, _) = CurrentWeekWindow();
@@ -2291,6 +2291,109 @@ public class RoomsController(
public async Task<IActionResult> CreatedByOther(long otherPlayerId) public async Task<IActionResult> CreatedByOther(long otherPlayerId)
=> Ok((await rooms.CreatedByAsync(otherPlayerId)).Select(RoomService.ToWireRoom).ToList()); => Ok((await rooms.CreatedByAsync(otherPlayerId)).Select(RoomService.ToWireRoom).ToList());
/// <summary>Per-player showcase curation, stored in the existing generic
/// player-settings table as a CSV of room ids. Colon-namespaced to match
/// the other server-owned keys (<c>settings:partyinvite</c>).</summary>
private const string ShowcaseSettingKey = "rooms:showcase";
private const int ShowcaseMaxRooms = 50;
/// <summary>GET <c>showcase/{accountId}</c> — the profile "Showcase Rooms"
/// carousel (<c>RoomListModel.JMGLGEKHHAK.PlayerShowcaseRooms = 7</c>,
/// dump.cs:2042498, driven by
/// <c>AccountModelController.RoomsShowcaseLinkImpl</c> at dump.cs:2113041).
/// Distinct from <c>PlayerCreatedRooms = 5</c>, which is the
/// <c>rooms/createdby/{id}</c> list above.
///
/// <para><b>Verb.</b> <c>NLDBPDCNNCF.EJPNHLIJNPM</c> formats the literal
/// <c>"showcase/{0}"</c> (NLDBPDCNNCF.txt ISIL 021 at :4484) and moves the
/// HTTPMethods ordinal <c>0</c> (= GET) into rdx immediately before the
/// dispatch call (ISIL 031/032 at :4494-4495). No cmov, so GET is the only
/// verb. The path arg is boxed as <c>System.Int32</c> (ISIL 017) — the
/// account id.</para>
///
/// <para><b>Response shape.</b> The issuing method's return type is the
/// contract: <c>Task&lt;List&lt;System.Int64&gt;&gt;</c>
/// (NLDBPDCNNCF.txt:4422) — a BARE JSON array of room ids, not Room
/// objects and not a <c>{Results,TotalResults}</c> container. The Rooms the
/// UI renders are a purely client-side projection: the cache entry
/// <c>&lt;GetRoomsShowcase&gt;b__0</c> is typed
/// <c>Task&lt;IReadOnlyList&lt;Int64&gt;&gt;</c>
/// (IBEOONPEELF_NestedType_HHDHJPIJBJB.txt:14) and the outer
/// <c>IBEOONPEELF.EJPNHLIJNPM</c> resolves those ids through the room cache
/// to <c>FGLDKEJLAKB&lt;IReadOnlyList&lt;NEMINAEBALC&gt;&gt;</c>
/// (IBEOONPEELF.txt:8952) before <c>RoomListModel</c> consumes it
/// (RoomListModel.txt ISIL 742 at :5819).</para>
///
/// <para><b>Backing data.</b> <c>showcase/{0}</c> is the only showcase
/// literal in the binary — the 2023 client can read the list but never
/// writes it (curation lived on the website), so the curated order is kept
/// in the existing per-player settings store under
/// <see cref="ShowcaseSettingKey"/> and is writable through
/// <c>PUT /settings/v1/{accountId}</c> with no new schema. Curated ids are
/// re-validated against Rooms on every read so a room that has since been
/// archived or privatised drops off the profile, and the curated order is
/// preserved. A player who never curated one falls back to the rooms they
/// actually created, most prominent first — the showcase the carousel is
/// there to display.</para>
///
/// <para>Unauthenticated on purpose: this is another player's profile
/// surface, and the sibling <c>rooms/createdby/{id}</c> is open too. The
/// owner viewing their own profile additionally sees their non-public
/// picks; everyone else sees public rooms only.</para></summary>
[HttpGet("showcase/{accountId:long}")]
// roomserver/ twin: EJPNHLIJNPM lives on NLDBPDCNNCF, whose requests all
// arrive with the roomserver/ prefix in this deployment
// (docs/recroom-2023-room-save.md:83-93) — same reason every other
// NLDBPDCNNCF route in this file is registered twice.
[HttpGet("roomserver/showcase/{accountId:long}")]
public async Task<IActionResult> RoomsShowcase(long accountId)
{
var viewerIsOwner = CurrentPlayerId == accountId;
var curatedCsv = await db.PlayerSettings.AsNoTracking()
.Where(s => s.PlayerId == accountId && s.Key == ShowcaseSettingKey)
.Select(s => s.Value)
.FirstOrDefaultAsync();
var curatedIds = (curatedCsv ?? string.Empty)
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(v => long.TryParse(v, out var id) ? id : 0L)
.Where(id => id > 0)
.Distinct()
.Take(ShowcaseMaxRooms)
.ToList();
if (curatedIds.Count > 0)
{
var visible = await db.Rooms.AsNoTracking()
.Where(r => curatedIds.Contains(r.Id)
&& r.State == 0
&& !r.IsDormRoom
&& !r.HiddenFromBrowse
&& (r.Accessibility == 1 || viewerIsOwner))
.Select(r => r.Id)
.ToListAsync();
var visibleSet = visible.ToHashSet();
// Curated order is the display order — filter, don't re-sort.
var ordered = curatedIds.Where(visibleSet.Contains).ToList();
if (ordered.Count > 0) return Ok(ordered);
}
var fallback = await db.Rooms.AsNoTracking()
.Where(r => r.CreatorPlayerId == accountId
&& r.State == 0
&& !r.IsDormRoom
&& !r.HiddenFromBrowse
&& (r.Accessibility == 1 || viewerIsOwner))
.OrderByDescending(r => r.HotScore)
.ThenByDescending(r => r.VisitCount)
.ThenByDescending(r => r.UpdatedAt)
.Select(r => r.Id)
.Take(ShowcaseMaxRooms)
.ToListAsync();
return Ok(fallback);
}
[HttpGet("api/rooms/v1/visitedby/me")] [HttpGet("api/rooms/v1/visitedby/me")]
[HttpGet("api/rooms/v2/visitedby/me")] [HttpGet("api/rooms/v2/visitedby/me")]
[HttpGet("api/rooms/v3/visitedby/me")] [HttpGet("api/rooms/v3/visitedby/me")]
@@ -0,0 +1,194 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using DorkNet.Server.Auth;
using DorkNet.Server.Binding;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.API.Subscriptions;
/// <summary>
/// The creator-subscription mutation pair the 2023-03-21 client hits from a
/// profile card's Subscribe button. Both live at the bare two-segment route
/// <c>subscription/{accountId}</c> on the Clubs host (enum value 13); the
/// route literal is <c>"subscription/{0}"</c> formatted with a boxed
/// <c>System.Int32</c> account id at
/// <c>RecNet.Runtime/IKMMOCKDKAF.txt:6551 (POST, verb rdx=2)</c> and
/// <c>:6862 (DELETE, verb rdx=4)</c>. Callers are the account-model wrappers
/// <c>OBEEEEAMLJI.BDJCLAGIHBN/LPFOPPHGDHG(Int32 accountId, …)</c>
/// (<c>Assembly-CSharp/OBEEEEAMLJI.txt:644,793</c>) behind
/// <c>AGUI/StackedUI/SubscribeConditionalButton</c>, so the path segment is
/// always a player account id — never a club id.
///
/// The GET side of this surface already exists but lives with the club reads
/// (<c>ClubsController</c>: <c>subscription/details/{id}</c>,
/// <c>subscription/subscriberCount/{id}</c>, <c>subscription/mine/member</c>)
/// and <c>PlayerSubscriptionsController</c>
/// (<c>subscription/top/creators/today</c>). Only the two writes were missing,
/// which made every subscribe/unsubscribe 404 and surface the client's
/// <c>"Failed to subscribe to {0}"</c> / <c>"Failed to unsubscribe from {0}"</c>
/// toast (format literals at <c>IKMMOCKDKAF.txt:6663</c> and the DELETE's
/// matching continuation).
///
/// Neither response body is parsed: the client's continuation is a bare
/// <c>Func&lt;IPCJLCNIBEG&lt;Int64&gt;, …&gt;</c> whose only use is the
/// success/failure branch, after which it invalidates its subscription cache
/// and re-reads <c>subscription/mine/member</c> + <c>subscription/details</c>.
/// Any 2xx is therefore sufficient — but the reads it follows up with must
/// reflect the write, which is why these handlers touch BOTH subscription
/// tables (see <see cref="Subscribe"/>).
/// </summary>
[ApiController]
[Authorize]
public class SubscriptionsController(
DorkNetDbContext db,
ClubService clubs,
ILogger<SubscriptionsController> logger) : ControllerBase
{
private long Me => this.RequireCurrentPlayerId();
/// <summary>
/// Body of the subscribe POST. The client appends exactly one
/// form-urlencoded field via <c>BNDIAONDFFF.AFGEDDANEKP("roomId", …)</c>
/// (<c>IKMMOCKDKAF.txt:6726 — Move rdx, "roomId"</c>), boxed from an
/// <c>ObscuredLong</c> holding the room the player is standing in. The
/// preceding compare against <c>-1</c> (<c>:6713</c>,
/// <c>Compare rax, 18446744073709551615</c> → jump past the append) means
/// the field is OMITTED entirely when the player is not in a room, so it
/// must be optional. Unsubscribe sends no body at all.
/// </summary>
public sealed class SubscribeRequest
{
public long RoomId { get; set; } = -1;
}
/// <summary>
/// POST <c>/subscription/{accountId}</c> — subscribe to a creator.
///
/// Writes two rows, because the 2023 client's post-subscribe refresh reads
/// two differently-sourced endpoints and both have to agree:
/// * <see cref="DorkNetDbContext.Subscriptions"/> (player→player) is what
/// <c>subscription/details/{id}</c>, <c>subscription/subscriberCount/{id}</c>,
/// <c>subscription/top/creators/today</c> and the PlayerState reputation
/// aggregate all count. This is the canonical row.
/// * <see cref="DorkNetDbContext.ClubSubscriptions"/> is what
/// <c>subscription/mine/member</c> and
/// <c>announcements/v2/subscription/mine/unread</c> read. A creator's
/// "creator club" is the club they own (same resolution
/// <c>subscription/details</c> uses for its <c>ClubId</c> field — oldest
/// id wins), so when the target owns one we mirror the subscription
/// there too. Without the mirror the subscribe succeeds but the
/// creator never appears in the client's own subscribed list and the
/// button flips straight back to "Subscribe" on the next cache refresh.
/// Creators with no club get only the canonical row, which is all their
/// profile card reads.
///
/// Idempotent: re-subscribing is a 200 no-op rather than a unique-index
/// violation (both tables carry a unique pair index).
/// </summary>
[HttpPost("/subscription/{accountId:long}")]
public async Task<IActionResult> Subscribe(
long accountId,
[ModelBinder(typeof(FormOrJsonModelBinder))] SubscribeRequest req)
{
var me = Me;
if (accountId == me) return BadRequest(new { Error = "cannot_subscribe_to_self" });
var targetExists = await db.Players.AnyAsync(p => p.Id == accountId);
if (!targetExists) return NotFound(new { Error = "account_not_found" });
var changed = false;
var existing = await db.Subscriptions.FirstOrDefaultAsync(
s => s.SubscriberPlayerId == me && s.TargetPlayerId == accountId);
if (existing is null)
{
db.Subscriptions.Add(new SubscriptionEntity
{
SubscriberPlayerId = me,
TargetPlayerId = accountId,
});
changed = true;
}
var creatorClubId = await CreatorClubIdAsync(accountId);
if (creatorClubId != 0)
{
var existingClub = await db.ClubSubscriptions.FirstOrDefaultAsync(
s => s.PlayerId == me && s.ClubId == creatorClubId);
if (existingClub is null)
{
db.ClubSubscriptions.Add(new ClubSubscriptionEntity
{
ClubId = creatorClubId,
PlayerId = me,
});
changed = true;
}
}
if (changed) await db.SaveChangesAsync();
// roomId is pure attribution — "which room was this creator discovered
// in". There is no column for it on SubscriptionEntity and this pass
// adds no schema, so it is recorded in the log rather than silently
// dropped; the subscription itself is fully persisted above.
if (req.RoomId > 0)
{
logger.LogInformation(
"Player {SubscriberId} subscribed to creator {AccountId} from room {RoomId}.",
me, accountId, req.RoomId);
}
return Ok();
}
/// <summary>
/// DELETE <c>/subscription/{accountId}</c> — unsubscribe. Same route
/// literal, verb register <c>rdx=4</c> at
/// <c>IKMMOCKDKAF.txt:6864</c>; the request carries no parameters
/// (<c>AFGEDDANEKP</c> is never called on the builder before the send),
/// so there is nothing to bind.
///
/// Removes both rows written by <see cref="Subscribe"/> so the canonical
/// counts and the <c>subscription/mine/member</c> list drop the creator
/// together. Unsubscribing when not subscribed is a 200 no-op — the client
/// treats any non-2xx as the "Failed to unsubscribe from {0}" toast, and a
/// double-tap of an already-removed subscription is not a failure the
/// player should see.
/// </summary>
[HttpDelete("/subscription/{accountId:long}")]
public async Task<IActionResult> Unsubscribe(long accountId)
{
var me = Me;
var row = await db.Subscriptions.FirstOrDefaultAsync(
s => s.SubscriberPlayerId == me && s.TargetPlayerId == accountId);
if (row is not null) db.Subscriptions.Remove(row);
var creatorClubId = await CreatorClubIdAsync(accountId);
if (creatorClubId != 0)
{
var clubRow = await db.ClubSubscriptions.FirstOrDefaultAsync(
s => s.PlayerId == me && s.ClubId == creatorClubId);
if (clubRow is not null) db.ClubSubscriptions.Remove(clubRow);
}
await db.SaveChangesAsync();
return Ok();
}
/// <summary>
/// The club id <c>subscription/details/{accountId}</c> reports as the
/// creator's <c>ClubId</c>: the oldest live club the account owns, or 0
/// when they own none. Kept byte-identical to that handler's resolution so
/// subscribing writes the row the details card points the player at.
/// </summary>
private async Task<long> CreatorClubIdAsync(long accountId)
{
var created = await clubs.CreatedByAsync(accountId);
return created.Count > 0 ? created.Min(c => c.Id) : 0L;
}
}
@@ -26,6 +26,7 @@ public class AccountsController(
DorkNetDbContext db, DorkNetDbContext db,
NotificationService notifications, NotificationService notifications,
ServerSettingsService settings, ServerSettingsService settings,
PlayerPresenceService presence,
ILogger<AccountsController> logger) : ControllerBase ILogger<AccountsController> logger) : ControllerBase
{ {
[HttpGet("/account/v1/savedlogins")] [HttpGet("/account/v1/savedlogins")]
@@ -62,6 +63,102 @@ public class AccountsController(
return Ok(BuildSelfAccount(player.Id, player.Username, player.DisplayName, player.ProfileImageName)); return Ok(BuildSelfAccount(player.Id, player.Username, player.DisplayName, player.ProfileImageName));
} }
/// <summary>
/// DELETE <c>/account/me</c> — the 2023 client's "delete local account"
/// (RecNet.Runtime <c>OPMAPIOEIFG.ADFMLPGEBOF()</c>). Binary evidence:
/// the request object is built at OPMAPIOEIFG.txt:8767-8777 with the
/// verb ordinal in <c>rdx</c> = 4 (BestHTTP.HTTPMethods.Delete) and the
/// path literal <c>"account/me"</c> at :8771; the failure branch at
/// :8790 carries the string <c>"Failed to delete local account"</c>.
/// The issuing method returns <c>LDGADANDBIO</c>, the fire-and-forget
/// promise handle — the response BODY is never parsed, only the HTTP
/// status decides success — so the RecNetResult we emit is purely for
/// human/curl consumption. Registering only <c>[HttpGet]</c> on this
/// path (the previous state) produced a 405 and the in-game
/// "delete my account" button silently failed.
///
/// Erasure runs through the same <see cref="PlayerAccountPurge"/>
/// cascade the admin SPA uses, inside one transaction: personal rows
/// are deleted, authored durable content is reassigned to the system
/// account, then the Players row goes and the live session is kicked
/// so the bearer token stops being useful immediately.
/// </summary>
[HttpDelete("/account/me")]
[HttpDelete("/account/v1/me")]
[HttpDelete("/account/v2/me")]
public async Task<IActionResult> DeleteMe()
{
var player = await GetCurrentPlayerAsync();
if (player is null) return Unauthorized();
// The system account (id 1) owns every reassigned artefact — losing
// it would orphan the whole DB — and admin accounts must be demoted
// through the SPA first so a compromised session can't nuke the
// server's only operator. Both refusals are surfaced as a non-2xx so
// the client's promise rejects and the player sees the failure
// rather than a phantom success.
if (player.Id == 1)
{
logger.LogWarning("[accounts] DELETE account/me refused — system account");
return BadRequest(new RecNetResult
{
Success = false,
Error = "The system account cannot be deleted.",
});
}
if (player.IsAdmin)
{
logger.LogWarning(
"[accounts] DELETE account/me refused — {Id} ({Username}) is an admin",
player.Id, player.Username);
return BadRequest(new RecNetResult
{
Success = false,
Error = "Admin accounts cannot self-delete. Demote the account first.",
});
}
var playerId = player.Id;
var username = player.Username;
// Authored content is reassigned rather than destroyed. Preference
// order: the system account (id 1), else the lowest-id surviving
// account, else 0 (single-account server — nothing left to own it).
var systemPlayerId = await db.Players.AnyAsync(p => p.Id == 1)
? 1L
: await db.Players.Where(p => p.Id != playerId)
.OrderBy(p => p.Id)
.Select(p => p.Id)
.FirstOrDefaultAsync();
// GetCurrentPlayerAsync goes through PlayerService.GetByIdAsync,
// which Include()s the Avatar and leaves it tracked on this same
// scoped DbContext. PurgeAsync deletes that avatar row with
// ExecuteDelete, so leaving it tracked would make the Players.Remove
// cascade emit a second DELETE for an already-gone row and throw
// DbUpdateConcurrencyException. Drop the graph, then re-read the
// Players row on its own.
db.ChangeTracker.Clear();
var row = await db.Players.FirstOrDefaultAsync(p => p.Id == playerId);
if (row is null) return Ok(new RecNetResult { Success = true, Error = string.Empty });
await using (var tx = await db.Database.BeginTransactionAsync())
{
var summary = await PlayerAccountPurge.PurgeAsync(db, playerId, systemPlayerId);
db.Players.Remove(row);
await db.SaveChangesAsync();
await tx.CommitAsync();
logger.LogInformation(
"[accounts] DELETE account/me — removed {Id} ({Username}); {Summary}",
playerId, username, summary);
}
await notifications.KickPlayerAsync(playerId, "This account has been deleted.");
presence.Clear(playerId);
return Ok(new RecNetResult { Success = true, Error = string.Empty });
}
/// <summary> /// <summary>
/// Resolves the player tied to the bearer token on the request. Returns /// Resolves the player tied to the bearer token on the request. Returns
/// null when no/invalid bearer is supplied — the caller decides whether /// null when no/invalid bearer is supplied — the caller decides whether
@@ -692,8 +789,103 @@ public class AccountsController(
return Ok(new { accountId = (int)accountId, bio = p?.Bio ?? string.Empty }); return Ok(new { accountId = (int)accountId, bio = p?.Bio ?? string.Empty });
} }
/// <summary>
/// GET <c>/platformid/{accountId}</c> — the 2023 client's
/// <c>OPMAPIOEIFG.DPLOCBLKCEB(Int32 accountId)</c>. Binary evidence:
/// path literal <c>"platformid/{0}"</c> at OPMAPIOEIFG.txt:11056 with
/// the verb ordinal <c>rdx</c> = 0 (GET) at :11066, and the failure
/// string <c>"Failed to GetPlatformId for accountId {0}"</c> at :11102.
///
/// RESPONSE SHAPE: the issuing method's return type is
/// <c>FGLDKEJLAKB&lt;System.String&gt;</c> — a BARE JSON string, not an
/// object. Emitting <c>{"PlatformId":0}</c> makes the strict reader hit
/// <c>'{'</c> where it wants <c>'"'</c> and throw. The legacy
/// <c>/account/v1/{id}/platformid</c> spelling is kept as an alias and
/// now returns the real stored value instead of a hardcoded 0.
///
/// The client short-circuits and never issues the request when
/// accountId &lt;= 0 (:11052-11053), so we only ever see real ids.
/// Unknown accounts get an empty string — "no platform identity" is the
/// honest answer and keeps the promise resolved rather than rejected.
/// </summary>
[HttpGet("/platformid/{accountId:long}")]
[HttpGet("/account/v1/{accountId:long}/platformid")] [HttpGet("/account/v1/{accountId:long}/platformid")]
public IActionResult GetPlatformId(long accountId) => Ok(new { PlatformId = 0L }); public async Task<IActionResult> GetPlatformId(long accountId)
{
var platformId = await db.Players
.Where(p => p.Id == accountId)
.Select(p => p.LastPlatformId)
.FirstOrDefaultAsync();
return Content(
System.Text.Json.JsonSerializer.Serialize(platformId ?? string.Empty),
"application/json");
}
/// <summary>
/// GET <c>/accounts/{accountId}/receives/{category}</c> — the 2023
/// client's platform-notification opt-in probe
/// (<c>LELAJKMOMIA.GCIPPNCOKAJ(Int32 accountId)</c>). Binary evidence:
/// path literal at LELAJKMOMIA.txt:695, verb ordinal <c>rdx</c> = 0
/// (GET) at :705, service-host ordinal 15 (platformnotifications) at
/// :709. The <c>{1}</c> segment is a <c>CCOKJMJOIJF</c> enum value
/// boxed and <c>String.Format</c>ed at :690-698, i.e. the enum MEMBER
/// NAME — the constant at this call site is value 8 (:693) but the
/// member names appear nowhere as literals in the binary, so the
/// segment MUST stay an unconstrained opaque string.
///
/// RESPONSE SHAPE: the continuation is <c>System.Action&lt;Boolean&gt;</c>
/// over <c>FGLDKEJLAKB&lt;Boolean&gt;</c> (:721/:733) — a BARE JSON
/// boolean, no envelope. A 404 here rejects the promise inside AGUI's
/// AccountSpecificUIBehaviour and blocks the notification-gated action.
///
/// Answered from the real <see cref="NotificationPrefsEntity"/> row that
/// backs <c>PUT /preferences</c>: <c>AllowAll</c> gates everything, then
/// the per-category flag. Categories are resolved by the same ids the
/// platformnotifications <c>config/categories</c> catalogue publishes
/// (1 Messages, 2 Friend Requests, 3 Event Invites, 4 Announcements) and
/// by normalised name, so both the numeric and member-name spellings
/// land on the right column. A category we don't model (such as the
/// value-8 one this call site uses) falls back to <c>AllowAll</c> — the
/// player has no mute for it, which is the truthful answer, not a stub.
/// </summary>
[HttpGet("/accounts/{accountId:long}/receives/{category}")]
[HttpGet("/platformnotifications/accounts/{accountId:long}/receives/{category}")]
public async Task<IActionResult> Receives(long accountId, string category)
{
var exists = await db.Players.AnyAsync(p => p.Id == accountId);
if (!exists) return Content("false", "application/json");
var prefs = await db.NotificationPrefs
.FirstOrDefaultAsync(p => p.PlayerId == accountId);
// No row at all = never opted out of anything.
if (prefs is null) return Content("true", "application/json");
var receives = prefs.AllowAll && CategoryAllowed(prefs, category);
return Content(receives ? "true" : "false", "application/json");
}
/// <summary>Maps the opaque <c>{category}</c> segment onto the columns
/// of <see cref="NotificationPrefsEntity"/>. Accepts the numeric
/// CategoryId published by <c>config/categories</c> as well as the enum
/// member name in any casing/punctuation; anything we don't model is
/// only gated by <c>AllowAll</c>.</summary>
private static bool CategoryAllowed(NotificationPrefsEntity prefs, string? category)
{
if (string.IsNullOrWhiteSpace(category)) return true;
var key = new string(category.Where(char.IsLetterOrDigit).ToArray()).ToLowerInvariant();
return key switch
{
"1" or "message" or "messages" or "directmessage" or "directmessages"
=> prefs.AllowMessage,
"2" or "friendrequest" or "friendrequests"
=> prefs.AllowFriendRequest,
"3" or "eventinvite" or "eventinvites" or "invite" or "invites"
=> prefs.AllowEventInvite,
"4" or "announcement" or "announcements" or "news"
=> prefs.AllowAnnouncements,
_ => true,
};
}
[HttpGet("/accountprivacysettings/{accountId:long}")] [HttpGet("/accountprivacysettings/{accountId:long}")]
[HttpGet("/account/v1/privacysettings/{accountId:long}")] [HttpGet("/account/v1/privacysettings/{accountId:long}")]
@@ -3325,132 +3325,12 @@ public class AdminController(
// ── Helpers ────────────────────────────────────────────────────────── // ── Helpers ──────────────────────────────────────────────────────────
private async Task<string> DeletePlayerDataAsync(long playerId, long systemPlayerId) /// <summary>Delegates to <see cref="PlayerAccountPurge.PurgeAsync"/> —
{ /// the cascade is shared with the 2023 client's self-service
var now = DateTime.UtcNow; /// <c>DELETE account/me</c> so both paths erase exactly the same set
var deleted = 0; /// of rows.</summary>
var reassigned = 0; private Task<string> DeletePlayerDataAsync(long playerId, long systemPlayerId)
=> PlayerAccountPurge.PurgeAsync(db, playerId, systemPlayerId);
deleted += await db.Avatars.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
deleted += await db.PlayerSettings.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
deleted += await db.Relationships.Where(x => x.RequesterId == playerId || x.TargetId == playerId).ExecuteDeleteAsync();
deleted += await db.CurrencyBalances.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
deleted += await db.PlayerInventory.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
deleted += await db.PlayerDevices.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
deleted += await db.NotificationPrefs.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
deleted += await db.CohortAssignments.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
deleted += await db.PushTokens.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
deleted += await db.PlatformIgnores.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
deleted += await db.Cards.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
deleted += await db.ObjectiveProgress.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
deleted += await db.GameRewardSelections.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
deleted += await db.CouponRedemptions.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
deleted += await db.LeaderboardStats.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
deleted += await db.PlayerElo.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
deleted += await db.RoyalePlayerProgress.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
deleted += await db.RoyaleMatchPlayers.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
deleted += await db.Messages.Where(x => x.SenderPlayerId == playerId || x.RecipientPlayerId == playerId).ExecuteDeleteAsync();
deleted += await db.ChatMessages.Where(x => x.SenderPlayerId == playerId).ExecuteDeleteAsync();
deleted += await db.ChatThreadMembers.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
reassigned += await db.ChatThreads
.Where(x => x.CreatorPlayerId == playerId)
.ExecuteUpdateAsync(s => s.SetProperty(x => x.CreatorPlayerId, systemPlayerId));
deleted += await db.Cheers.Where(x => x.FromPlayerId == playerId || x.TargetPlayerId == playerId).ExecuteDeleteAsync();
deleted += await db.Reports.Where(x => x.ReporterPlayerId == playerId || x.TargetPlayerId == playerId).ExecuteDeleteAsync();
deleted += await db.BugReports.Where(x => x.ReporterPlayerId == playerId).ExecuteDeleteAsync();
deleted += await db.Subscriptions.Where(x => x.SubscriberPlayerId == playerId || x.TargetPlayerId == playerId).ExecuteDeleteAsync();
deleted += await db.ClubMemberships.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
deleted += await db.ClubSubscriptions.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
deleted += await db.ClubAnnouncementReads.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
reassigned += await db.Clubs
.Where(x => x.CreatorPlayerId == playerId)
.ExecuteUpdateAsync(s => s
.SetProperty(x => x.CreatorPlayerId, systemPlayerId)
.SetProperty(x => x.UpdatedAt, now));
reassigned += await db.ClubAnnouncements
.Where(x => x.AuthorPlayerId == playerId)
.ExecuteUpdateAsync(s => s.SetProperty(x => x.AuthorPlayerId, systemPlayerId));
deleted += await db.RoomBookmarks.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
deleted += await db.RoomVisits.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
deleted += await db.RoomRoles.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
deleted += await db.RoomBans.Where(x => x.BannedPlayerId == playerId).ExecuteDeleteAsync();
reassigned += await db.RoomBans
.Where(x => x.BannedByPlayerId == playerId)
.ExecuteUpdateAsync(s => s.SetProperty(x => x.BannedByPlayerId, systemPlayerId));
reassigned += await db.RoomRoles
.Where(x => x.GrantedByPlayerId == playerId)
.ExecuteUpdateAsync(s => s.SetProperty(x => x.GrantedByPlayerId, systemPlayerId));
reassigned += await db.Rooms
.Where(x => x.CreatorPlayerId == playerId)
.ExecuteUpdateAsync(s => s
.SetProperty(x => x.CreatorPlayerId, systemPlayerId)
.SetProperty(x => x.UpdatedAt, now));
reassigned += await db.RoomDataBlobs
.Where(x => x.UploadedByPlayerId == playerId)
.ExecuteUpdateAsync(s => s.SetProperty(x => x.UploadedByPlayerId, systemPlayerId));
reassigned += await db.RoomKeys
.Where(x => x.CreatorPlayerId == playerId)
.ExecuteUpdateAsync(s => s.SetProperty(x => x.CreatorPlayerId, systemPlayerId));
deleted += await db.RoomKeyPurchases.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
deleted += await db.PlaylistInteractions.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
reassigned += await db.Playlists
.Where(x => x.CreatorPlayerId == playerId)
.ExecuteUpdateAsync(s => s.SetProperty(x => x.CreatorPlayerId, systemPlayerId));
deleted += await db.PlayerEventResponses
.Where(x => x.PlayerId == playerId
|| db.PlayerEvents.Where(e => e.CreatorPlayerId == playerId).Select(e => e.Id).Contains(x.EventId))
.ExecuteDeleteAsync();
deleted += await db.PlayerEvents.Where(x => x.CreatorPlayerId == playerId).ExecuteDeleteAsync();
deleted += await db.PrivateInstanceInvitees.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
var privateInstanceIds = await db.PrivateInstances
.Where(x => x.OwnerPlayerId == playerId)
.Select(x => x.Id)
.ToListAsync();
if (privateInstanceIds.Count > 0)
{
deleted += await db.PrivateInstanceInvitees
.Where(x => privateInstanceIds.Contains(x.PrivateInstanceId))
.ExecuteDeleteAsync();
deleted += await db.PrivateInstances
.Where(x => privateInstanceIds.Contains(x.Id))
.ExecuteDeleteAsync();
}
deleted += await db.GiftPackages
.Where(x => x.RecipientPlayerId == playerId)
.ExecuteDeleteAsync();
if (playerId <= int.MaxValue)
{
reassigned += await db.GiftPackages
.Where(x => x.FromPlayerId == (int)playerId)
.ExecuteUpdateAsync(s => s.SetProperty(x => x.FromPlayerId, (int?)null));
}
reassigned += await db.Inventions
.Where(x => x.CreatorPlayerId == playerId)
.ExecuteUpdateAsync(s => s
.SetProperty(x => x.CreatorPlayerId, systemPlayerId)
.SetProperty(x => x.UpdatedAt, now));
reassigned += await db.Photos
.Where(x => x.UploaderPlayerId == playerId)
.ExecuteUpdateAsync(s => s
.SetProperty(x => x.UploaderPlayerId, systemPlayerId)
.SetProperty(x => x.DeletedAt, now)
.SetProperty(x => x.IsPublic, false));
reassigned += await db.SignupCodes
.Where(x => x.RedeemedByPlayerId == playerId)
.ExecuteUpdateAsync(s => s.SetProperty(x => x.RedeemedByPlayerId, (long?)null));
return $"deleted_rows={deleted} reassigned_rows={reassigned}";
}
private Task LogAsync(string action, string targetType, long targetId, string reason) private Task LogAsync(string action, string targetType, long targetId, string reason)
{ {
+370 -55
View File
@@ -30,11 +30,23 @@ public class ChatController(
DorkNetDbContext db, DorkNetDbContext db,
NotificationService notifications) : ControllerBase NotificationService notifications) : ControllerBase
{ {
// MGGNHLHPHOF on the 2023 wire: Success=0, InvalidArguments=1,
// ThreadNotFound=2, MembershipNotFound=3, PlayerAlreadyOnThread=4,
// CannotMessagePlayer=5, InvalidCharacters=6, RecentlyLeftThread=7,
// ThreadTooLarge=8.
private const int ChatSuccess = 0; private const int ChatSuccess = 0;
private const int ChatInvalidArguments = 1; private const int ChatInvalidArguments = 1;
private const int ChatThreadNotFound = 2;
private const int ChatMembershipNotFound = 3; private const int ChatMembershipNotFound = 3;
private const int ChatPlayerAlreadyOnThread = 4; private const int ChatPlayerAlreadyOnThread = 4;
/// <summary>Bit markers on <c>ClubMembershipEntity.Permissions</c>:
/// 128 = pending invite/request, 256 = banned. Both mean "not on the
/// roster" — same test <c>ClubService.MembershipTypeFromPerms</c>
/// applies.</summary>
private const int ClubPendingFlag = 128;
private const int ClubBannedFlag = 256;
private long Me => this.RequireCurrentPlayerId(); private long Me => this.RequireCurrentPlayerId();
/// <summary>List the caller's chat threads, most-recently-active /// <summary>List the caller's chat threads, most-recently-active
@@ -64,10 +76,34 @@ public class ChatController(
.Distinct() .Distinct()
.ToListAsync(); .ToListAsync();
// 3. Club chats for every club I'm actually on the roster of —
// club threads carry no ChatThreadMembers rows (the roster
// IS the membership), so without this the "Club chats"
// filter on the thread list is always empty. Only clubs
// whose channel has been used are listed, so browsing the
// inbox never materialises empty threads.
var myClubKeys = (await db.ClubMemberships
.Where(m => m.PlayerId == Me &&
(m.Permissions & ClubPendingFlag) == 0 &&
(m.Permissions & ClubBannedFlag) == 0)
.Select(m => m.ClubId)
.ToListAsync())
.Select(ClubKey)
.ToList();
var activeClubKeys = myClubKeys.Count == 0
? new List<string>()
: await db.ChatMessages
.Where(c => myClubKeys.Contains(c.ThreadKey))
.Select(c => c.ThreadKey)
.Distinct()
.ToListAsync();
var keys = sentOrReceived var keys = sentOrReceived
.Concat(memberKeys) .Concat(memberKeys)
.Concat(activeClubKeys)
.Distinct() .Distinct()
.Where(k => !k.StartsWith("group:") || memberKeys.Contains(k)) .Where(k => !k.StartsWith("group:") || memberKeys.Contains(k))
.Where(k => ClubIdFromKey(k) == 0 || activeClubKeys.Contains(k))
.ToList(); .ToList();
if (keys.Count == 0) return Ok(Array.Empty<object>()); if (keys.Count == 0) return Ok(Array.Empty<object>());
@@ -83,6 +119,7 @@ public class ChatController(
.Where(m => m.PlayerId == Me && keys.Contains(m.ThreadKey)) .Where(m => m.PlayerId == Me && keys.Contains(m.ThreadKey))
.ToDictionaryAsync(m => m.ThreadKey, m => m); .ToDictionaryAsync(m => m.ThreadKey, m => m);
var participants = await LoadParticipantsAsync(keys); var participants = await LoadParticipantsAsync(keys);
var favorites = await LoadFavoritesAsync(keys);
return Ok(latest return Ok(latest
.OrderByDescending(c => c.SentAt) .OrderByDescending(c => c.SentAt)
@@ -95,7 +132,8 @@ public class ChatController(
meta, meta,
member, member,
playerIds ?? new List<int>(), playerIds ?? new List<int>(),
latestMessage: c); latestMessage: c,
isFavorited: favorites.Contains(c.ThreadKey));
}) })
.ToList()); .ToList());
} }
@@ -119,11 +157,83 @@ public class ChatController(
.FirstOrDefaultAsync(m => m.ThreadKey == key && m.PlayerId == Me); .FirstOrDefaultAsync(m => m.ThreadKey == key && m.PlayerId == Me);
var participants = await LoadParticipantsAsync(new[] { key }); var participants = await LoadParticipantsAsync(new[] { key });
participants.TryGetValue(key, out var playerIds); participants.TryGetValue(key, out var playerIds);
var favorites = await LoadFavoritesAsync(new[] { key });
return Ok(ToWireThread( return Ok(ToWireThread(
meta, meta,
member, member,
playerIds ?? new List<int>(), playerIds ?? new List<int>(),
messages: rows.OrderBy(c => c.SentAt).ToList())); messages: rows.OrderBy(c => c.SentAt).ToList(),
isFavorited: favorites.Contains(key)));
}
/// <summary>GET <c>thread/club/{clubId}</c> — the 2023-03-21 club
/// chat tab's only entry point. Binary evidence: RecNet.Runtime
/// <c>DLDKCILCKNA.txt</c> method <c>BOOOAPKCCJI(Int64)</c>, instr
/// 045 <c>Move rcx, "thread/club/{0}"</c>, verb constant
/// <c>Move rdx, 0</c> at instr 054 = HTTPMethods.Get, query fields
/// <c>maxCount</c> (instr 067, default 10) and <c>mode</c> (instr
/// 079, default 0). The declared return type is
/// <c>FGLDKEJLAKB&lt;List&lt;JNMKLHDFPOJ&gt;&gt;</c>, so the body
/// must be a JSON ARRAY of chat threads even though a club has
/// exactly one channel — hence the one-element array.
///
/// The three-segment path matched no template before, so this 404'd
/// and club chat could not open at all.
///
/// <paramref name="mode"/> is accepted and ignored: the client
/// always sends 0 and its semantics are not resolvable from the
/// ISIL (no other value is ever passed).</summary>
[HttpGet("/thread/club/{clubId:long}")]
public async Task<IActionResult> GetClubThreads(long clubId,
[FromQuery] int maxCount = 10,
[FromQuery] int mode = 0)
{
_ = mode;
var take = Math.Clamp(maxCount, 1, 200);
var club = await db.Clubs.FirstOrDefaultAsync(c => c.Id == clubId);
if (club is null) return Ok(Array.Empty<object>());
// Only roster members see the club channel. Non-members get an
// empty array rather than a 403 — the client deserialises the
// body as a list before it ever looks at the status code.
var onRoster = await db.ClubMemberships.AnyAsync(m =>
m.ClubId == clubId && m.PlayerId == Me &&
(m.Permissions & ClubPendingFlag) == 0 &&
(m.Permissions & ClubBannedFlag) == 0);
if (!onRoster) return Ok(Array.Empty<object>());
var key = ClubKey(clubId);
var meta = await EnsureThreadRowAsync(key);
// Name the thread after the club the first time it materialises
// so the thread-list card has a label without a second fetch.
if (string.IsNullOrEmpty(meta.Name) && !string.IsNullOrEmpty(club.Name))
{
meta.Name = club.Name.Length > 128 ? club.Name[..128] : club.Name;
meta.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
}
var rows = await db.ChatMessages
.Where(c => c.ThreadKey == key)
.OrderByDescending(c => c.SentAt)
.Take(take)
.ToListAsync();
var member = await db.ChatThreadMembers
.FirstOrDefaultAsync(m => m.ThreadKey == key && m.PlayerId == Me);
var participants = await LoadParticipantsAsync(new[] { key });
participants.TryGetValue(key, out var playerIds);
var favorites = await LoadFavoritesAsync(new[] { key });
return Ok(new List<Dictionary<string, object?>>
{
ToWireThread(
meta,
member,
playerIds ?? new List<int>(),
messages: rows.OrderBy(c => c.SentAt).ToList(),
isFavorited: favorites.Contains(key)),
});
} }
/// <summary>Recent messages for a thread, paginated by /// <summary>Recent messages for a thread, paginated by
@@ -228,20 +338,8 @@ public class ChatController(
// the sender's chat UI doesn't show its own outgoing message // the sender's chat UI doesn't show its own outgoing message
// until the next thread refresh. DM keys encode the two // until the next thread refresh. DM keys encode the two
// participants directly; group chat threads fan out across the // participants directly; group chat threads fan out across the
// explicit membership table. // explicit membership table; club chats fan out over the roster.
var recipients = new List<long>(); var recipients = await ThreadRecipientsAsync(key);
if (key.StartsWith("dm:") && TryParseDmKey(key, out var a, out var b))
{
var other = a == Me ? b : a;
recipients.Add(other);
}
else if (key.StartsWith("group:"))
{
recipients = await db.ChatThreadMembers
.Where(m => m.ThreadKey == key && m.PlayerId != Me)
.Select(m => m.PlayerId)
.ToListAsync();
}
recipients.Add(Me); recipients.Add(Me);
foreach (var rid in recipients) foreach (var rid in recipients)
{ {
@@ -341,7 +439,11 @@ public class ChatController(
public async Task<IActionResult> Leave(string chatThreadId) public async Task<IActionResult> Leave(string chatThreadId)
{ {
chatThreadId = await ResolveThreadKeyAsync(chatThreadId); chatThreadId = await ResolveThreadKeyAsync(chatThreadId);
if (chatThreadId.StartsWith("dm:")) // DMs can't be left, and neither can club chats — club channel
// membership is the club roster, so "leaving" is leaving the
// club. Deleting the row here would only discard the caller's
// last-read pointer.
if (chatThreadId.StartsWith("dm:") || ClubIdFromKey(chatThreadId) != 0)
return Ok(ChatInvalidArguments); return Ok(ChatInvalidArguments);
var removed = await db.ChatThreadMembers var removed = await db.ChatThreadMembers
.Where(m => m.ThreadKey == chatThreadId && m.PlayerId == Me) .Where(m => m.ThreadKey == chatThreadId && m.PlayerId == Me)
@@ -349,24 +451,40 @@ public class ChatController(
return Ok(removed > 0 ? ChatSuccess : ChatMembershipNotFound); return Ok(removed > 0 ? ChatSuccess : ChatMembershipNotFound);
} }
public sealed record RenameRequest(string Name); /// <summary>Rename payload. A plain class with a parameterless ctor
/// (not a positional record) because
/// <see cref="Binding.FormOrJsonModelBinder"/> constructs it with
/// <c>Activator.CreateInstance</c> and then sets properties.</summary>
public sealed class RenameRequest
{
public string? Name { get; set; }
}
/// <summary>Set the thread's display name. Permission: any member /// <summary>Set the thread's display name. Permission: any member
/// of the thread can rename. For DMs the watch typically nudges /// of the thread can rename. For DMs the watch typically nudges
/// users to pick a nickname after first send; group-chat creators /// users to pick a nickname after first send; group-chat creators
/// usually set the name at creation time.</summary> /// usually set the name at creation time.
///
/// The 2023-03-21 client sends this as a form POST, not a JSON PUT:
/// RecNet.Runtime <c>DLDKCILCKNA.txt</c> method
/// <c>BKNMPEEMNPB(Int64, String)</c>, instr 122
/// <c>Move rcx, "thread/{0}/rename"</c> with verb constant
/// <c>Move rdx, 2</c> at instr 131 (= HTTPMethods.Post) and a single
/// form field <c>name</c> added at instr 145 via
/// <c>BNDIAONDFFF.AFGEDDANEKP</c>. PUT-only + <c>[FromBody]</c> gave
/// that client a 405 (and a 415 even if it had used PUT). The
/// response is consumed by an <c>Action&lt;MGGNHLHPHOF&gt;</c>
/// (instr 162) — a BARE integer body, not a wrapper object.</summary>
[HttpPut("/thread/{chatThreadId}/rename")] [HttpPut("/thread/{chatThreadId}/rename")]
public async Task<IActionResult> Rename(string chatThreadId, [FromBody] RenameRequest body) [HttpPost("/thread/{chatThreadId}/rename")]
public async Task<IActionResult> Rename(string chatThreadId,
[ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))] RenameRequest body)
{ {
chatThreadId = await ResolveThreadKeyAsync(chatThreadId); chatThreadId = await ResolveThreadKeyAsync(chatThreadId);
var name = (body.Name ?? string.Empty).Trim(); var name = (body?.Name ?? string.Empty).Trim();
if (name.Length > 128) name = name[..128]; if (name.Length > 128) name = name[..128];
var iAmMember = chatThreadId.StartsWith("dm:") if (!await IsThreadMemberAsync(chatThreadId)) return Ok(ChatMembershipNotFound);
? IsDmParticipant(chatThreadId, Me)
: await db.ChatThreadMembers
.AnyAsync(m => m.ThreadKey == chatThreadId && m.PlayerId == Me);
if (!iAmMember) return Ok(ChatMembershipNotFound);
var meta = await db.ChatThreads.FirstOrDefaultAsync(t => t.ThreadKey == chatThreadId); var meta = await db.ChatThreads.FirstOrDefaultAsync(t => t.ThreadKey == chatThreadId);
if (meta is null) if (meta is null)
@@ -384,6 +502,112 @@ public class ChatController(
return Ok(ChatSuccess); return Ok(ChatSuccess);
} }
/// <summary>Favourite payload — plain class for the same
/// <see cref="Binding.FormOrJsonModelBinder"/> reason as
/// <see cref="RenameRequest"/>.</summary>
public sealed class FavoriteRequest
{
public bool Favorite { get; set; }
}
/// <summary>Star / unstar a chat thread for the caller.
///
/// Binary evidence: RecNet.Runtime <c>DLDKCILCKNA.txt</c> method
/// <c>DHKDPHCNLKD(Int64, Boolean)</c> — instr 106
/// <c>Move rcx, "thread/{0}/favorite"</c>, verb constant
/// <c>Move rdx, 3</c> at instr 115 (= HTTPMethods.Put), single form
/// field <c>favorite</c> (Boolean, boxed at instr 122, added at
/// instr 128). No route existed at all, so this 404'd.
///
/// RESPONSE SHAPE: the continuation at instr 142 is
/// <c>Func&lt;GLIKNMPPEHL, MGGNHLHPHOF&gt;</c> — a client-side
/// PROJECTION, so the body is the wrapper object
/// <c>GLIKNMPPEHL</c>, NOT the bare chat-result int the method's
/// return type suggests. Its reader (<c>OGCMIDEFOEE.txt</c> instrs
/// 042/069) accepts exactly two keys, each in the usual three
/// casings: <c>ChatResult</c> and <c>IsFavorited</c>.
///
/// The flag is per-(player, thread) and there is no column for it on
/// <see cref="ChatThreadMemberEntity"/>, so it is persisted in the
/// generic <c>PlayerSettings</c> table under
/// <c>chat.favorite:&lt;threadKey&gt;</c> and read back into the
/// thread DTO's <c>IsFavorited</c> by
/// <see cref="LoadFavoritesAsync"/>.</summary>
[HttpPut("/thread/{chatThreadId}/favorite")]
[HttpPost("/thread/{chatThreadId}/favorite")]
public async Task<IActionResult> Favorite(string chatThreadId,
[ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))] FavoriteRequest body)
{
var key = await ResolveThreadKeyAsync(chatThreadId);
if (!await db.ChatThreads.AnyAsync(t => t.ThreadKey == key) &&
!await db.ChatMessages.AnyAsync(c => c.ThreadKey == key))
{
return Ok(new Dictionary<string, object?>
{
["chatResult"] = ChatThreadNotFound,
["isFavorited"] = false,
});
}
if (!await IsThreadMemberAsync(key))
{
return Ok(new Dictionary<string, object?>
{
["chatResult"] = ChatMembershipNotFound,
["isFavorited"] = false,
});
}
var favorite = body?.Favorite ?? false;
var settingKey = FavoriteSettingKey(key);
var row = await db.PlayerSettings
.FirstOrDefaultAsync(s => s.PlayerId == Me && s.Key == settingKey);
if (favorite)
{
if (row is null)
{
db.PlayerSettings.Add(new PlayerSettingEntity
{
PlayerId = Me,
Key = settingKey,
Value = "1",
});
}
else
{
row.Value = "1";
}
}
else if (row is not null)
{
db.PlayerSettings.Remove(row);
}
await db.SaveChangesAsync();
return Ok(new Dictionary<string, object?>
{
["chatResult"] = ChatSuccess,
["isFavorited"] = favorite,
});
}
/// <summary>Is the caller a participant of this thread? DM keys
/// carry both ids, group chats use the membership table, club chats
/// use the club roster.</summary>
private async Task<bool> IsThreadMemberAsync(string key)
{
if (key.StartsWith("dm:")) return IsDmParticipant(key, Me);
var clubId = ClubIdFromKey(key);
if (clubId != 0)
{
return await db.ClubMemberships.AnyAsync(m =>
m.ClubId == clubId && m.PlayerId == Me &&
(m.Permissions & ClubPendingFlag) == 0 &&
(m.Permissions & ClubBannedFlag) == 0);
}
return await db.ChatThreadMembers
.AnyAsync(m => m.ThreadKey == key && m.PlayerId == Me);
}
public sealed record CreateGroupChatRequest(List<long> MemberIds, string? Name); public sealed record CreateGroupChatRequest(List<long> MemberIds, string? Name);
/// <summary>Create a group chat. Generates a fresh /// <summary>Create a group chat. Generates a fresh
@@ -456,7 +680,10 @@ public class ChatController(
.ToListAsync(); .ToListAsync();
var meta = await EnsureThreadRowAsync(key); var meta = await EnsureThreadRowAsync(key);
var participants = ids.Concat(new[] { Me }).Distinct().Select(x => (int)x).ToList(); var participants = ids.Concat(new[] { Me }).Distinct().Select(x => (int)x).ToList();
return Ok(ToWireThread(meta, null, participants, messages: rows.OrderBy(c => c.SentAt).ToList())); var favorites = await LoadFavoritesAsync(new[] { key });
return Ok(ToWireThread(meta, null, participants,
messages: rows.OrderBy(c => c.SentAt).ToList(),
isFavorited: favorites.Contains(key)));
} }
/// <summary>Form-urlencoded variant of <see cref="Send"/> used by /// <summary>Form-urlencoded variant of <see cref="Send"/> used by
@@ -491,19 +718,7 @@ public class ChatController(
// Notify every participant, sender INCLUDED — see SendMessageJson // Notify every participant, sender INCLUDED — see SendMessageJson
// above for the rationale; the 2020 watch's chat send path needs // above for the rationale; the 2020 watch's chat send path needs
// the self-push to refresh its own UI. // the self-push to refresh its own UI.
var recipients = new List<long>(); var recipients = await ThreadRecipientsAsync(key);
if (key.StartsWith("dm:") && TryParseDmKey(key, out var a, out var b))
{
var other = a == Me ? b : a;
if (other != Me) recipients.Add(other);
}
else if (key.StartsWith("group:"))
{
recipients = await db.ChatThreadMembers
.Where(m => m.ThreadKey == key && m.PlayerId != Me)
.Select(m => m.PlayerId)
.ToListAsync();
}
recipients.Add(Me); recipients.Add(Me);
foreach (var rid in recipients) foreach (var rid in recipients)
{ {
@@ -513,9 +728,11 @@ public class ChatController(
} }
var participants = await LoadParticipantsAsync(new[] { key }); var participants = await LoadParticipantsAsync(new[] { key });
participants.TryGetValue(key, out var playerIds); participants.TryGetValue(key, out var playerIds);
var favorites = await LoadFavoritesAsync(new[] { key });
return Ok(new Dictionary<string, object?> return Ok(new Dictionary<string, object?>
{ {
["chatThread"] = ToWireThread(thread, null, playerIds ?? new List<int>(), latestMessage: entry), ["chatThread"] = ToWireThread(thread, null, playerIds ?? new List<int>(),
latestMessage: entry, isFavorited: favorites.Contains(key)),
["chatResult"] = ChatSuccess, ["chatResult"] = ChatSuccess,
}); });
} }
@@ -545,19 +762,7 @@ public class ChatController(
db.ChatMessages.Add(entry); db.ChatMessages.Add(entry);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
var recipients = new List<long>(); var recipients = await ThreadRecipientsAsync(key);
if (key.StartsWith("dm:") && TryParseDmKey(key, out var a, out var b))
{
var other = a == Me ? b : a;
if (other != Me) recipients.Add(other);
}
else if (key.StartsWith("group:"))
{
recipients = await db.ChatThreadMembers
.Where(m => m.ThreadKey == key && m.PlayerId != Me)
.Select(m => m.PlayerId)
.ToListAsync();
}
// Include the sender so the form-POST send path also refreshes // Include the sender so the form-POST send path also refreshes
// its own chat thread — same reason as the JSON send path above. // its own chat thread — same reason as the JSON send path above.
recipients.Add(Me); recipients.Add(Me);
@@ -761,15 +966,109 @@ public class ChatController(
result[group.Key] = group.Select(m => (int)m.PlayerId).Distinct().ToList(); result[group.Key] = group.Select(m => (int)m.PlayerId).Distinct().ToList();
} }
// Club threads have no ChatThreadMembers rows — membership IS
// the club roster, so joining/leaving the club joins/leaves the
// chat automatically.
var clubIds = distinct
.Select(k => new { Key = k, ClubId = ClubIdFromKey(k) })
.Where(x => x.ClubId != 0)
.ToList();
if (clubIds.Count > 0)
{
var ids = clubIds.Select(x => x.ClubId).Distinct().ToList();
var roster = await db.ClubMemberships
.Where(m => ids.Contains(m.ClubId) &&
(m.Permissions & ClubPendingFlag) == 0 &&
(m.Permissions & ClubBannedFlag) == 0)
.Select(m => new { m.ClubId, m.PlayerId })
.ToListAsync();
foreach (var x in clubIds)
{
result[x.Key] = roster
.Where(m => m.ClubId == x.ClubId)
.Select(m => (int)m.PlayerId)
.Distinct()
.ToList();
}
}
return result; return result;
} }
/// <summary>PlayerSettings key holding the caller's per-thread
/// favourite flag. The flag is per-(player, thread) and there is no
/// column for it on <see cref="ChatThreadMemberEntity"/>, so it
/// lives in the same generic per-player settings table the rest of
/// the server uses for exactly this kind of flag (see
/// RoomsController's playerdata and InventionsController).</summary>
private const string FavoritePrefix = "chat.favorite:";
private static string FavoriteSettingKey(string threadKey) => FavoritePrefix + threadKey;
/// <summary>Thread keys the caller has starred, out of
/// <paramref name="keys"/>.</summary>
private async Task<HashSet<string>> LoadFavoritesAsync(IEnumerable<string> keys)
{
var distinct = keys.Distinct().ToList();
if (distinct.Count == 0) return new HashSet<string>();
var settingKeys = distinct.Select(FavoriteSettingKey).ToList();
var rows = await db.PlayerSettings
.AsNoTracking()
.Where(s => s.PlayerId == Me && settingKeys.Contains(s.Key) && s.Value == "1")
.Select(s => s.Key)
.ToListAsync();
return rows.Select(k => k[FavoritePrefix.Length..]).ToHashSet();
}
/// <summary>Players to push a new message to. DM keys carry both
/// participants; group chats fan out over the membership table;
/// club chats fan out over the club roster.</summary>
private async Task<List<long>> ThreadRecipientsAsync(string key)
{
if (key.StartsWith("dm:") && TryParseDmKey(key, out var a, out var b))
{
var other = a == Me ? b : a;
return other == Me ? new List<long>() : new List<long> { other };
}
if (key.StartsWith("group:"))
{
return await db.ChatThreadMembers
.Where(m => m.ThreadKey == key && m.PlayerId != Me)
.Select(m => m.PlayerId)
.ToListAsync();
}
var clubId = ClubIdFromKey(key);
if (clubId != 0)
{
return await db.ClubMemberships
.Where(m => m.ClubId == clubId && m.PlayerId != Me &&
(m.Permissions & ClubPendingFlag) == 0 &&
(m.Permissions & ClubBannedFlag) == 0)
.Select(m => m.PlayerId)
.Distinct()
.ToListAsync();
}
return new List<long>();
}
/// <summary>Project a thread row onto the client's chat-thread DTO.
/// The 2023-03-21 reader (RecNet.Runtime EKEFFNBIOHJ.txt, instrs
/// 082/109/133/149/173/197/221/245/269) matches exactly nine keys,
/// each in PascalCase / camelCase / lowercase:
/// <c>ChatThreadId, LastReadMessageId, Messages, LatestMessage,
/// PlayerIds, ChatThreadName, SnoozedUntil, IsFavorited, ClubId</c>.
/// <c>IsFavorited</c> and <c>ClubId</c> did not exist on the 2020
/// watch's DTO, so they were missing here; both are emitted now.
/// <c>LatestMessage</c> is emitted alongside <c>Messages</c> because
/// the thread-list card binds the preview off it even when the full
/// message array is present.</summary>
private static Dictionary<string, object?> ToWireThread( private static Dictionary<string, object?> ToWireThread(
ChatThreadEntity thread, ChatThreadEntity thread,
ChatThreadMemberEntity? member, ChatThreadMemberEntity? member,
List<int> playerIds, List<int> playerIds,
ChatMessageEntity? latestMessage = null, ChatMessageEntity? latestMessage = null,
List<ChatMessageEntity>? messages = null) List<ChatMessageEntity>? messages = null,
bool isFavorited = false)
{ {
var dto = new Dictionary<string, object?> var dto = new Dictionary<string, object?>
{ {
@@ -778,11 +1077,15 @@ public class ChatController(
["lastReadMessageId"] = member?.LastReadMessageId ?? 0, ["lastReadMessageId"] = member?.LastReadMessageId ?? 0,
["snoozedUntil"] = member?.SnoozeUntil, ["snoozedUntil"] = member?.SnoozeUntil,
["playerIds"] = playerIds, ["playerIds"] = playerIds,
["isFavorited"] = isFavorited,
["clubId"] = ClubIdFromKey(thread.ThreadKey),
}; };
if (messages is not null) if (messages is not null)
{ {
dto["messages"] = messages.Select(m => ToWireMessage(m, thread.Id)).ToList(); dto["messages"] = messages.Select(m => ToWireMessage(m, thread.Id)).ToList();
if (messages.Count > 0)
dto["latestMessage"] = ToWireMessage(messages[^1], thread.Id);
} }
else if (latestMessage is not null) else if (latestMessage is not null)
{ {
@@ -792,6 +1095,18 @@ public class ChatController(
return dto; return dto;
} }
/// <summary>Canonical thread key for a club's chat channel. One
/// thread per club — the 2023 client's club chat tab is a single
/// channel, and <c>GET thread/club/{id}</c> returns it wrapped in a
/// one-element array.</summary>
private static string ClubKey(long clubId) => $"club:{clubId}";
/// <summary>Inverse of <see cref="ClubKey"/>; 0 for non-club
/// threads, which is what the client's <c>ClubId</c> field carries
/// for DMs and group chats.</summary>
private static long ClubIdFromKey(string key) =>
key.StartsWith("club:") && long.TryParse(key.AsSpan(5), out var id) ? id : 0;
private static Dictionary<string, object?> ToWireMessage(ChatMessageEntity c, long chatThreadId) => new() private static Dictionary<string, object?> ToWireMessage(ChatMessageEntity c, long chatThreadId) => new()
{ {
["chatMessageId"] = c.Id, ["chatMessageId"] = c.Id,
@@ -18,13 +18,33 @@ namespace DorkNet.Server.Controllers.Clubs;
/// URL surface (verb + template) verified against /// URL surface (verb + template) verified against
/// <c>Cpp2IL_ISIL/.../JDJGIBLMFKK.txt</c>: /// <c>Cpp2IL_ISIL/.../JDJGIBLMFKK.txt</c>:
/// GET /club/home/me /// GET /club/home/me
/// PUT /club/home/me (2023, form clubId)
/// DELETE /club/home/me (2023)
/// GET /club/mine/member /// GET /club/mine/member
/// GET /club/mine/created /// GET /club/mine/created
/// GET /club/categoryTags /// GET /club/categoryTags
/// DELETE /club/{clubId} (2023, disband)
/// GET /club/{clubId}/hasDisabledClubChat (2023, bare bool)
/// DELETE /club/{clubId}/mainimage (2023)
/// GET /club/{clubId}/members (2023, bare array)
/// GET /club/{clubId}/members/banned (2023, bare array)
/// GET /club/{clubId}/members/requests (2023, bare array)
/// GET /club/{clubId}/members/search (2023, paged envelope)
/// GET /club/{clubId}/members/requests/search (2023, paged envelope)
/// GET /announcements/mine (2023, flat array)
/// GET /announcements/subscription/mine (2023, flat array)
/// GET /announcements/v2/mine/unread /// GET /announcements/v2/mine/unread
/// GET /announcements/v2/subscription/mine/unread /// GET /announcements/v2/subscription/mine/unread
/// POST /announcements/club/{clubId}/{announcementId}/read /// POST /announcements/club/{clubId}/{announcementId}/read
/// ///
/// Still unserved for want of schema (see the per-route notes in
/// <c>docs/recroom-2023-client-api-complete.md</c>): PUT
/// <c>club/{id}/minlevel</c>, PUT <c>club/{id}/clubChatEnabled</c>, PUT
/// <c>club/{id}/permissions/{role}</c> and DELETE
/// <c>club/{id}/additionalimage/{slot}</c> — each needs storage that does
/// not exist yet (ClubEntity.MinLevel, ClubEntity.ClubChatEnabled, a
/// per-role club-permission table, a club additional-image table).
///
/// Wire types (deserialiser JSON keys, per ISIL): /// Wire types (deserialiser JSON keys, per ISIL):
/// Club (2020 <c>PLILLKHMNDA</c> / 2023 <c>FOIJDINBPFG</c>): /// Club (2020 <c>PLILLKHMNDA</c> / 2023 <c>FOIJDINBPFG</c>):
/// ClubId, Name, Description, MainImageName, State, /// ClubId, Name, Description, MainImageName, State,
@@ -67,6 +87,50 @@ public class ClubsController(ClubService clubs, DorkNetDbContext db) : Controlle
return Ok(ToWireClub(home, memberCount)); return Ok(ToWireClub(home, memberCount));
} }
/// <summary>PUT <c>/club/home/me</c> — "Set Home Clubhouse Room". The
/// 2023 client sends the target club in a form-urlencoded
/// <c>clubId</c> field (boxed <c>Nullable&lt;Int64&gt;</c> pushed through
/// <c>BNDIAONDFFF.AFGEDDANEKP</c> at <c>IKMMOCKDKAF.txt:16923-16927</c>)
/// and parses NO response DTO — the issuing method is
/// <c>LDGADANDBIO JOPMBFIFFBB(Nullable&lt;Int64&gt;)</c>, the status-only
/// promise type, so an empty 200 is the contract. Verb ordinal 3 (PUT)
/// is the HasValue branch of the same method at :16911; the null branch
/// is the DELETE below (:16838).</summary>
[HttpPut("/club/home/me")]
[Authorize]
public async Task<IActionResult> HomeMeSet()
{
var pid = Me;
long clubId = 0;
if (Request.HasFormContentType)
{
var form = await Request.ReadFormAsync();
foreach (var key in new[] { "clubId", "ClubId" })
if (long.TryParse(form[key].FirstOrDefault(), out var fromForm) && fromForm > 0)
{
clubId = fromForm;
break;
}
}
if (clubId == 0 && long.TryParse(Request.Query["clubId"].FirstOrDefault(), out var fromQuery))
clubId = fromQuery;
if (clubId <= 0) return BadRequest(new { error = "missing_club_id" });
if (!await clubs.SetHomeClubAsync(pid, clubId)) return NotFound();
return Ok();
}
/// <summary>DELETE <c>/club/home/me</c> — "Remove Home Clubhouse Room".
/// No body, no response DTO (<c>IKMMOCKDKAF.txt:16830-16841</c>: route
/// literal, host 13, verb ordinal 4, error string "Failed to Remove Home
/// Clubhouse Room"). Idempotent so a double-tap is harmless.</summary>
[HttpDelete("/club/home/me")]
[Authorize]
public async Task<IActionResult> HomeMeClear()
{
await clubs.ClearHomeClubAsync(Me);
return Ok();
}
[HttpGet("/club/mine/member")] [HttpGet("/club/mine/member")]
[Authorize] [Authorize]
public async Task<IActionResult> MineMember() public async Task<IActionResult> MineMember()
@@ -163,6 +227,35 @@ public class ClubsController(ClubService clubs, DorkNetDbContext db) : Controlle
return Ok(GroupByClub(rows, await LastReadByClubAsync(pid, rows.Select(a => a.ClubId)))); return Ok(GroupByClub(rows, await LastReadByClubAsync(pid, rows.Select(a => a.ClubId))));
} }
/// <summary>GET <c>/announcements/mine</c> — every announcement across the
/// clubs the caller is a member of, newest first. Distinct from the v2
/// unread rollup above in BOTH shape and semantics: the issuing method is
/// <c>FGLDKEJLAKB&lt;List&lt;JDPPAFLFNBD&gt;&gt; IOCFHCFLIMJ()</c>
/// (<c>IKMMOCKDKAF.txt:996</c>), i.e. a FLAT array of Announcement objects
/// with no per-club envelope and no unread filter — the request literals
/// at :1075 carry the description "get all announcements for clubs I'm
/// in".</summary>
[HttpGet("/announcements/mine")]
[Authorize]
public async Task<IActionResult> AnnouncementsMine()
{
var rows = await clubs.AnnouncementsForMemberClubsAsync(Me);
return Ok(rows.Select(ToWireAnnouncement).ToList());
}
/// <summary>GET <c>/announcements/subscription/mine</c> — same flat
/// <c>List&lt;JDPPAFLFNBD&gt;</c> shape as
/// <see cref="AnnouncementsMine"/> (<c>IIOMOLFOLPD</c> at
/// <c>IKMMOCKDKAF.txt:1083</c>, route literal at :1162) but scoped to the
/// clubs the caller SUBSCRIBES to rather than belongs to.</summary>
[HttpGet("/announcements/subscription/mine")]
[Authorize]
public async Task<IActionResult> AnnouncementsSubscriptionMine()
{
var rows = await clubs.AnnouncementsForSubscribedClubsAsync(Me);
return Ok(rows.Select(ToWireAnnouncement).ToList());
}
[HttpGet("/announcements/v2/subscription/mine/unread")] [HttpGet("/announcements/v2/subscription/mine/unread")]
[Authorize] [Authorize]
public async Task<IActionResult> UnreadSubscription() public async Task<IActionResult> UnreadSubscription()
@@ -271,6 +364,50 @@ public class ClubsController(ClubService clubs, DorkNetDbContext db) : Controlle
return Ok(ToWireClub(club, members)); return Ok(ToWireClub(club, members));
} }
/// <summary>DELETE <c>/club/{id}</c> — "Delete Club" (disband). Issuing
/// method is <c>LDGADANDBIO MACDNDJNODA(Int64, String)</c> — the string is
/// the club NAME used only for the client-side confirmation copy and is
/// never transmitted; the request is a bare DELETE with no body
/// (<c>IKMMOCKDKAF.txt:17107-17120</c>, verb ordinal 4, host 13, error
/// string "Failed to Delete Club"). <c>LDGADANDBIO</c> is the status-only
/// promise so an empty 200 is the whole contract.
///
/// Owner-only, and a soft delete — see
/// <see cref="ClubService.DisbandAsync"/>.</summary>
[HttpDelete("/club/{clubId:long}")]
[Authorize]
public async Task<IActionResult> ClubDelete(long clubId)
{
try
{
var disbanded = await clubs.DisbandAsync(clubId, Me);
if (disbanded is null) return NotFound();
return Ok();
}
catch (UnauthorizedAccessException) { return Forbid(); }
}
/// <summary>GET <c>/club/{id}/hasDisabledClubChat</c> — the gate the club
/// chat flow checks before it will even request the club's thread. The
/// issuing method is <c>FGLDKEJLAKB&lt;System.Boolean&gt; OFOJAHBKMOJ(Int64)</c>
/// (<c>IKMMOCKDKAF.txt:25422</c>, route + verb ordinal 0 at :25540-25547),
/// so the body is a BARE JSON boolean — not an object, not a wrapper.
///
/// The answer is the negation of the Club wire field
/// <c>ClubChatEnabled</c> that <see cref="ToWireClub"/> emits. That field
/// has no column on <see cref="ClubEntity"/> yet and its setter
/// (<c>PUT club/{id}/clubChatEnabled</c>) is therefore not implemented, so
/// no club can currently be in the disabled state and <c>false</c> is the
/// true answer for every club. Both sites must flip together when the
/// column lands.</summary>
[HttpGet("/club/{clubId:long}/hasDisabledClubChat")]
public async Task<IActionResult> ClubHasDisabledChat(long clubId)
{
var club = await clubs.GetByIdAsync(clubId);
if (club is null) return NotFound();
return Ok(false);
}
/// <summary>GET <c>/club/{id}/details</c> — the central club page /// <summary>GET <c>/club/{id}/details</c> — the central club page
/// payload. Wire shape is <c>PIHMJGCGNLP</c> with required keys /// payload. Wire shape is <c>PIHMJGCGNLP</c> with required keys
/// <c>Club</c>, <c>CoownerPermissions</c>, <c>ModeratorPermissions</c>, /// <c>Club</c>, <c>CoownerPermissions</c>, <c>ModeratorPermissions</c>,
@@ -461,6 +598,32 @@ public class ClubsController(ClubService clubs, DorkNetDbContext db) : Controlle
catch (UnauthorizedAccessException) { return Forbid(); } catch (UnauthorizedAccessException) { return Forbid(); }
} }
/// <summary>DELETE <c>/club/{id}/mainimage</c> — "Remove Club Image".
/// One method, <c>BLEDOCHHHJM(Int64, String imageName)</c>, drives both
/// verbs: <c>IKMMOCKDKAF.txt:16010</c> tests the captured imageName and
/// branches to verb ordinal 4 with no body when it is null (:16019-16024,
/// description "Remove Club Image") or verb 3 with the form field when it
/// is not (:16046-16050). Both branches go through
/// <c>IBAKMFKEEDJ</c>, so the response is the full <c>LCLFBBPEMIH</c>
/// details envelope, NOT a status-only 200.
///
/// Registered as its own action rather than a third binding on
/// <see cref="ClubMainImage"/> so the delete semantics are explicit —
/// aliasing an edit verb onto a destructive handler is the exact bug that
/// made <c>PUT /announcements/club/{c}/{a}</c> destroy posts.</summary>
[HttpDelete("/club/{clubId:long}/mainimage")]
[Authorize]
public async Task<IActionResult> ClubMainImageDelete(long clubId)
{
try
{
var updated = await clubs.ModifyAsync(clubId, Me, c => c.ImageName = string.Empty);
if (updated is null) return NotFound();
return await BuildDetailsResponseAsync(updated.Id);
}
catch (UnauthorizedAccessException) { return Forbid(); }
}
/// <summary>POST/PUT <c>/club/{id}/additionalimage/{slot}</c> — the club /// <summary>POST/PUT <c>/club/{id}/additionalimage/{slot}</c> — the club
/// gallery's per-slot image setter (<c>IKMMOCKDKAF.txt:16214</c>). /// gallery's per-slot image setter (<c>IKMMOCKDKAF.txt:16214</c>).
/// ///
@@ -496,6 +659,332 @@ public class ClubsController(ClubService clubs, DorkNetDbContext db) : Controlle
return Ok(ToWireMembership(row)); return Ok(ToWireMembership(row));
} }
// ── Member lists (roster / banned / requests, + their searches) ──
/// <summary>GET <c>/club/{id}/members</c> — the club page's member roster.
/// Issuing method is
/// <c>FGLDKEJLAKB&lt;List&lt;CADEIMCFIIG&gt;&gt; GFOBOIIGDJF(Int64, GNLOJEONFIG?, MJOOBDNCHBO?, Int32?, Int32?)</c>
/// (<c>IKMMOCKDKAF.txt:19444</c>) so the body is a bare JSON ARRAY of the
/// same membership row <see cref="ClubMember"/> already serves — no paged
/// envelope. Verb ordinal 0 (GET) at :19746, query keys verbatim from
/// :19753/:19777/:19809/:19822: <c>membershipType</c>, <c>sortBy</c>,
/// <c>skip</c>, <c>take</c>.
///
/// <c>membershipType</c> is the MJOOBDNCHBO wire value, not the stored
/// perms int, so it is compared after
/// <see cref="ClubService.MembershipTypeFromPerms"/>. With no filter the
/// roster is real members only — ban markers and pending rows have their
/// own endpoints below and must not leak into the member list.</summary>
[HttpGet("/club/{clubId:long}/members")]
public async Task<IActionResult> ClubMembers(
[FromRoute] long clubId,
[FromQuery] int? membershipType,
[FromQuery] int? sortBy,
[FromQuery] int? skip,
[FromQuery] int? take)
{
var club = await clubs.GetByIdAsync(clubId);
if (club is null) return NotFound();
var rows = await LoadMemberRowsAsync(clubId);
rows = membershipType is int wanted
? rows.Where(r => r.MembershipType == wanted).ToList()
: rows.Where(r => r.MembershipType >= ClubService.MembershipTypeMember).ToList();
return Ok(Page(SortMembers(rows, sortBy ?? 0), skip ?? 0, take ?? 0)
.Select(r => ToWireMembership(r.Row))
.ToList());
}
/// <summary>GET <c>/club/{id}/members/banned</c> — the banned list.
/// <c>MBKDPCIOMDD(Int64, GNLOJEONFIG?, Int32?, Int32?)</c> returns
/// <c>List&lt;ICPNBOOIDLI&gt;</c> (<c>IKMMOCKDKAF.txt:18692</c>): a bare
/// array whose rows carry only <c>AccountId</c> (int), <c>ClubId</c>
/// (long) and <c>CreatedAt</c> — there is no MembershipType field on
/// ICPNBOOIDLI (recnet-runtime-decomp/ICPNBOOIDLI.cs). GET at :18978,
/// query keys <c>sortBy</c>/<c>skip</c>/<c>take</c> at :18985/:19016/:19028.
///
/// Rows are the perms=256 ban markers stamped by
/// <see cref="MemberBan"/>. Moderator-gated: the ban list is staff-only
/// information.</summary>
[HttpGet("/club/{clubId:long}/members/banned")]
[Authorize]
public async Task<IActionResult> ClubMembersBanned(
[FromRoute] long clubId,
[FromQuery] int? sortBy,
[FromQuery] int? skip,
[FromQuery] int? take)
{
var club = await clubs.GetByIdAsync(clubId);
if (club is null) return NotFound();
if (!await clubs.CanManageAsync(clubId, Me, club)) return Forbid();
var rows = (await LoadMemberRowsAsync(clubId))
.Where(r => (r.Row.Permissions & 256) != 0)
.ToList();
return Ok(Page(SortMembers(rows, sortBy ?? 0), skip ?? 0, take ?? 0)
.Select(r => ToWireBan(r.Row))
.ToList());
}
/// <summary>GET <c>/club/{id}/members/requests</c> — pending join
/// requests / outstanding invites.
/// <c>GBJMBMCGCDL(Int64, Int32?, Int32?)</c> returns
/// <c>List&lt;AADIHDCMEDB&gt;</c> (<c>IKMMOCKDKAF.txt:21086</c>) — a bare
/// array, GET at :21325, only <c>skip</c> + <c>take</c> query keys
/// (:21338, :21350). Error copy "Failed to get pending club member
/// requests" at :21394 confirms the screen.</summary>
[HttpGet("/club/{clubId:long}/members/requests")]
[Authorize]
public async Task<IActionResult> ClubMemberRequests(
[FromRoute] long clubId,
[FromQuery] int? skip,
[FromQuery] int? take)
{
var club = await clubs.GetByIdAsync(clubId);
if (club is null) return NotFound();
if (!await clubs.CanManageAsync(clubId, Me, club)) return Forbid();
var rows = PendingRows(await LoadMemberRowsAsync(clubId));
return Ok(Page(SortMembers(rows, 1), skip ?? 0, take ?? 0)
.Select(r => ToWireMemberRequest(r.Row))
.ToList());
}
/// <summary>GET <c>/club/{id}/members/search</c> — the member-list search
/// box. <c>PGMGPEKCNJP</c> returns <c>MBMNHFAPFCJ</c>
/// (<c>IKMMOCKDKAF.txt:18108</c>), a PAGED envelope —
/// <c>List&lt;CADEIMCFIIG&gt;</c> + Int32 total + String continuation
/// token (recnet-runtime-decomp/MBMNHFAPFCJ.cs) — not the bare array the
/// unsearched roster returns. GET at :18475; query keys are DOT-PREFIXED
/// and must be read off <see cref="HttpRequest.Query"/> rather than bound
/// as properties: <c>parameters.name</c> (:18484),
/// <c>parameters.type</c> (:18492), <c>parameters.sortBy</c> (:18521),
/// <c>parameters.maxCount</c> (:18554), plus an unprefixed
/// <c>continuationToken</c> (:18563).
///
/// The envelope's own key names are the one thing the binary cannot give
/// up (they live in DataMember blobs in global-metadata.dat), so the
/// entity-named pair validated on the sibling <c>club/search</c> envelope
/// — <c>Clubs</c>/<c>TotalClubs</c>/<c>ContinuationToken</c> — is mirrored
/// here as <c>Members</c>/<c>TotalMembers</c>, with <c>Results</c>/
/// <c>TotalResults</c> alongside as the alternate spelling. Json.NET
/// ignores whichever pair the DTO doesn't declare.</summary>
[HttpGet("/club/{clubId:long}/members/search")]
public async Task<IActionResult> ClubMembersSearch(long clubId)
{
var club = await clubs.GetByIdAsync(clubId);
if (club is null) return NotFound();
var name = QueryValue("parameters.name", "name");
var type = QueryInt("parameters.type", "type", "membershipType");
var sortBy = QueryInt("parameters.sortBy", "sortBy");
var maxCount = QueryInt("parameters.maxCount", "maxCount", "take");
var offset = QueryInt("continuationToken") ?? 0;
var rows = await LoadMemberRowsAsync(clubId);
rows = type is int wanted
? rows.Where(r => r.MembershipType == wanted).ToList()
: rows.Where(r => r.MembershipType >= ClubService.MembershipTypeMember).ToList();
rows = FilterByName(rows, name);
var ordered = SortMembers(rows, sortBy ?? 0).ToList();
var (page, nextToken) = Slice(ordered, offset, maxCount);
return Ok(PagedEnvelope("Members", "TotalMembers",
page.Select(r => ToWireMembership(r.Row)).ToList(), ordered.Count, nextToken));
}
/// <summary>GET <c>/club/{id}/members/requests/search</c> — search inside
/// the pending-requests screen. <c>EDANLDCJECM</c> returns
/// <c>EDFOCLNECPM</c> (<c>IKMMOCKDKAF.txt:21448</c>): the paged envelope
/// over <c>List&lt;AADIHDCMEDB&gt;</c>. GET at :21812; dot-prefixed query
/// keys <c>parameters.name</c> (:21821), <c>parameters.sortBy</c>
/// (:21829, the NAHBJCGNJKA enum — Default=0, RequestDate_Asc=1,
/// RequestDate_Desc=2, Username_Asc=3, Username_Desc=4),
/// <c>parameters.maxCount</c> (:21862), <c>parameters.status</c> (:21867,
/// MDFFODMAIGJ — Invited=0, Requested=1, Denied=2) and unprefixed
/// <c>continuationToken</c> (:21896).
///
/// RequestDate_Asc/Desc index the same JoinedAt column JoinDate_Asc/Desc
/// do on the roster sort, so the two enums share
/// <see cref="SortMembers"/>. The <c>status</c> filter can only ever
/// match Requested — see <see cref="ToWireMemberRequest"/>.</summary>
[HttpGet("/club/{clubId:long}/members/requests/search")]
[Authorize]
public async Task<IActionResult> ClubMemberRequestsSearch(long clubId)
{
var club = await clubs.GetByIdAsync(clubId);
if (club is null) return NotFound();
if (!await clubs.CanManageAsync(clubId, Me, club)) return Forbid();
var name = QueryValue("parameters.name", "name");
var sortBy = QueryInt("parameters.sortBy", "sortBy");
var maxCount = QueryInt("parameters.maxCount", "maxCount", "take");
var status = QueryInt("parameters.status", "status");
var offset = QueryInt("continuationToken") ?? 0;
var rows = FilterByName(PendingRows(await LoadMemberRowsAsync(clubId)), name);
if (status is int wantedStatus)
rows = rows.Where(r => RequestStatusFor(r.Row) == wantedStatus).ToList();
var ordered = SortMembers(rows, sortBy ?? 0).ToList();
var (page, nextToken) = Slice(ordered, offset, maxCount);
return Ok(PagedEnvelope("Requests", "TotalRequests",
page.Select(r => ToWireMemberRequest(r.Row)).ToList(), ordered.Count, nextToken));
}
/// <summary>A membership row paired with the account's username, which
/// every list endpoint above needs for the Username_Asc/Desc sorts and
/// the <c>parameters.name</c> filter. Loaded once per request rather than
/// per row.</summary>
private sealed record MemberRow(ClubMembershipEntity Row, string Username)
{
public int MembershipType => ClubService.MembershipTypeFromPerms(Row.Permissions);
}
private async Task<List<MemberRow>> LoadMemberRowsAsync(long clubId)
{
var rows = await db.ClubMemberships.AsNoTracking()
.Where(m => m.ClubId == clubId)
.ToListAsync();
if (rows.Count == 0) return new List<MemberRow>();
var ids = rows.Select(r => r.PlayerId).Distinct().ToList();
var names = await db.Players.AsNoTracking()
.Where(p => ids.Contains(p.Id))
.Select(p => new { p.Id, p.Username, p.DisplayName })
.ToListAsync();
var byId = names.ToDictionary(
n => n.Id,
n => string.IsNullOrWhiteSpace(n.Username) ? n.DisplayName : n.Username);
return rows
.Select(r => new MemberRow(r, byId.GetValueOrDefault(r.PlayerId, string.Empty)))
.ToList();
}
/// <summary>Rows carrying the pending marker (128) and NOT the ban marker
/// — the join-request / invite queue.</summary>
private static List<MemberRow> PendingRows(List<MemberRow> rows) => rows
.Where(r => (r.Row.Permissions & 128) != 0 && (r.Row.Permissions & 256) == 0)
.ToList();
private static List<MemberRow> FilterByName(List<MemberRow> rows, string? name)
{
if (string.IsNullOrWhiteSpace(name)) return rows;
var needle = name.Trim();
return rows
.Where(r => r.Username.Contains(needle, StringComparison.OrdinalIgnoreCase))
.ToList();
}
/// <summary>Shared ordering for GNLOJEONFIG (roster/banned) and
/// NAHBJCGNJKA (requests) — both enums are Default=0, date asc=1,
/// date desc=2, Username_Asc=3, Username_Desc=4 over the same JoinedAt
/// column (recnet-runtime-decomp/GNLOJEONFIG.cs, NAHBJCGNJKA.cs).
/// Default puts the most privileged roles first, which is the order the
/// club page renders its roster in.</summary>
private static IEnumerable<MemberRow> SortMembers(IEnumerable<MemberRow> rows, int sortBy) => sortBy switch
{
1 => rows.OrderBy(r => r.Row.JoinedAt).ThenBy(r => r.Row.Id),
2 => rows.OrderByDescending(r => r.Row.JoinedAt).ThenByDescending(r => r.Row.Id),
3 => rows.OrderBy(r => r.Username, StringComparer.OrdinalIgnoreCase).ThenBy(r => r.Row.Id),
4 => rows.OrderByDescending(r => r.Username, StringComparer.OrdinalIgnoreCase).ThenBy(r => r.Row.Id),
_ => rows.OrderByDescending(r => r.MembershipType)
.ThenBy(r => r.Row.JoinedAt)
.ThenBy(r => r.Row.Id),
};
/// <summary>skip/take paging for the three bare-array lists. take=0 means
/// "unspecified" (the client omits the param), so it falls back to a
/// 100-row page rather than returning nothing.</summary>
private static List<MemberRow> Page(IEnumerable<MemberRow> rows, int skip, int take) => rows
.Skip(Math.Max(skip, 0))
.Take(take <= 0 ? 100 : Math.Clamp(take, 1, 500))
.ToList();
/// <summary>Continuation-token paging for the two search envelopes. The
/// token is opaque to the client (a String on the DTO), so it carries the
/// next row offset; an empty string means "no more pages", which is what
/// the sibling <c>club/search</c> envelope already returns.</summary>
private static (List<MemberRow> Page, string NextToken) Slice(
List<MemberRow> ordered, int offset, int? maxCount)
{
var start = Math.Clamp(offset, 0, ordered.Count);
var size = maxCount is int m && m > 0 ? Math.Clamp(m, 1, 500) : 30;
var page = ordered.Skip(start).Take(size).ToList();
var next = start + page.Count;
return (page, next < ordered.Count ? next.ToString() : string.Empty);
}
private static Dictionary<string, object?> PagedEnvelope(
string listKey, string totalKey, List<object> rows, int total, string continuationToken) =>
new()
{
[listKey] = rows,
["Results"] = rows,
[totalKey] = total,
["TotalResults"] = total,
["ContinuationToken"] = continuationToken,
};
/// <summary>First non-empty value across a set of query-string aliases.
/// The 2023 client prefixes the search DTO's fields with
/// <c>parameters.</c>, which ASP.NET cannot bind to a plain property, so
/// these are read straight off the query collection.</summary>
private string? QueryValue(params string[] keys)
{
foreach (var key in keys)
{
var value = Request.Query[key].FirstOrDefault();
if (!string.IsNullOrWhiteSpace(value)) return value;
}
return null;
}
private int? QueryInt(params string[] keys) =>
int.TryParse(QueryValue(keys), out var parsed) ? parsed : null;
/// <summary><c>ICPNBOOIDLI</c> — the banned-list row. Three fields only
/// (recnet-runtime-decomp/ICPNBOOIDLI.cs): int AccountId, long ClubId,
/// DateTime. CreatedAt is the membership row's JoinedAt, which
/// <see cref="MemberBan"/> leaves at the original join time when it
/// stamps the ban marker onto an existing row.</summary>
private static object ToWireBan(ClubMembershipEntity row) => new
{
AccountId = (int)row.PlayerId,
ClubId = row.ClubId,
CreatedAt = row.JoinedAt,
};
/// <summary><c>AADIHDCMEDB</c> — the pending-request row. Field list from
/// recnet-runtime-decomp/AADIHDCMEDB.cs: long id, int AccountId, int?
/// inviter account id, long ClubId, MJOOBDNCHBO MembershipType,
/// MDFFODMAIGJ status, DateTime.
///
/// Two fields the current perms model genuinely cannot fill, rather than
/// invent: the inviter is not recorded anywhere (invites and requests
/// both collapse to the single 128 marker on the target's own row) so it
/// is emitted as null — the DTO declares it nullable — and the status is
/// always Requested for the same reason. Nothing in the accept/deny flow
/// reads either field: those calls key off <c>accountId</c>.</summary>
private static object ToWireMemberRequest(ClubMembershipEntity row) => new
{
Id = row.Id,
RequestId = row.Id,
AccountId = (int)row.PlayerId,
InviterAccountId = (int?)null,
ClubId = row.ClubId,
MembershipType = ClubService.MembershipTypeFromPerms(row.Permissions),
Status = RequestStatusFor(row),
CreatedAt = row.JoinedAt,
};
/// <summary>MDFFODMAIGJ: Invited=0, Requested=1, Denied=2. Only Requested
/// is reachable — see <see cref="ToWireMemberRequest"/>.</summary>
private static int RequestStatusFor(ClubMembershipEntity row) => 1;
// ── Member mutations (void / IPromise) ─────────────────────────── // ── Member mutations (void / IPromise) ───────────────────────────
/// <summary>POST <c>/club/{id}/members/requesttojoin</c> — caller /// <summary>POST <c>/club/{id}/members/requesttojoin</c> — caller
@@ -0,0 +1,441 @@
using System.Security.Cryptography;
using System.Text.Json;
using System.Text.Json.Serialization;
using DorkNet.Server.Auth;
using DorkNet.Server.Binding;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace DorkNet.Server.Controllers.Link;
/// <summary>
/// The Link host (<c>GJDLNNLKDIJ.Link</c> = host ordinal <c>11</c>) share-code
/// service — <c>RecNet.Runtime/CKBKHENHCAN</c>. Every "share this" surface in
/// the 2023 client funnels through here:
/// <c>RecRoom.Sharing.ActionCode</c> subclasses (FriendCode, ReferralCode,
/// MeetupCode, ClubCode, PlayerEventCode, RoomCode, InfluencerCode, PhotoCode)
/// create an <b>action link</b> — a short code the player reads out or pastes,
/// which the receiving client redeems — while <c>LinkManager</c> stores the
/// larger opaque payload behind a share URL as a <b>data link</b>.
///
/// <para>The five routes and their verbs, read off the dispatch call in
/// <c>CKBKHENHCAN.txt</c> (the shape is
/// <c>(this, verb=rdx, host=r8, path=r9, body=stack)</c>, verb being a
/// <c>BestHTTP.HTTPMethods</c> ordinal — GET=0, POST=2, PUT=3):
/// <list type="bullet">
/// <item><c>GET actionlink/{code}</c> — <c>CGIEJDIHJBD</c> (:635), verb
/// <c>045 Move rdx, 0</c> (:793), host <c>050 LoadAddress r8, [rdx+11]</c>
/// (:798), literal <c>"actionlink/"</c> (:786).</item>
/// <item><c>POST actionlink</c> — <c>PCNPNDPAFLK</c> (:860), verb
/// <c>106 Move rdx, 2</c> (:1226), host <c>105 Move r8, 11</c> (:1225),
/// literal <c>"actionlink"</c> (:1224).</item>
/// <item><c>POST actionlink/{code}/consume</c> — <c>MKAKECJOKDG</c> (:1380),
/// verb <c>077 Move rdx, 2</c> (:1675), path built by
/// <c>String.Concat("actionlink/", WebUtility.UrlEncode(code), "/consume")</c>
/// (:1665-1669).</item>
/// <item><c>PUT datalink</c> — <c>ONPLPBLKACA</c> (:419), verb
/// <c>033 Move rdx, 3</c> (:560), literal <c>"datalink"</c> (:558).</item>
/// <item><c>GET datalink/{code}</c> — <c>KEHDDPCHPGK</c> (:194), verb
/// <c>045 Move rdx, 0</c> (:352), literal <c>"datalink/"</c> (:345).</item>
/// </list>
/// All five were unrouted, so every share/redeem flow 404'd.</para>
///
/// <para><b>Storage.</b> Codes live in <c>PlayerSettings</c> — the server's
/// existing general-purpose per-player key/value table, already used for
/// ban-appeal codes (<c>CompatibilityFeatureController</c>) and external
/// friend invites (<c>ExternalFriendInviteController</c>) — under
/// <c>actionlink:{CODE}</c> / <c>datalink:{CODE}</c> keys on the creator's
/// row. That keeps codes durable across restarts without a schema change; the
/// cost is the 1024-character <see cref="PlayerSettingEntity.Value"/> cap,
/// which is enforced explicitly below rather than left to trip a Postgres
/// <c>varchar(1024)</c> constraint at <c>SaveChanges</c>.</para>
/// </summary>
[ApiController]
public class LinkController(DorkNetDbContext db, ILogger<LinkController> logger) : ControllerBase
{
private const string ActionPrefix = "actionlink:";
private const string DataPrefix = "datalink:";
/// <summary>Unambiguous uppercase alphabet — no O/0/I/1 — because action
/// codes are read aloud and re-typed into
/// <c>ActionCodeConsumptionModel</c>'s input field.</summary>
private const string Alphabet = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ";
/// <summary><c>PlayerSettingEntity.Value</c> is <c>[MaxLength(1024)]</c>.</summary>
private const int ValueLimit = 1024;
/// <summary><c>RecRoom.Sharing.CBKKGBIJHAL</c> (2023.06.21 dump.cs:1248232):
/// Unknown=-1, Friend=0, Referral=1, Meetup=2, Club=3, PlayerEvent=4,
/// Room=5, Influencer=6, Photo=7. Confirmed live at the consume call sites
/// — RoomCode passes <c>030 Move r8, 5</c>
/// (RoomCode_NestedType_IBLIEDEHMPI.txt:175) and ClubCode passes
/// <c>029 Move r8, 3</c> (ClubCode_NestedType_EAGCMJOEANN.txt:85).</summary>
private const int CodeTypeReferral = 1;
// ── actionlink ────────────────────────────────────────────────────────────
/// <summary>POST <c>actionlink</c> — mint a share code.
///
/// <para>Form fields are added one at a time by
/// <c>BNDIAONDFFF.AFGEDDANEKP</c>: <c>data</c> (:1234), <c>validHours</c>
/// (:1249), <c>maxCount</c> (:1261, boxed <c>Nullable&lt;Int32&gt;</c> —
/// omitted when null), <c>codeType</c> (:1273) and <c>extraDataId</c>
/// (:1287, boxed <c>Nullable&lt;Int64&gt;</c>). The explicit
/// <c>[ModelBinder]</c> is mandatory: <c>[ApiController]</c> would
/// otherwise re-infer <c>[FromBody]</c> and 415 the form post.</para>
///
/// <para>The issuing method is
/// <c>FGLDKEJLAKB&lt;System.String&gt; PCNPNDPAFLK(...)</c> (:860), so the
/// body is a <b>bare JSON string</b> — the code itself. <c>Ok(string)</c>
/// would go out as unquoted <c>text/plain</c> via
/// <c>StringOutputFormatter</c>, which the client's String formatter
/// cannot read, hence the explicit <c>Content(JsonSerializer.Serialize(…))</c>
/// (same pattern as the ban-appeal code handler).</para></summary>
[HttpPost("/actionlink")]
[Authorize]
public async Task<IActionResult> ActionLinkCreate(
[ModelBinder(typeof(FormOrJsonModelBinder))] ActionLinkCreateRequest req)
{
var me = this.RequireCurrentPlayerId();
var data = req.Data ?? string.Empty;
var payload = new ActionLinkPayload
{
Data = data,
ExpiresAt = req.ValidHours > 0
? (DateTime?)DateTime.UtcNow.AddHours(req.ValidHours)
: null,
MaxCount = req.MaxCount is > 0 ? req.MaxCount : (int?)null,
Uses = 0,
CodeType = req.CodeType,
ExtraDataId = req.ExtraDataId,
};
var encoded = JsonSerializer.Serialize(payload);
if (encoded.Length > ValueLimit)
{
logger.LogWarning(
"[link] actionlink payload too large player={PlayerId} codeType={CodeType} bytes={Length} limit={Limit}",
me, req.CodeType, encoded.Length, ValueLimit);
return BadRequest(new { Error = "Action link data is too large to store." });
}
var code = await ReserveCodeAsync(ActionPrefix, 8);
if (code is null) return StatusCode(StatusCodes.Status503ServiceUnavailable);
db.PlayerSettings.Add(new PlayerSettingEntity
{
PlayerId = me,
Key = ActionPrefix + code,
Value = encoded,
});
await db.SaveChangesAsync();
logger.LogInformation(
"[link] actionlink created code={Code} player={PlayerId} codeType={CodeType} validHours={ValidHours} maxCount={MaxCount}",
code, me, req.CodeType, req.ValidHours, req.MaxCount);
return Content(JsonSerializer.Serialize(code), "application/json");
}
/// <summary>GET <c>actionlink/{code}</c> — peek at a code without spending
/// a use.
///
/// <para>Anonymous on purpose: <c>ActionCode.ModifyPreAuthLaunchTarget</c>
/// resolves an inbound deep link during <c>BootSequence</c>, i.e. before
/// the player has a token.</para>
///
/// <para>Return type <c>FGLDKEJLAKB&lt;NBAFGFFIOLK&gt;</c> (:635); the
/// generated formatter <c>DPIECEMDKPL</c> registers exactly three members —
/// <c>CreatorPlayerId</c> (:267, Int32), <c>Data</c> (:294, String),
/// <c>IsValid</c> (:310, Boolean) — each also in camelCase (:278/:302/:318)
/// and lowercase (:286/:326), so the server's camelCase anonymous-object
/// serialization binds. An unknown code answers 200 with
/// <c>IsValid:false</c> rather than 404 so the client always gets a body it
/// can parse instead of faulting the promise.</para></summary>
[HttpGet("/actionlink/{code}")]
[AllowAnonymous]
public async Task<IActionResult> ActionLinkGet(string code)
{
var row = await FindAsync(ActionPrefix, code);
var payload = Decode(row);
if (row is null || payload is null) return Ok(ActionLinkResult(0, string.Empty, false));
var valid = IsUsable(payload);
return Ok(ActionLinkResult((int)row.PlayerId, valid ? payload.Data : string.Empty, valid));
}
/// <summary>POST <c>actionlink/{code}/consume</c> — redeem a code.
///
/// <para>Form fields: <c>id</c> (:1684 — the code again), <c>validHours</c>
/// (:1692, <c>Nullable&lt;Int32&gt;</c>), <c>codeType</c> (:1720),
/// <c>newPlayer</c> (:1732) and <c>newInstall</c> (:1745). Response shape
/// is the same <c>NBAFGFFIOLK</c> as the GET (:1380).</para>
///
/// <para><c>validHours</c> here is the creating <c>ActionCode</c>'s
/// <c>Configuration.AutoRenewHours</c> — the call sites load it immediately
/// before dispatch (<c>043 Call Configuration.get_AutoRenewHours</c>,
/// RoomCode_NestedType_IBLIEDEHMPI.txt:168; ClubCode_NestedType_EAGCMJOEANN.txt:78)
/// and it is <c>null</c> for codes whose <c>AutoRenew</c> is off. So a
/// present value means "this code renews on use": the expiry is pushed out
/// that many hours instead of the code burning down.</para>
///
/// <para><c>codeType</c> is checked against the stored one — a Room code
/// (5) redeemed through the Club (3) flow would otherwise hand the club
/// handler a room payload to parse.</para>
///
/// <para>Referral codes are the one type with no other server touchpoint:
/// <c>ReferralCode</c> has no HTTP routes of its own, and
/// <c>IncentivizedReferralsController</c> counts
/// <c>referral:credited:{inviterId}:{inviteeId}</c> rows that nothing else
/// writes. Redeeming one here as a new player is what credits the
/// inviter.</para>
///
/// <para>Anonymous like the GET — the consume can fire from the pre-auth
/// launch-target path; the caller's id is only needed for referral
/// crediting.</para></summary>
[HttpPost("/actionlink/{code}/consume")]
[AllowAnonymous]
public async Task<IActionResult> ActionLinkConsume(
string code,
[ModelBinder(typeof(FormOrJsonModelBinder))] ActionLinkConsumeRequest req)
{
// The client sends the code in the path AND as the `id` field; prefer
// the path, fall back to the field.
var wanted = string.IsNullOrWhiteSpace(code) ? req.Id : code;
var row = await FindAsync(ActionPrefix, wanted);
var payload = Decode(row);
if (row is null || payload is null)
{
logger.LogInformation("[link] actionlink consume miss code={Code}", Normalize(wanted));
return Ok(ActionLinkResult(0, string.Empty, false));
}
if (!IsUsable(payload) || payload.CodeType != req.CodeType)
{
logger.LogInformation(
"[link] actionlink consume rejected code={Code} storedType={StoredType} askedType={AskedType} uses={Uses}/{MaxCount} expires={Expires}",
Normalize(wanted), payload.CodeType, req.CodeType, payload.Uses, payload.MaxCount, payload.ExpiresAt);
return Ok(ActionLinkResult((int)row.PlayerId, string.Empty, false));
}
payload.Uses++;
// AutoRenewHours present ⇒ the code renews rather than ages out.
if (req.ValidHours is > 0) payload.ExpiresAt = DateTime.UtcNow.AddHours(req.ValidHours.Value);
var encoded = JsonSerializer.Serialize(payload);
if (encoded.Length <= ValueLimit) row.Value = encoded;
var consumer = this.CurrentPlayerId();
await CreditReferralAsync(payload, row.PlayerId, consumer, req.NewPlayer);
await db.SaveChangesAsync();
logger.LogInformation(
"[link] actionlink consumed code={Code} creator={Creator} consumer={Consumer} type={CodeType} uses={Uses} newPlayer={NewPlayer} newInstall={NewInstall}",
Normalize(wanted), row.PlayerId, consumer ?? 0, payload.CodeType, payload.Uses, req.NewPlayer, req.NewInstall);
return Ok(ActionLinkResult((int)row.PlayerId, payload.Data, true));
}
// ── datalink ──────────────────────────────────────────────────────────────
/// <summary>PUT <c>datalink</c> — stash the payload behind a share URL.
///
/// <para>Single form field <c>data</c> (:569, added right after the
/// <c>033 Move rdx, 3</c> PUT dispatch at :560). Return type is
/// <c>FGLDKEJLAKB&lt;System.String&gt;</c> (:419) — a bare JSON string
/// holding the generated code, so the same explicit
/// <c>Content(JsonSerializer.Serialize(…), "application/json")</c> as the
/// action-link create.</para>
///
/// <para>Codes are 10 characters here rather than 8: nobody types a
/// datalink code, it only ever travels inside a URL, so the extra entropy
/// is free.</para></summary>
[HttpPut("/datalink")]
[Authorize]
public async Task<IActionResult> DataLinkCreate(
[ModelBinder(typeof(FormOrJsonModelBinder))] DataLinkCreateRequest req)
{
var me = this.RequireCurrentPlayerId();
var data = req.Data ?? string.Empty;
if (data.Length > ValueLimit)
{
logger.LogWarning(
"[link] datalink payload too large player={PlayerId} bytes={Length} limit={Limit}",
me, data.Length, ValueLimit);
return BadRequest(new { Error = "Data link payload is too large to store." });
}
var code = await ReserveCodeAsync(DataPrefix, 10);
if (code is null) return StatusCode(StatusCodes.Status503ServiceUnavailable);
db.PlayerSettings.Add(new PlayerSettingEntity
{
PlayerId = me,
Key = DataPrefix + code,
Value = data,
});
await db.SaveChangesAsync();
logger.LogInformation("[link] datalink created code={Code} player={PlayerId} bytes={Length}",
code, me, data.Length);
return Content(JsonSerializer.Serialize(code), "application/json");
}
/// <summary>GET <c>datalink/{code}</c> — resolve a share URL back to its
/// payload.
///
/// <para>Return type <c>FGLDKEJLAKB&lt;LBBKBMPAEIO&gt;</c> (:194) whose
/// generated formatter <c>IKBPJACLIHH</c> registers a single member,
/// <c>Data</c> (:139) — plus the <c>data</c> (:150) alias the server's
/// camelCase serializer emits. Anonymous, like the action-link read: this
/// is resolved from a cold-boot deep link. An unknown code returns an empty
/// <c>Data</c> rather than 404 so the strict reader still gets an
/// object.</para></summary>
[HttpGet("/datalink/{code}")]
[AllowAnonymous]
public async Task<IActionResult> DataLinkGet(string code)
{
var row = await FindAsync(DataPrefix, code);
return Ok(new { Data = row?.Value ?? string.Empty });
}
// ── helpers ───────────────────────────────────────────────────────────────
/// <summary>Codes are stored uppercase so a player who types their share
/// code in lowercase still redeems it.</summary>
private static string Normalize(string? code)
=> (code ?? string.Empty).Trim().ToUpperInvariant();
private Task<PlayerSettingEntity?> FindAsync(string prefix, string? code)
{
var normalized = Normalize(code);
if (normalized.Length == 0) return Task.FromResult<PlayerSettingEntity?>(null);
var key = prefix + normalized;
return db.PlayerSettings.FirstOrDefaultAsync(s => s.Key == key);
}
/// <summary>Generates a code that is free across every player's rows (the
/// unique index is on <c>(PlayerId, Key)</c>, but a code has to be globally
/// unique because lookups are by key alone). Retries a handful of times
/// before giving up rather than colliding on insert.</summary>
private async Task<string?> ReserveCodeAsync(string prefix, int length)
{
for (var attempt = 0; attempt < 6; attempt++)
{
var code = NewCode(length);
var key = prefix + code;
if (!await db.PlayerSettings.AnyAsync(s => s.Key == key)) return code;
}
logger.LogError("[link] exhausted code-generation attempts for prefix={Prefix}", prefix);
return null;
}
private static string NewCode(int length)
{
var chars = new char[length];
for (var i = 0; i < length; i++)
chars[i] = Alphabet[RandomNumberGenerator.GetInt32(Alphabet.Length)];
return new string(chars);
}
private ActionLinkPayload? Decode(PlayerSettingEntity? row)
{
if (row is null) return null;
try
{
return JsonSerializer.Deserialize<ActionLinkPayload>(row.Value);
}
catch (JsonException ex)
{
logger.LogWarning(ex, "[link] unreadable action-link row id={RowId} key={Key}", row.Id, row.Key);
return null;
}
}
private static bool IsUsable(ActionLinkPayload payload)
{
if (payload.ExpiresAt is { } expiry && expiry <= DateTime.UtcNow) return false;
if (payload.MaxCount is { } max && payload.Uses >= max) return false;
return true;
}
/// <summary>Records "<c>consumer</c> was referred by <c>creator</c>" in the
/// shape <c>IncentivizedReferralsController</c> already reads
/// (<c>referral:credited:{inviterId}:{inviteeId}</c>, counted by key prefix
/// regardless of which row owns it — stored on the invitee, matching the
/// <c>externalinvite:redeemed:*</c> convention). Only fires for referral
/// codes redeemed by a signed-in, brand-new player who is not the
/// creator.</summary>
private async Task CreditReferralAsync(
ActionLinkPayload payload, long creatorId, long? consumerId, bool newPlayer)
{
if (payload.CodeType != CodeTypeReferral) return;
if (!newPlayer) return;
if (consumerId is not { } consumer || consumer == creatorId) return;
var key = $"referral:credited:{creatorId}:{consumer}";
if (await db.PlayerSettings.AnyAsync(s => s.PlayerId == consumer && s.Key == key)) return;
db.PlayerSettings.Add(new PlayerSettingEntity
{
PlayerId = consumer,
Key = key,
Value = DateTime.UtcNow.ToString("O"),
});
logger.LogInformation("[link] referral credited inviter={Inviter} invitee={Invitee}", creatorId, consumer);
}
private static object ActionLinkResult(int creatorPlayerId, string data, bool isValid) => new
{
CreatorPlayerId = creatorPlayerId,
Data = data,
IsValid = isValid,
};
/// <summary>Everything the client told us at create time, packed into the
/// single <c>PlayerSettings.Value</c> string. Keys are one character each
/// to leave as much of the 1024-character budget as possible for
/// <see cref="Data"/>.</summary>
private sealed class ActionLinkPayload
{
[JsonPropertyName("d")] public string Data { get; set; } = string.Empty;
[JsonPropertyName("e")] public DateTime? ExpiresAt { get; set; }
[JsonPropertyName("m")] public int? MaxCount { get; set; }
[JsonPropertyName("u")] public int Uses { get; set; }
[JsonPropertyName("t")] public int CodeType { get; set; }
[JsonPropertyName("x")] public long? ExtraDataId { get; set; }
}
}
/// <summary>Form fields of <c>POST actionlink</c> — CKBKHENHCAN.txt:1234
/// (<c>data</c>), :1249 (<c>validHours</c>), :1261 (<c>maxCount</c>), :1273
/// (<c>codeType</c>), :1287 (<c>extraDataId</c>).</summary>
public sealed class ActionLinkCreateRequest
{
public string? Data { get; set; }
public int ValidHours { get; set; }
public int? MaxCount { get; set; }
public int CodeType { get; set; }
public long? ExtraDataId { get; set; }
}
/// <summary>Form fields of <c>POST actionlink/{code}/consume</c> —
/// CKBKHENHCAN.txt:1684 (<c>id</c>), :1692 (<c>validHours</c>), :1720
/// (<c>codeType</c>), :1732 (<c>newPlayer</c>), :1745
/// (<c>newInstall</c>).</summary>
public sealed class ActionLinkConsumeRequest
{
public string? Id { get; set; }
public int? ValidHours { get; set; }
public int CodeType { get; set; }
public bool NewPlayer { get; set; }
public bool NewInstall { get; set; }
}
/// <summary>Sole form field of <c>PUT datalink</c> — CKBKHENHCAN.txt:569.</summary>
public sealed class DataLinkCreateRequest
{
public string? Data { get; set; }
}
+1
View File
@@ -161,6 +161,7 @@ Note: counts are approximate because several client URLs (`api/avatar/v3/saved`,
| GET | `api/inventions/v3/saved` | REAL | `InventionsController.Saved` — caller's own creations (SAVED list) | | GET | `api/inventions/v3/saved` | REAL | `InventionsController.Saved` — caller's own creations (SAVED list) |
| POST | `api/inventions/v3/save` | REAL | `InventionsController.Save` — creates a new `InventionEntity` (initial save) | | POST | `api/inventions/v3/save` | REAL | `InventionsController.Save` — creates a new `InventionEntity` (initial save) |
| POST | `api/inventions/v3/addversion` | REAL | `InventionsController.AddVersion` — appends `InventionVersionEntity`, bumps `CurrentVersionNumber` | | POST | `api/inventions/v3/addversion` | REAL | `InventionsController.AddVersion` — appends `InventionVersionEntity`, bumps `CurrentVersionNumber` |
| POST | `remote-run/push-to-studio` | REAL | `InventionsController.PushToStudio` (also `roomserver/` alias) — Rec Room Studio remote-run handoff. Raw-JSON body `{SessionId, RoomId, SubRoomId, UnityAssetId, RoomData:{Filename,Hash,OwnershipProof}, SubRoomData:{…}, SavedByAccountId}`; stamps the pushed `RoomDataBlobEntity` with its room/sub-room, records `UnityAssetId` on the `RoomScene`, sets `Rooms.StudioSessionId`, keeps the push under `remoterun:{SessionId}` in `PlayerSettings`. Does NOT commit the live save (that stays `rooms/{id}/subrooms/{id}/data`). Replies with the flattened `RemoteRunDetails` |
### `api/Leaderboard/` ### `api/Leaderboard/`
+133 -5
View File
@@ -185,19 +185,41 @@ public class ClubService(DorkNetDbContext db)
.ToList(); .ToList();
} }
/// <summary>
/// Per-player settings key holding the explicitly chosen home club.
/// <see cref="PlayerSettingEntity"/> is the server's general-purpose
/// per-player key/value bag (the same store <c>MatchPlayerController</c>
/// and <c>RoomsController</c> use for stateful scalars), so the 2023
/// client's PUT/DELETE <c>club/home/me</c> pair persists here rather
/// than needing a new column on <see cref="PlayerEntity"/>.
/// </summary>
public const string HomeClubSettingKey = "club.home";
/// <summary> /// <summary>
/// Composite home-tab payload for <c>GET /club/home/me</c>. The /// Composite home-tab payload for <c>GET /club/home/me</c>. The
/// watch's deserialiser for this endpoint expects a single /// watch's deserialiser for this endpoint expects a single
/// <c>RecNet.Club</c> object (<c>typeof(System.Action`1&lt;PLILLKHMNDA&gt;)</c> /// <c>RecNet.Club</c> object (<c>typeof(System.Action`1&lt;PLILLKHMNDA&gt;)</c>
/// per ISIL) — typically the player's "home club" (set via /// per ISIL) — typically the player's "home club" (set via
/// <c>SetMyHomeClub</c>) rendered as the headline card. We pick the /// <c>SetMyHomeClub</c>) rendered as the headline card. An explicit
/// first club the player is a member of, ordered by membership /// pick made through <see cref="SetHomeClubAsync"/> always wins; when
/// recency. Returns null when the player has no club memberships /// nothing is stored (or the stored club has since been disbanded) we
/// — the controller turns that into <see cref="Microsoft.AspNetCore.Mvc.NoContentResult"/> /// fall back to the most recently joined club so the tab is never
/// so the watch knows to render the "join a club" empty state. /// blank for an existing member. Returns null when the player has no
/// club memberships at all — the controller turns that into the
/// ClubId=0 sentinel the watch renders as "no home club".
/// </summary> /// </summary>
public async Task<ClubEntity?> HomeClubAsync(long playerId) public async Task<ClubEntity?> HomeClubAsync(long playerId)
{ {
var stored = await db.PlayerSettings.AsNoTracking()
.Where(s => s.PlayerId == playerId && s.Key == HomeClubSettingKey)
.Select(s => s.Value)
.FirstOrDefaultAsync();
if (long.TryParse(stored, out var storedId) && storedId > 0)
{
var chosen = await db.Clubs.FirstOrDefaultAsync(c => c.Id == storedId && c.State == 0);
if (chosen is not null) return chosen;
}
// Most-recent-joined club wins. Owners are members too (the // Most-recent-joined club wins. Owners are members too (the
// /create flow inserts an owner-permissions=127 membership // /create flow inserts an owner-permissions=127 membership
// row), so a brand-new club creator sees their fresh club here. // row), so a brand-new club creator sees their fresh club here.
@@ -209,6 +231,112 @@ public class ClubService(DorkNetDbContext db)
return pick; return pick;
} }
/// <summary>Persist the player's explicit home-club pick. Returns false
/// when the club id doesn't resolve to a live club so the caller can 404
/// instead of storing a dangling id.</summary>
public async Task<bool> SetHomeClubAsync(long playerId, long clubId)
{
if (!await db.Clubs.AnyAsync(c => c.Id == clubId && c.State == 0)) return false;
var row = await db.PlayerSettings
.FirstOrDefaultAsync(s => s.PlayerId == playerId && s.Key == HomeClubSettingKey);
if (row is null)
{
db.PlayerSettings.Add(new PlayerSettingEntity
{
PlayerId = playerId,
Key = HomeClubSettingKey,
Value = clubId.ToString(),
});
}
else
{
row.Value = clubId.ToString();
}
await db.SaveChangesAsync();
return true;
}
/// <summary>Drop the player's explicit home-club pick. Idempotent —
/// missing row is a no-op, matching the client's fire-and-forget
/// "Remove Home Clubhouse Room" call.</summary>
public async Task ClearHomeClubAsync(long playerId)
{
var row = await db.PlayerSettings
.FirstOrDefaultAsync(s => s.PlayerId == playerId && s.Key == HomeClubSettingKey);
if (row is null) return;
db.PlayerSettings.Remove(row);
await db.SaveChangesAsync();
}
/// <summary>
/// Disband a club — the client's "Delete Club" action. Owner-only
/// (unlike <see cref="ModifyAsync"/>, which co-owners and moderators
/// can drive), and a soft delete: State is stamped with the client's
/// <c>IACDINKNHKB.MarkedForDelete</c> = 1000 so every read path here
/// (all of which filter <c>State == 0</c>) stops surfacing it while
/// announcements / memberships stay intact for moderation review.
/// Returns null when the club doesn't exist; throws
/// <see cref="UnauthorizedAccessException"/> when the caller isn't the
/// creator.
/// </summary>
public async Task<ClubEntity?> DisbandAsync(long clubId, long callerId)
{
var club = await db.Clubs.FirstOrDefaultAsync(c => c.Id == clubId && c.State == 0);
if (club is null) return null;
if (club.CreatorPlayerId != callerId) throw new UnauthorizedAccessException();
club.State = 1000; // IACDINKNHKB.MarkedForDelete
club.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return club;
}
/// <summary>Every announcement across the clubs the player is a real
/// member of (pending invites/requests = 128 and ban markers = 256 are
/// excluded — they must not leak a club's board). Backs the flat
/// <c>GET /announcements/mine</c> list, which is a different wire shape
/// from the per-club unread rollup.</summary>
public async Task<List<ClubAnnouncementEntity>> AnnouncementsForMemberClubsAsync(
long playerId, int take = 100)
{
var clubIds = await db.ClubMemberships
.Where(m => m.PlayerId == playerId && (m.Permissions & 384) == 0)
.Select(m => m.ClubId)
.ToListAsync();
return await AnnouncementsAcrossClubsAsync(clubIds, take);
}
/// <summary>Every announcement across the clubs the player subscribes
/// to. Backs the flat <c>GET /announcements/subscription/mine</c>
/// list.</summary>
public async Task<List<ClubAnnouncementEntity>> AnnouncementsForSubscribedClubsAsync(
long playerId, int take = 100)
{
var clubIds = await db.ClubSubscriptions
.Where(s => s.PlayerId == playerId)
.Select(s => s.ClubId)
.ToListAsync();
return await AnnouncementsAcrossClubsAsync(clubIds, take);
}
private async Task<List<ClubAnnouncementEntity>> AnnouncementsAcrossClubsAsync(
List<long> clubIds, int take)
{
if (clubIds.Count == 0) return new List<ClubAnnouncementEntity>();
// Disbanded clubs keep their rows but must not surface on the
// aggregated feed, so the club set is re-filtered on State.
var live = await db.Clubs
.Where(c => clubIds.Contains(c.Id) && c.State == 0)
.Select(c => c.Id)
.ToListAsync();
if (live.Count == 0) return new List<ClubAnnouncementEntity>();
return await db.ClubAnnouncements
.Where(a => live.Contains(a.ClubId))
.OrderByDescending(a => a.CreatedAt)
.ThenByDescending(a => a.Id)
.Take(Math.Clamp(take, 1, 500))
.ToListAsync();
}
/// <summary> /// <summary>
/// Upserts a read-marker so subsequent <see cref="UnreadDirectAsync"/> /// Upserts a read-marker so subsequent <see cref="UnreadDirectAsync"/>
/// / <see cref="UnreadSubscriptionAsync"/> calls skip the /// / <see cref="UnreadSubscriptionAsync"/> calls skip the
@@ -0,0 +1,153 @@
using Microsoft.EntityFrameworkCore;
using DorkNet.Server.Data;
namespace DorkNet.Server.Services;
/// <summary>
/// The single account-erasure cascade used by every "remove this player"
/// path in the server: the admin SPA's
/// <c>DELETE api/admin/v1/players/{id}</c> and the 2023 client's
/// <c>DELETE account/me</c> ("delete local account").
///
/// Personal rows are deleted outright; authored durable content (rooms,
/// clubs, inventions, playlists, chat threads …) is reassigned to the
/// system account so other players' worlds don't lose their contents when
/// one author leaves. Kept as a static helper — not a DI service — so both
/// call sites share one implementation without either controller having to
/// grow a constructor dependency.
///
/// Callers are responsible for the surrounding transaction, for removing
/// the <c>Players</c> row itself, and for kicking the live session.
/// </summary>
public static class PlayerAccountPurge
{
/// <summary>Delete/reassign every row that references
/// <paramref name="playerId"/>. Returns a one-line audit summary.</summary>
public static async Task<string> PurgeAsync(
DorkNetDbContext db, long playerId, long systemPlayerId)
{
var now = DateTime.UtcNow;
var deleted = 0;
var reassigned = 0;
deleted += await db.Avatars.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
deleted += await db.PlayerSettings.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
deleted += await db.Relationships.Where(x => x.RequesterId == playerId || x.TargetId == playerId).ExecuteDeleteAsync();
deleted += await db.CurrencyBalances.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
deleted += await db.PlayerInventory.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
deleted += await db.PlayerDevices.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
deleted += await db.NotificationPrefs.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
deleted += await db.CohortAssignments.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
deleted += await db.PushTokens.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
deleted += await db.PlatformIgnores.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
deleted += await db.Cards.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
deleted += await db.ObjectiveProgress.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
deleted += await db.GameRewardSelections.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
deleted += await db.CouponRedemptions.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
deleted += await db.LeaderboardStats.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
deleted += await db.PlayerElo.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
deleted += await db.RoyalePlayerProgress.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
deleted += await db.RoyaleMatchPlayers.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
deleted += await db.Messages.Where(x => x.SenderPlayerId == playerId || x.RecipientPlayerId == playerId).ExecuteDeleteAsync();
deleted += await db.ChatMessages.Where(x => x.SenderPlayerId == playerId).ExecuteDeleteAsync();
deleted += await db.ChatThreadMembers.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
reassigned += await db.ChatThreads
.Where(x => x.CreatorPlayerId == playerId)
.ExecuteUpdateAsync(s => s.SetProperty(x => x.CreatorPlayerId, systemPlayerId));
deleted += await db.Cheers.Where(x => x.FromPlayerId == playerId || x.TargetPlayerId == playerId).ExecuteDeleteAsync();
deleted += await db.Reports.Where(x => x.ReporterPlayerId == playerId || x.TargetPlayerId == playerId).ExecuteDeleteAsync();
deleted += await db.BugReports.Where(x => x.ReporterPlayerId == playerId).ExecuteDeleteAsync();
deleted += await db.Subscriptions.Where(x => x.SubscriberPlayerId == playerId || x.TargetPlayerId == playerId).ExecuteDeleteAsync();
deleted += await db.ClubMemberships.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
deleted += await db.ClubSubscriptions.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
deleted += await db.ClubAnnouncementReads.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
reassigned += await db.Clubs
.Where(x => x.CreatorPlayerId == playerId)
.ExecuteUpdateAsync(s => s
.SetProperty(x => x.CreatorPlayerId, systemPlayerId)
.SetProperty(x => x.UpdatedAt, now));
reassigned += await db.ClubAnnouncements
.Where(x => x.AuthorPlayerId == playerId)
.ExecuteUpdateAsync(s => s.SetProperty(x => x.AuthorPlayerId, systemPlayerId));
deleted += await db.RoomBookmarks.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
deleted += await db.RoomVisits.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
deleted += await db.RoomRoles.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
deleted += await db.RoomBans.Where(x => x.BannedPlayerId == playerId).ExecuteDeleteAsync();
reassigned += await db.RoomBans
.Where(x => x.BannedByPlayerId == playerId)
.ExecuteUpdateAsync(s => s.SetProperty(x => x.BannedByPlayerId, systemPlayerId));
reassigned += await db.RoomRoles
.Where(x => x.GrantedByPlayerId == playerId)
.ExecuteUpdateAsync(s => s.SetProperty(x => x.GrantedByPlayerId, systemPlayerId));
reassigned += await db.Rooms
.Where(x => x.CreatorPlayerId == playerId)
.ExecuteUpdateAsync(s => s
.SetProperty(x => x.CreatorPlayerId, systemPlayerId)
.SetProperty(x => x.UpdatedAt, now));
reassigned += await db.RoomDataBlobs
.Where(x => x.UploadedByPlayerId == playerId)
.ExecuteUpdateAsync(s => s.SetProperty(x => x.UploadedByPlayerId, systemPlayerId));
reassigned += await db.RoomKeys
.Where(x => x.CreatorPlayerId == playerId)
.ExecuteUpdateAsync(s => s.SetProperty(x => x.CreatorPlayerId, systemPlayerId));
deleted += await db.RoomKeyPurchases.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
deleted += await db.PlaylistInteractions.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
reassigned += await db.Playlists
.Where(x => x.CreatorPlayerId == playerId)
.ExecuteUpdateAsync(s => s.SetProperty(x => x.CreatorPlayerId, systemPlayerId));
deleted += await db.PlayerEventResponses
.Where(x => x.PlayerId == playerId
|| db.PlayerEvents.Where(e => e.CreatorPlayerId == playerId).Select(e => e.Id).Contains(x.EventId))
.ExecuteDeleteAsync();
deleted += await db.PlayerEvents.Where(x => x.CreatorPlayerId == playerId).ExecuteDeleteAsync();
deleted += await db.PrivateInstanceInvitees.Where(x => x.PlayerId == playerId).ExecuteDeleteAsync();
var privateInstanceIds = await db.PrivateInstances
.Where(x => x.OwnerPlayerId == playerId)
.Select(x => x.Id)
.ToListAsync();
if (privateInstanceIds.Count > 0)
{
deleted += await db.PrivateInstanceInvitees
.Where(x => privateInstanceIds.Contains(x.PrivateInstanceId))
.ExecuteDeleteAsync();
deleted += await db.PrivateInstances
.Where(x => privateInstanceIds.Contains(x.Id))
.ExecuteDeleteAsync();
}
deleted += await db.GiftPackages
.Where(x => x.RecipientPlayerId == playerId)
.ExecuteDeleteAsync();
if (playerId <= int.MaxValue)
{
reassigned += await db.GiftPackages
.Where(x => x.FromPlayerId == (int)playerId)
.ExecuteUpdateAsync(s => s.SetProperty(x => x.FromPlayerId, (int?)null));
}
reassigned += await db.Inventions
.Where(x => x.CreatorPlayerId == playerId)
.ExecuteUpdateAsync(s => s
.SetProperty(x => x.CreatorPlayerId, systemPlayerId)
.SetProperty(x => x.UpdatedAt, now));
reassigned += await db.Photos
.Where(x => x.UploaderPlayerId == playerId)
.ExecuteUpdateAsync(s => s
.SetProperty(x => x.UploaderPlayerId, systemPlayerId)
.SetProperty(x => x.DeletedAt, now)
.SetProperty(x => x.IsPublic, false));
reassigned += await db.SignupCodes
.Where(x => x.RedeemedByPlayerId == playerId)
.ExecuteUpdateAsync(s => s.SetProperty(x => x.RedeemedByPlayerId, (long?)null));
return $"deleted_rows={deleted} reassigned_rows={reassigned}";
}
}
+116 -112
View File
@@ -1557,17 +1557,17 @@ Of 65 real client routes: ~17 fully work. 20 routes are entirely MISSING (all cl
| PUT | `comments/read/{0}/{1}` | none | status-only ('Failed to update latest read comment'). MISSING on server | | PUT | `comments/read/{0}/{1}` | none | status-only ('Failed to update latest read comment'). MISSING on server |
| GET | `comments/unreadcounts` | roomIds (repeated Int64 list). Verb is dynamic: GET when <100 room ids (query), POST with form body when >=100 (verb = count<100 ? 0 : 2 — cmovl at instr 059-061). Server registers | JSON object mapping roomId (stringified Int64 key) -> unread count (UInt32) | | GET | `comments/unreadcounts` | roomIds (repeated Int64 list). Verb is dynamic: GET when <100 room ids (query), POST with form body when >=100 (verb = count<100 ? 0 : 2 — cmovl at instr 059-061). Server registers | JSON object mapping roomId (stringified Int64 key) -> unread count (UInt32) |
| GET | `members/bulk` | query: id (repeated, one per account). clubId is NOT transmitted — it is only the client-side cache key (response handler NJNJEOFNJIG(clubId, List<FCKGOFHNDNJ>) at IKMMOCKDKAF.txt: | JSON array; FCKGOFHNDNJ CLR: int AccountId, MJOOBDNCHBO MembershipType | | GET | `members/bulk` | query: id (repeated, one per account). clubId is NOT transmitted — it is only the client-side cache key (response handler NJNJEOFNJIG(clubId, List<FCKGOFHNDNJ>) at IKMMOCKDKAF.txt: | JSON array; FCKGOFHNDNJ CLR: int AccountId, MJOOBDNCHBO MembershipType |
| GET | `thread/club/{0}` | query: maxCount (int), mode (int, semantics UNKNOWN). Chat host (GJDLNNLKDIJ.Chat=8) | JSON array of chat threads. JNMKLHDFPOJ CLR (recnet-runtime-decomp/JNMKLHDFPOJ.cs): 2 longs (thread id + ?), List<ChatMessage> messages, ChatMessage latest, List<int> playerIds, string name, DateTime? snoozedUntil, long | | GET | `thread/club/{0}` | query: maxCount (int, default 10), mode (int, semantics UNKNOWN — client always sends 0). Chat host (GJDLNNLKDIJ.Chat=8) | JSON array of chat threads. The thread reader (EKEFFNBIOHJ.txt instrs 082/109/133/149/173/197/221/245/269) takes exactly nine keys in three casings: ChatThreadId, LastReadMessageId, Messages, LatestMessage, PlayerIds, ChatThreadName, SnoozedUntil, IsFavorited, ClubId. IMPLEMENTED: `ChatController.GetClubThreads` — one `club:{clubId}` thread per club, roster-gated, returned as a one-element array |
| PUT | `thread/message/{0}/moderate` | form-urlencoded: moderationState (byte enum: Active=0,Junior_Pending=11,Moderation_Pending=100,Moderation_Closed=101,Moderation_Banned=102) | chat-result enum MGGNHLHPHOF (Success=0,InvalidArguments,ThreadNotFound,MembershipNotFound,PlayerAlreadyOnThread,CannotMessagePlayer,InvalidCharacters,RecentlyLeftThread,ThreadTooLarge) — bare value. MISSING on server | | PUT | `thread/message/{0}/moderate` | form-urlencoded: moderationState (byte enum: Active=0,Junior_Pending=11,Moderation_Pending=100,Moderation_Closed=101,Moderation_Banned=102) | chat-result enum MGGNHLHPHOF (Success=0,InvalidArguments,ThreadNotFound,MembershipNotFound,PlayerAlreadyOnThread,CannotMessagePlayer,InvalidCharacters,RecentlyLeftThread,ThreadTooLarge) — bare value (`Action<MGGNHLHPHOF>` at DLDKCILCKNA.txt instr 101). STILL MISSING on server — blocked on a `ChatMessageEntity.ModerationState` column |
| POST | `thread/withmembers` | form-urlencoded: ids (repeated Int32 list), messageCount (int). Server has a form-reading variant (ChatController.cs:438-446) — matches | single thread object (get-or-create DM/group with these members) | | POST | `thread/withmembers` | form-urlencoded: ids (repeated Int32 list), messageCount (int). Server has a form-reading variant (ChatController.cs:438-446) — matches | single thread object (get-or-create DM/group with these members) |
| GET | `thread/{0}` | query: messageCount (int) | single thread object with messages | | GET | `thread/{0}` | query: messageCount (int) | single thread object with messages |
| POST | `thread/{0}` | form-urlencoded: messageContents = JSON string of DLDKCILCKNA.MessageJson with PRESERVED field names {Type (int: Text=0,PartyInvite=1), Version (int), Data (string)} (recnet-runtim | ANDECIOOJNB: {ChatMessage, chat-result enum}. Server's validated keys: chatMessage, chatResult; message keys: chatMessageId, chatThreadId, senderPlayerId, timeSent, contents (ChatController.cs:792-810) | | POST | `thread/{0}` | form-urlencoded: messageContents = JSON string of DLDKCILCKNA.MessageJson with PRESERVED field names {Type (int: Text=0,PartyInvite=1), Version (int), Data (string)} (recnet-runtim | ANDECIOOJNB: {ChatMessage, chat-result enum}. Server's validated keys: chatMessage, chatResult; message keys: chatMessageId, chatThreadId, senderPlayerId, timeSent, contents (ChatController.cs:792-810) |
| PUT | `thread/{0}/favorite` | form-urlencoded: favorite (bool) | chat-result enum. MISSING on server | | PUT | `thread/{0}/favorite` | form-urlencoded: favorite (bool) | NOT a bare enum — the continuation is `Func<GLIKNMPPEHL, MGGNHLHPHOF>` (DLDKCILCKNA.txt instr 142), so the body is the wrapper object GLIKNMPPEHL, read by OGCMIDEFOEE.txt (instrs 042/069) with exactly two keys in three casings: ChatResult, IsFavorited. IMPLEMENTED: `ChatController.Favorite` — flag persisted in PlayerSettings under `chat.favorite:<threadKey>` and echoed back in the thread DTO's IsFavorited |
| POST | `thread/{0}/leave` | none | chat-result enum. VERB MISMATCH: server registers HttpDelete only (ChatController.cs:337) → 405 for this client's POST | | POST | `thread/{0}/leave` | none | chat-result enum. VERB MISMATCH: server registers HttpDelete only (ChatController.cs:337) → 405 for this client's POST |
| POST | `thread/{0}/member/{1}` | none (both ids in path) | chat-result enum (PlayerAlreadyOnThread etc.). Server POST exists (ChatController.cs:673) | | POST | `thread/{0}/member/{1}` | none (both ids in path) | chat-result enum (PlayerAlreadyOnThread etc.). Server POST exists (ChatController.cs:673) |
| GET | `thread/{0}/message` | query: messageCount (int), mode (int, semantics UNKNOWN), referenceMessageId (Int64) | JSON array of ChatMessage. CLR (recnet-runtime-decomp/RecNet/ChatMessage.cs): long MessageId, long ThreadId, int SenderAccountId, DateTime SentAt, string contents, MOFGOGFEHNN moderation state, MessageJson parsed content | | GET | `thread/{0}/message` | query: messageCount (int), mode (int, semantics UNKNOWN), referenceMessageId (Int64) | JSON array of ChatMessage. CLR (recnet-runtime-decomp/RecNet/ChatMessage.cs): long MessageId, long ThreadId, int SenderAccountId, DateTime SentAt, string contents, MOFGOGFEHNN moderation state, MessageJson parsed content |
| POST | `thread/{0}/message/{1}/read` | none | chat-result enum. Server accepts POST+PUT (ChatController.cs:268-269) — OK | | POST | `thread/{0}/message/{1}/read` | none | chat-result enum. Server accepts POST+PUT (ChatController.cs:268-269) — OK |
| POST | `thread/{0}/rename` | form-urlencoded: name (string) | chat-result enum. MISMATCH: server registers PUT only with [FromBody] JSON (ChatController.cs:355-356) → 405/415 for this client's form POST | | POST | `thread/{0}/rename` | form-urlencoded: name (string) | chat-result enum, bare value (`Action<MGGNHLHPHOF>` at DLDKCILCKNA.txt instr 162). FIXED: `ChatController.Rename` now registers PUT+POST and binds via FormOrJsonModelBinder |
| POST | `thread/{0}/snooze` | form-urlencoded: snooze (bool) | chat-result enum. ENCODING MISMATCH: server POST exists but binds [FromBody] SnoozeRequest JSON (ChatController.cs:307-308) → 415 on form content-type | | POST | `thread/{0}/snooze` | form-urlencoded: snooze (bool) | chat-result enum. ENCODING MISMATCH: server POST exists but binds [FromBody] SnoozeRequest JSON (ChatController.cs:307-308) → 415 on form content-type |
#### Not HTTP routes #### Not HTTP routes
@@ -1586,29 +1586,23 @@ Handler: `DorkNet.Server/Controllers/Clubs/ClubsController.cs:338`
**Fix.** In BuildDetailsResponseAsync emit each *Permissions key as an object {ClubId, MembershipType, approveMember, banUnban, createEvent, editDetails, editPermissionSettings, postAnnouncement} (key casing of the 6 bools per the PUT permissions request closure; response casing UNKNOWN — mirror request casing and validate live). Applies to all 8 envelope-returning handlers at once. **Fix.** In BuildDetailsResponseAsync emit each *Permissions key as an object {ClubId, MembershipType, approveMember, banUnban, createEvent, editDetails, editPermissionSettings, postAnnouncement} (key casing of the 6 bools per the PUT permissions request closure; response casing UNKNOWN — mirror request casing and validate live). Applies to all 8 envelope-returning handlers at once.
##### `PUT club/home/me` — MISSING (breaks-gameplay) ##### `PUT club/home/me` — FIXED
Only HttpGet is registered for /club/home/me (ClubsController.cs:44); a PUT gets 405. The 2023 client's 'Set Home Clubhouse Room' sends PUT with form-urlencoded clubId and fails every time. `ClubsController.HomeMeSet` reads the form-urlencoded `clubId` (query fallback) and stores it through `ClubService.SetHomeClubAsync`, which persists into the general-purpose `PlayerSettings` key/value bag under key `club.home` — no new column needed. Returns an empty 200; the client's `LDGADANDBIO JOPMBFIFFBB(Nullable<Int64>)` parses no DTO. `HomeClubAsync` now prefers the stored pick and only falls back to most-recently-joined when nothing is stored or the stored club was disbanded.
**Fix.** Add [HttpPut("/club/home/me")] reading Request.Form["clubId"] (long), persist a HomeClubId on the player (new column/service method), return 200 empty (client parses no DTO). ##### `DELETE club/home/me` — FIXED
##### `DELETE club/home/me` — MISSING (breaks-gameplay) `ClubsController.HomeMeClear` removes the `club.home` `PlayerSettings` row (idempotent) and returns an empty 200.
No HttpDelete registered → 405. 'Remove Home Clubhouse Room' fails. ##### `DELETE club/{0}` — FIXED
**Fix.** Add [HttpDelete("/club/home/me")] clearing the stored home club; return 200 empty. `ClubsController.ClubDelete` → `ClubService.DisbandAsync`: owner-only (co-owners/moderators are rejected, unlike `ModifyAsync`), soft delete stamping `State = 1000` (`IACDINKNHKB.MarkedForDelete`). Every read path here filters `State == 0`, so the club disappears from browse/mine/details while its announcements and membership rows survive for moderation. Returns an empty 200.
##### `DELETE club/{0}` — MISSING (breaks-gameplay) ##### `PUT club/{0}/clubChatEnabled` — MISSING (breaks-gameplay) — BLOCKED ON SCHEMA
Only GET /club/{clubId:long} exists → DELETE 405. 'Delete Club' (disband) always fails. No route → 404. Club-chat toggle in settings fails. Verified from the binary: `MEPEAAJOGLP(Int64, Boolean)` at IKMMOCKDKAF.txt:25601, route literal :25779, verb ordinal 3 (PUT) at :25802, form key `clubChatEnabled` (IKMMOCKDKAF_NestedType_BLCIHIILJNE.txt:67), response is the `LCLFBBPEMIH` details envelope. Deliberately NOT stubbed: a handler that accepted the toggle and dropped it would make the settings screen lie, and `GET club/{0}/hasDisabledClubChat` (now served) would keep answering `false`.
**Fix.** Add [HttpDelete("/club/{clubId:long}")]: owner-only, set State=1000 (MarkedForDelete) or hard-delete club + membership rows; return 200 empty (status-only on client). **Fix.** Needs `ClubEntity.ClubChatEnabled` (bool, default true). Once the column exists: `[HttpPut("/club/{clubId:long}/clubChatEnabled")]` reading the form `clubChatEnabled` through `FormOrJsonModelBinder`, write via `ModifyAsync`, return `BuildDetailsResponseAsync`, and update BOTH `ToWireClub`'s `ClubChatEnabled = true` literal and `ClubsController.ClubHasDisabledChat`'s `false`.
##### `PUT club/{0}/clubChatEnabled` — MISSING (breaks-gameplay)
No route anywhere in the server (grep for clubChatEnabled: zero hits) → 404. Club-chat toggle in settings fails.
**Fix.** Add [HttpPut("/club/{clubId:long}/clubChatEnabled")] reading form key clubChatEnabled (bool), persist a ClubChatEnabled column on ClubEntity, return the details envelope.
##### `PUT club/{0}/clubhouse` — SHAPE_MISMATCH (breaks-gameplay) ##### `PUT club/{0}/clubhouse` — SHAPE_MISMATCH (breaks-gameplay)
@@ -1626,11 +1620,9 @@ Handler: `DorkNet.Server/Controllers/Clubs/ClubsController.cs:258`
**Fix.** Emit MMOCDPPONNG-shaped objects for the three permission keys (see club/create finding); persist CustomTags/AdditionalImages later. **Fix.** Emit MMOCDPPONNG-shaped objects for the three permission keys (see club/create finding); persist CustomTags/AdditionalImages later.
##### `GET club/{0}/hasDisabledClubChat` — MISSING (breaks-gameplay) ##### `GET club/{0}/hasDisabledClubChat` — FIXED (pending schema)
No route (grep zero hits) → 404. The club chat thread open path checks this first, so club chat cannot open even after thread/club/{id} is added. `ClubsController.ClubHasDisabledChat` 404s an unknown/disbanded club and otherwise answers a bare JSON `false` (the issuing method is `FGLDKEJLAKB<System.Boolean> OFOJAHBKMOJ(Int64)` — a bare boolean body, not an object). `false` is the *true* answer today: the value is the negation of the Club wire field `ClubChatEnabled`, which has no `ClubEntity` column and whose setter (`PUT club/{0}/clubChatEnabled`) is therefore still unimplemented, so no club can be in the disabled state. When the column lands, this handler and `ToWireClub`'s `ClubChatEnabled` literal must change together.
**Fix.** Add [HttpGet("/club/{clubId:long}/hasDisabledClubChat")] returning a bare JSON boolean (false, or !ClubChatEnabled once persisted).
##### `PUT club/{0}/mainimage` — SHAPE_MISMATCH (breaks-gameplay) ##### `PUT club/{0}/mainimage` — SHAPE_MISMATCH (breaks-gameplay)
@@ -1640,11 +1632,9 @@ Handler: `DorkNet.Server/Controllers/Clubs/ClubsController.cs:396`
**Fix.** Read Request.Form["imageName"] when HasFormContentType (mirror ReadCreateClubRequestAsync pattern), keep JSON path for other callers. **Fix.** Read Request.Form["imageName"] when HasFormContentType (mirror ReadCreateClubRequestAsync pattern), keep JSON path for other callers.
##### `GET club/{0}/members` — MISSING (breaks-gameplay) ##### `GET club/{0}/members` — FIXED
Only /club/{clubId}/members/{playerId:long} exists; 'members' with query params has no matching template (sub-literals like 'banned' fail the :long constraint too) → 404. The club page member list never loads. `ClubsController.ClubMembers` binds `membershipType`/`sortBy`/`skip`/`take` and returns the bare `List<CADEIMCFIIG>` array via the existing `ToWireMembership` projection. `membershipType` is compared as the MJOOBDNCHBO wire value (after `MembershipTypeFromPerms`), not the stored perms int; with no filter the roster is real members only, so ban markers (256) and pending rows (128) stay in their own endpoints. `sortBy` implements GNLOJEONFIG (Default=privileged-first, JoinDate_Asc/Desc, Username_Asc/Desc) — usernames are joined in from `Players` once per request.
**Fix.** Add [HttpGet("/club/{clubId:long}/members")] with query membershipType/sortBy/skip/take, returning a JSON array of {AccountId:int, ClubId:long, MembershipType:int, CreatedAt} rows (reuse ToWireMembership, ClubsController.cs:828-834).
##### `POST club/{0}/members/acceptrequest` — SHAPE_MISMATCH (breaks-gameplay) ##### `POST club/{0}/members/acceptrequest` — SHAPE_MISMATCH (breaks-gameplay)
@@ -1758,11 +1748,11 @@ Handler: `DorkNet.Server/Controllers/Clubs/ClubsController.cs:697`
**Fix.** Accept repeated 'id' query params; when clubId is absent, return each account's membership without club scoping (e.g. highest/most-relevant membership per account) or infer from context — response stays [{AccountId, MembershipType}]. **Fix.** Accept repeated 'id' query params; when clubId is absent, return each account's membership without club scoping (e.g. highest/most-relevant membership per account) or infer from context — response stays [{AccountId, MembershipType}].
##### `GET thread/club/{0}` — MISSING (breaks-gameplay) ##### `GET thread/club/{0}` — FIXED (was breaks-gameplay)
'thread/club/123' (3 segments) matches no template — thread/{chatThreadId} is 2 segments — → 404. The club chat tab cannot open at all. 'thread/club/123' (3 segments) matched no template — thread/{chatThreadId} is 2 segments — → 404. The club chat tab could not open at all.
**Fix.** Add [HttpGet("/thread/club/{clubId:long}")] in ChatController: get-or-create a club thread key (e.g. "club:{clubId}"), honor query maxCount, and return a JSON ARRAY of ToWireThread objects (client expects List<JNMKLHDFPOJ>) with ClubId included in the thread wire object; extend ThreadKey routing helpers (ResolveThreadKeyAsync, participant loading, send fan-out to club members) to understand club: keys so thread/{id} POST/GET work on it. **Fixed.** `ChatController.GetClubThreads` — `[HttpGet("/thread/club/{clubId:long}")]`, get-or-creates the `club:{clubId}` thread (named after the club on first materialisation), honours `maxCount`, accepts and ignores `mode`, and returns a one-element JSON ARRAY of ToWireThread objects. Non-roster callers and unknown clubs get `[]` (the client deserialises the body before it looks at the status). The thread helpers now understand `club:` keys: `LoadParticipantsAsync` resolves participants from ClubMemberships (pending 128 / banned 256 excluded), `ThreadRecipientsAsync` fans new messages out over the roster, `IsThreadMemberAsync` gates rename/favorite on roster membership, `Leave` refuses club threads, and `GetThreads` lists club channels that have messages. ToWireThread now also emits the two keys the 2023 reader wants and the 2020 DTO lacked: `isFavorited` and `clubId`.
##### `POST thread/{0}/leave` — VERB_MISMATCH (breaks-gameplay) ##### `POST thread/{0}/leave` — VERB_MISMATCH (breaks-gameplay)
@@ -1780,55 +1770,47 @@ Handler: `DorkNet.Server/Controllers/Clubs/ClubsController.cs:417`
**Fix.** Persist additional image slots (new AdditionalImages storage keyed clubId+slot), read Request.Form["imageName"] when HasFormContentType, echo them in the envelope's AdditionalImages as [{imageName,slot}] per HIKCHBLAMLP. **Fix.** Persist additional image slots (new AdditionalImages storage keyed clubId+slot), read Request.Form["imageName"] when HasFormContentType, echo them in the envelope's AdditionalImages as [{imageName,slot}] per HIKCHBLAMLP.
##### `DELETE club/{0}/additionalimage/{1}` — MISSING (degraded) ##### `DELETE club/{0}/additionalimage/{1}` — MISSING (degraded) — BLOCKED ON SCHEMA
Only POST/PUT registered (ClubsController.cs:417-418) → DELETE 405. 'Remove Additional Club Image' fails. Only POST/PUT registered → DELETE 405. 'Remove Additional Club Image' fails. Verified from the binary: `AKJJLMLPBKN(Int64, Int32 slot, String imageName)` at IKMMOCKDKAF.txt:16054 branches on the captured imageName (:16219) — verb ordinal 4 with no body when null (description "Remove Additional Club Image", :16229-16234), verb 3 with the form field otherwise (:16256-16260) — and both branches parse the `LCLFBBPEMIH` details envelope.
**Fix.** Add [HttpDelete("/club/{clubId:long}/additionalimage/{slot:int}")] clearing the slot, return details envelope. Deliberately NOT registered: the PUT twin is already an admitted stub that persists nothing, so a DELETE would have nothing to clear. Registering it would only convert a visible 405 into a silent success while the gallery stays blank.
##### `DELETE club/{0}/mainimage` — MISSING (degraded) **Fix.** Needs the additional-image store the PUT twin also wants: a `ClubAdditionalImageEntity` keyed (ClubId, Slot) with an ImageName column. Then register `[HttpDelete("/club/{clubId:long}/additionalimage/{slot:int}")]`, delete the row, and have `BuildDetailsResponseAsync` project the rows into the envelope's `AdditionalImages` as `HIKCHBLAMLP` {imageName, slot} instead of the current `Array.Empty<object>()`.
Only POST/PUT registered (:396-397) → DELETE 405. 'Remove Club Image' fails. ##### `DELETE club/{0}/mainimage` — FIXED
**Fix.** Add [HttpDelete("/club/{clubId:long}/mainimage")] setting ImageName='' and returning the details envelope. `ClubsController.ClubMainImageDelete` is its own action (NOT a third verb binding on the POST/PUT setter — aliasing an edit verb onto a destructive handler is exactly what made `PUT /announcements/club/{c}/{a}` delete posts). Clears `ImageName` through `ModifyAsync` and returns the full `LCLFBBPEMIH` details envelope, which is what `IBAKMFKEEDJ` parses on both branches of `BLEDOCHHHJM` — the DELETE branch is *not* status-only.
##### `GET club/{0}/members/banned` — MISSING (degraded) ##### `GET club/{0}/members/banned` — FIXED
No route → 404 ('banned' fails the {playerId:long} constraint of the single-member route). Banned list screen empty-errors. `ClubsController.ClubMembersBanned` returns the bare `List<ICPNBOOIDLI>` array — three fields only ({AccountId:int, ClubId:long, CreatedAt}; ICPNBOOIDLI has no MembershipType) — over rows carrying the perms 256 ban marker, with `sortBy`/`skip`/`take`. Moderator-gated (`CanManageAsync`): the ban list is staff-only information.
**Fix.** Add [HttpGet("/club/{clubId:long}/members/banned")] (sortBy/skip/take query) returning array of {AccountId:int, ClubId:long, CreatedAt} for rows with perms ban marker 256 (ICPNBOOIDLI layout). ##### `GET club/{0}/members/requests` — FIXED
##### `GET club/{0}/members/requests` — MISSING (degraded) `ClubsController.ClubMemberRequests` returns the bare `List<AADIHDCMEDB>` array over rows with the 128 pending marker (and without the 256 ban marker), `skip`/`take`, moderator-gated. Two fields are still un-fillable rather than invented: `InviterAccountId` is emitted as null (the DTO declares it nullable; invites and requests both collapse to the single 128 marker on the target's own row, so no inviter is recorded) and `Status` is always Requested for the same reason. Neither is read by the accept/deny flow, which keys off `accountId`. Persisting invited-vs-requested would let `Status` be filled properly.
No route → 404. Pending-join-requests screen fails ('Failed to get pending club member requests'). ##### `GET club/{0}/members/requests/search` — FIXED
**Fix.** Add [HttpGet("/club/{clubId:long}/members/requests")] (skip/take) returning array of AADIHDCMEDB-layout rows (request id, AccountId, inviter id?, ClubId, MembershipType, status Invited=0/Requested=1/Denied=2, CreatedAt). Response key casing UNKNOWN — dual-case via Dictionary per the camelCase-serializer memory, then validate live. Note: current perms model collapses invited vs requested (MembershipTypeFromPerms returns Requested for any 128 row, ClubService.cs:354) — persist the distinction to fill status correctly. `ClubsController.ClubMemberRequestsSearch` reads the dot-prefixed keys straight off `Request.Query` (`parameters.name`, `parameters.sortBy`, `parameters.maxCount`, `parameters.status`, plus the unprefixed `continuationToken`) and returns the `EDFOCLNECPM` paged envelope. `continuationToken` is an opaque row offset (String on the DTO); an empty string means no further pages, matching the `club/search` envelope. NAHBJCGNJKA's RequestDate_Asc/Desc index the same JoinedAt column GNLOJEONFIG's JoinDate_Asc/Desc do, so both enums share one comparator. Envelope key names remain unverified (DataMember blobs are not in the ISIL/decompile), so it emits the entity-named pair validated on the sibling `club/search` envelope — `Requests`/`TotalRequests` — alongside `Results`/`TotalResults`; Json.NET ignores whichever pair the DTO doesn't declare.
##### `GET club/{0}/members/requests/search` — MISSING (degraded) ##### `GET club/{0}/members/search` — FIXED
No route → 404. Request-search box fails. `ClubsController.ClubMembersSearch`, same treatment: `parameters.name`/`parameters.type`/`parameters.sortBy`/`parameters.maxCount` + `continuationToken` off `Request.Query`, returning the `MBMNHFAPFCJ` paged envelope with `Members`/`TotalMembers` plus `Results`/`TotalResults` and `ContinuationToken`.
**Fix.** Add [HttpGet("/club/{clubId:long}/members/requests/search")] binding query keys 'parameters.name', 'parameters.sortBy', 'parameters.maxCount', 'parameters.status', 'continuationToken' (bind via Request.Query, not [FromQuery] props, because of the dotted prefix) and return a paged envelope {list of AADIHDCMEDB rows, total int, continuationToken} — envelope key casing UNKNOWN, validate live. ##### `PUT club/{0}/minlevel` — MISSING (degraded) — BLOCKED ON SCHEMA
##### `GET club/{0}/members/search` — MISSING (degraded) No club minlevel route (only Rooms.MinLevel exists) → 404. 'Min Level to Join' setting fails. Verified from the binary: `AOAFDLMFCGB(Int64, Int32)` at IKMMOCKDKAF.txt:16448, route literal :16561, verb ordinal 3 (PUT) at :16584, form key `minLevel` (IKMMOCKDKAF_NestedType_AOOCOABJPDN.txt:67), response is the `LCLFBBPEMIH` details envelope via IBAKMFKEEDJ.
No route → 404. Member-list search box fails ('SearchMembers failed'). **Fix.** Needs `ClubEntity.MinLevel` (int, default 0). Once the column exists: `[HttpPut("/club/{clubId:long}/minlevel")]` reading the form `minLevel` through `FormOrJsonModelBinder`, write via `ModifyAsync`, return `BuildDetailsResponseAsync`, and swap `ToWireClub`'s `MinLevel = 0` literal for the column.
**Fix.** Add [HttpGet("/club/{clubId:long}/members/search")] binding 'parameters.name'/'parameters.type'/'parameters.sortBy'/'parameters.maxCount'/'continuationToken' from Request.Query; return paged envelope of membership rows (CADEIMCFIIG layout) — envelope casing UNKNOWN, validate live.
##### `PUT club/{0}/minlevel` — MISSING (degraded)
No club minlevel route (only Rooms.MinLevel exists) → 404. 'Min Level to Join' setting fails.
**Fix.** Add [HttpPut("/club/{clubId:long}/minlevel")] reading form minLevel (int), persist MinLevel on ClubEntity, return details envelope.
##### `PUT club/{0}/permissions/{1}` — MISSING (degraded) ##### `PUT club/{0}/permissions/{1}` — MISSING (degraded)
Only HttpGet is registered → PUT 405. Per-role permission checklist saves fail. (The GET's bare-int response is a separate 2023 mismatch but the 2023 client doesn't call GET here.) Only HttpGet is registered → PUT 405. Per-role permission checklist saves fail. BLOCKED ON SCHEMA. Verified from the binary: `HDDNEPNKCIK(MMOCDPPONNG)` at IKMMOCKDKAF.txt:17537 formats the route from the DTO's own ClubId + MembershipType (:17671-17689), verb ordinal 3 (PUT) at :17709, description "Modify Club Permissions", response is the `LCLFBBPEMIH` details envelope. Form keys are the 6 bools `editDetails`/`approveMember`/`createEvent`/`postAnnouncement`/`editPermissionSettings`/`banUnban` (IKMMOCKDKAF_NestedType_BOIMHOCCOEI.txt:173-253), matching the response-side reader BPHCOIBNCDP.txt:638-758.
Handler: `DorkNet.Server/Controllers/Clubs/ClubsController.cs:290 (GET only)` Deliberately NOT stubbed: `PermissionsForRole` derives the six bools from the role with a fixed default policy and there is nowhere to store an override, so a handler would accept the checklist and silently discard it — the screen would appear to save and revert on reload.
**Fix.** Add [HttpPut("/club/{clubId:long}/permissions/{role:int}")] reading the 6 form bools (approveMember/banUnban/createEvent/editDetails/editPermissionSettings/postAnnouncement), persist per-role permission rows, return the details envelope with object-shaped permissions. **Fix.** Needs a per-role permission table (ClubId, MembershipType, the 6 bools). Then register `[HttpPut("/club/{clubId:long}/permissions/{role:int}")]` binding those 6 form keys through `FormOrJsonModelBinder`, upsert the row owner-only, and have `PermissionsForRole` read the stored row (falling back to the current derived default when absent) so `BuildDetailsResponseAsync` reflects the save.
##### `POST comments/create/{0}` — MISSING (degraded) ##### `POST comments/create/{0}` — MISSING (degraded)
@@ -1848,19 +1830,21 @@ No route → 404. Room-details comment list never loads ('Failed to get room com
**Fix.** Add [HttpGet("/comments/get/{roomId:long}")] with query count (int) + minId (long), returning JSON array of RoomComment. **Fix.** Add [HttpGet("/comments/get/{roomId:long}")] with query count (int) + minId (long), returning JSON array of RoomComment.
##### `PUT thread/message/{0}/moderate` — MISSING (degraded) ##### `PUT thread/message/{0}/moderate` — MISSING (degraded) — BLOCKED ON SCHEMA
4-segment path matches no thread template → 404. Chat moderation (hide/flag) fails. 4-segment path matches no thread template → 404. Chat moderation (hide/flag) fails.
**Fix.** Add [HttpPut("/thread/message/{messageId:long}/moderate")] reading form moderationState (int), persist a ModerationState column on ChatMessageEntity, return bare int 0 (MGGNHLHPHOF.Success). Verb + shape re-verified from the binary: DLDKCILCKNA.txt `PLBCHNGAEML(ChatMessage, MOFGOGFEHNN)`, instr 065 `Move rcx, "thread/message/{0}/moderate"`, verb `Move rdx, 3` at instr 075 (PUT), host `Move r8, 8` (Chat), single form field `moderationState` at instr 087, response consumed by `Action<MGGNHLHPHOF>` at instr 101 = bare int.
##### `POST thread/{0}/rename` — VERB_MISMATCH (degraded) **Fix.** Add `ChatMessageEntity.ModerationState` (int, default 0 = Active) + migration, emit it as the `ModerationState` key in `ChatController.ToWireMessage` (the ChatMessage reader CBKDKACJDOF.txt reads ChatMessageId / ChatThreadId / SenderPlayerId / TimeSent / Contents / ModerationState), then add `[HttpPut("/thread/message/{messageId:long}/moderate")]` reading form `moderationState` and returning bare int 0. Deliberately NOT stubbed: without the column the handler could only discard the request, which hides the gap behind a 200.
Server registers PUT only with [FromBody] RenameRequest JSON; the client sends POST with form 'name' → 405 (and would 415 even as PUT). Renaming a group chat fails. ##### `POST thread/{0}/rename` — FIXED (was verb_mismatch, degraded)
Handler: `DorkNet.Server/Controllers/Chat/ChatController.cs:355` Server registered PUT only with [FromBody] RenameRequest JSON; the client sends POST with form 'name' → 405 (and would 415 even as PUT). Renaming a group chat failed.
**Fix.** Add [HttpPost] on the route plus a form-content branch reading Request.Form["name"]; keep returning bare int 0. Handler: `DorkNet.Server/Controllers/Chat/ChatController.cs` — `ChatController.Rename`
**Fixed.** Route now carries both `[HttpPut]` and `[HttpPost]`; `RenameRequest` became a plain class with a parameterless ctor bound by `[ModelBinder(typeof(FormOrJsonModelBinder))]`, so the client's form `name` and the admin SPA's JSON both bind. Still returns the bare int (`Action<MGGNHLHPHOF>` at DLDKCILCKNA.txt instr 162). Membership is now checked through `IsThreadMemberAsync`, which also understands club threads.
##### `POST thread/{0}/snooze` — SHAPE_MISMATCH (degraded) ##### `POST thread/{0}/snooze` — SHAPE_MISMATCH (degraded)
@@ -1884,11 +1868,11 @@ Handler: `DorkNet.Server/Controllers/API/Comments/CommentsController.cs:40`
**Fix.** Once comments/read persists per-player read state, compute real counts (max comment id per room vs player's last-read). **Fix.** Once comments/read persists per-player read state, compute real counts (max comment id per room vs player's last-read).
##### `PUT thread/{0}/favorite` — MISSING (cosmetic) ##### `PUT thread/{0}/favorite` — FIXED (was cosmetic)
No route → 404. Starring a thread fails. No route → 404. Starring a thread failed.
**Fix.** Add [HttpPut("/thread/{chatThreadId}/favorite")] reading form favorite (bool), persist Favorited on ChatThreadMemberEntity, return bare int 0. **Fixed.** `ChatController.Favorite` — `[HttpPut]`+`[HttpPost]` on `/thread/{chatThreadId}/favorite`, form field `favorite` bound with FormOrJsonModelBinder. The response is NOT a bare int as previously recorded: the client's continuation is `Func<GLIKNMPPEHL, MGGNHLHPHOF>` (DLDKCILCKNA.txt instr 142), i.e. a client-side projection off a wrapper object, so the handler returns `{chatResult, isFavorited}` (reader OGCMIDEFOEE.txt, both keys casing-tolerant). The flag is per-(player, thread) and there is no column for it, so it is persisted in the generic PlayerSettings table under `chat.favorite:<threadKey>` and read back into every thread DTO's `IsFavorited` via `LoadFavoritesAsync`. Unknown threads answer ThreadNotFound(2), non-members MembershipNotFound(3).
### Relationships, friends and messaging ### Relationships, friends and messaging
@@ -2322,17 +2306,19 @@ Handler: `DorkNet.Server/Controllers/API/Keepsakes/KeepsakesController.cs:117`
**Fix.** Bind {RoomId,SubRoomId,KeepsakeCategory}, store a real KeepsakeInstance row (guid id, room, subroom, category, placer), and return the bare Guid as the JSON body. **Fix.** Bind {RoomId,SubRoomId,KeepsakeCategory}, store a real KeepsakeInstance row (guid id, room, subroom, category, placer), and return the bare Guid as the JSON body.
##### `DELETE api/keepsakes/{keepsakeInstanceId:guid}` — MISSING (breaks-gameplay) ##### `DELETE api/keepsakes/{keepsakeInstanceId:guid}` — FIXED
No DELETE route under api/keepsakes at all; a GUID path segment matches no template in KeepsakesController -> 404. Removing a placed keepsake fails. Was: no DELETE route under api/keepsakes at all; a GUID path segment matched no template in KeepsakesController -> 404, so removing a placed keepsake failed.
**Fix.** Add [HttpDelete("{keepsakeInstanceId:guid}")] removing the instance (owner/placer gated), 200 with no body. **Fixed.** `KeepsakesController.Delete` ([HttpDelete("{keepsakeInstanceId:guid}")]) resolves the guid back to the row whose EventKey encodes it, gates on placer / room creator / accepted co-owner (RoomRoles.Role==0) / admin, deletes the instance plus any collection rows pointing at it, and returns a bodiless 200 — the client's issuing method returns the non-generic `LDGADANDBIO` promise (NCCLEJPIABA.JBKENCNIEPA, NCCLEJPIABA.txt:1544 + `Move rdx, 4`@1698) so no body is read.
##### `POST api/keepsakes/{keepsakeInstanceId:guid}/collect` — MISSING (breaks-gameplay) ##### `POST api/keepsakes/{keepsakeInstanceId:guid}/collect` — FIXED
Route not registered -> 404. Collecting a keepsake in-room fails; client expects {"TotalXp":int,"SocialBoostXp":int}. Was: route not registered -> 404, so collecting a keepsake in-room failed.
**Fix.** Add [HttpPost("{keepsakeInstanceId:guid}/collect")] recording a collection row and returning {TotalXp,SocialBoostXp} (SocialBoostXp 0 while SocialXpBoostEnabled=false). **Fixed.** `KeepsakesController.Collect` ([HttpPost("{keepsakeInstanceId:guid}/collect")]) writes a collection row (`Category="collection"`, `EventKey="collect:{roomId}:{instanceGuid:N}"` — no schema change) and returns `{"TotalXp":int,"SocialBoostXp":int}` (DHNBKMHDANK; keys from its Utf8Json formatter, RecNet.Runtime/PKCMBJFBHBO.txt:42,69). TotalXp is the whole award — the client renders `TotalXp - SocialBoostXp` as the base figure (PDFJLLECNBE_NestedType_LKIPMJFEAFK.txt:82-99) — and is priced from the same category `XpValue` that `api/keepsakes/categories` serves. SocialBoostXp is 0 while SocialXpBoostEnabled=false. Re-collecting the same instance is idempotent and awards 0.
Two supporting corrections in the same controller: `api/keepsakes/categories` now emits the full `PIHCLHIKEPH` id set (0-8, dump.cs:1199524) instead of three entries keyed by DorkNet's internal account/event/room buckets — the client folds the list into a `Dictionary<PIHCLHIKEPH, KeepsakeCategoryConfigDTO>` that every placed instance is looked up in — and `api/keepsakes/rooms/{roomId}` now returns real `CollectionRecords` (`{AccountId, KeepsakeInstanceId, CollectedAt}`, dump.cs:1234642 / CNNAFLNKDCL.txt:48,75,99) scoped to the caller instead of an empty array.
##### `GET api/keepsakes/events/{progressionEventId}` — SHAPE_MISMATCH (breaks-gameplay) ##### `GET api/keepsakes/events/{progressionEventId}` — SHAPE_MISMATCH (breaks-gameplay)
@@ -2348,11 +2334,15 @@ Route not registered anywhere -> 404. Claiming a reward chest on the progression
**Fix.** Add [HttpPost("api/progressionEvents/collect/{eventId:long}/{rewardIndex:int}")] that increments the player's RewardsCollected and returns a GiftDrop object (reuse the gifts/generate wire builder). **Fix.** Add [HttpPost("api/progressionEvents/collect/{eventId:long}/{rewardIndex:int}")] that increments the player's RewardsCollected and returns a GiftDrop object (reuse the gifts/generate wire builder).
##### `GET event/{0}/instances` — MISSING (breaks-gameplay) ##### `GET event/{0}/instances` — FIXED
Only /room/{roomId:long}/instances exists (MatchController.cs:418); no event/{eventId}/instances is registered anywhere -> 404 and the 'join' instance browser for multi-instance events never populates. Client wants List<{RoomInstanceId,RoomId,SubRoomId,IsFull,CreatedAt,PlayerIds}> — the 2023 Utf8Json reader accepts the camelCase keys the room variant already emits (MatchController.cs:478-486). Was: only /room/{roomId:long}/instances existed; no event/{eventId}/instances was registered anywhere -> 404 and the 'join' instance browser for multi-instance events never populated. Client wants List<{RoomInstanceId,RoomId,SubRoomId,IsFull,CreatedAt,PlayerIds}> — the 2023 Utf8Json reader accepts the camelCase keys the room variant already emits.
**Fix.** Add [HttpGet("/event/{eventId:long}/instances")] in MatchController: resolve the event's RoomId (and SubRoomId when set) then reuse the RoomInstances enumeration/wire code. Verb/shape ground truth: `Matchmaking+<GetEventInstanceBrowser>b__0` (Matchmaking_NestedType_CBMHJMNIHNN.txt:14) formats `"event/{0}/instances"` at :296 with the PlayerEventId ([rdi+16] of the HPIOAGDJHDH it closes over) and news up BNDIAONDFFF with rdx=0 at :124 = HTTPMethods.Get. Return type `FGLDKEJLAKB<List<PNDCMIMEJLD>>` = bare JSON array; PNDCMIMEJLD's getters (PNDCMIMEJLD.txt:3-197) are Int64/Int64/Int64/Boolean/DateTime/List\<Int32\>/String/Int32/Boolean/String, i.e. the same SimpleRoomInstance the room browser returns (Matchmaking.txt:14237 uses an identical BNDIAONDFFF/verb-0 sequence). The trailing SubroomName/PlayerCount/HasModPresent/HashedInstanceId are populated locally on the watch and are not read off the wire.
**Fixed.** `[HttpGet("/event/{eventId:long}/instances")]` now lives on `PlayerEventsController.EventInstances` (the route is a matchmaking-host-root path but the data is a player-event one): it resolves the event's RoomId plus the SubRoomId persisted in the event's extras blob, then enumerates PlayerPresenceService instances for that room (filtered to the sub-room when the event names one) and merges in the caller's own PrivateInstances rows, emitting the same six camelCase keys as `room/{id}/instances`. `isFull` is computed from the instance's MaxCapacity vs its presenced player count.
Handler: `DorkNet.Server/Controllers/API/PlayerEvents/PlayerEventsController.cs` (`EventInstances`)
##### `GET api/playerevents/v1/{0}` — SHAPE_MISMATCH (degraded) ##### `GET api/playerevents/v1/{0}` — SHAPE_MISMATCH (degraded)
@@ -2617,6 +2607,8 @@ Only bare GET api/images/v6 (query ?name=) is registered. api/images/v6/123 fall
**Fix.** Add [HttpGet("api/images/v6/{id:long}")] to ImagesController resolving db.Photos by Id (public or owned) and returning BuildImageInfo(photo, photo.UploaderPlayerId) — BuildImageInfo already carries all 12 LGLCPNPJCEC keys (Id, ImageName, PlayerId, RoomId, PlayerEventId, Accessibility, AccessibilityLocked, Type, CreatedAt, TaggedPlayerIds, CheerCount, CommentCount). **Fix.** Add [HttpGet("api/images/v6/{id:long}")] to ImagesController resolving db.Photos by Id (public or owned) and returning BuildImageInfo(photo, photo.UploaderPlayerId) — BuildImageInfo already carries all 12 LGLCPNPJCEC keys (Id, ImageName, PlayerId, RoomId, PlayerEventId, Accessibility, AccessibilityLocked, Type, CreatedAt, TaggedPlayerIds, CheerCount, CommentCount).
**Status: FIXED.** `ImagesController.ImageByIdV6` (`[HttpGet("api/images/v6/{id:long}")]`, anonymous, public-or-owner) returns a single BuildImageInfo object. Key list re-confirmed against the generated serializer `IBILPLGNAJE.txt:819-1078`.
##### `GET api/images/v5/bulk` — SHAPE_MISMATCH (degraded) ##### `GET api/images/v5/bulk` — SHAPE_MISMATCH (degraded)
Two defects. (1) Response: items are BuildImageInfo objects but the client deserializes ICOFKEGOGOD whose keys are SavedImageId / SavedImageType / ClubId / Description — the builder sends Id and Type instead of SavedImageId and SavedImageType and has no ClubId, so the client's photo id reads as default 0 (or the strict reader throws on the missing non-nullable Int64 — throw-vs-default UNKNOWN); every downstream per-photo action (cheer, detail, delete) then targets id 0. (2) POST fallback (>=100 ids): client posts FORM field "ids", but the action binds [FromBody] JSON → ASP.NET returns 415 for form content. Two defects. (1) Response: items are BuildImageInfo objects but the client deserializes ICOFKEGOGOD whose keys are SavedImageId / SavedImageType / ClubId / Description — the builder sends Id and Type instead of SavedImageId and SavedImageType and has no ClubId, so the client's photo id reads as default 0 (or the strict reader throws on the missing non-nullable Int64 — throw-vs-default UNKNOWN); every downstream per-photo action (cheer, detail, delete) then targets id 0. (2) POST fallback (>=100 ids): client posts FORM field "ids", but the action binds [FromBody] JSON → ASP.NET returns 415 for form content.
@@ -2633,6 +2625,8 @@ Handler: `DorkNet.Server\Controllers\API\Images\ImagesController.cs:539`
**Fix.** Add [HttpGet("api/images/v5/cheered/bulk")] (and v4) parsing ids from Request.Query["id"] (comma-split all values); on POST also accept Request.Form["id"]. **Fix.** Add [HttpGet("api/images/v5/cheered/bulk")] (and v4) parsing ids from Request.Query["id"] (comma-split all values); on POST also accept Request.Form["id"].
**Status: FIXED.** `ImagesController.CheeredBulk` is now registered GET+POST on v4 and v5 and takes no bound parameter at all — ids come from `ReadCheerBulkIdsAsync`, which reads query values, then form values when the body is form-encoded, then a JSON body (array or object) otherwise. Dropping `[FromBody]` is what removes the 415 on the >=100-id form POST.
##### `GET api/images/v1/slideshow` — SHAPE_MISMATCH (degraded) ##### `GET api/images/v1/slideshow` — SHAPE_MISMATCH (degraded)
Top level {ValidTill, Images} is right, but each Images item must be NBNFGGCGCLP {SavedImageId, ImageName, Username, PlayerId, RoomName, RoomId}. BuildImageInfo supplies ImageName/PlayerId/RoomId but has NO SavedImageId (sends Id), NO Username, NO RoomName — the slideshow reader gets default 0/null for those (or throws on the non-nullable SavedImageId — UNKNOWN), so slideshow entries lose attribution or fail to render. Top level {ValidTill, Images} is right, but each Images item must be NBNFGGCGCLP {SavedImageId, ImageName, Username, PlayerId, RoomName, RoomId}. BuildImageInfo supplies ImageName/PlayerId/RoomId but has NO SavedImageId (sends Id), NO Username, NO RoomName — the slideshow reader gets default 0/null for those (or throws on the non-nullable SavedImageId — UNKNOWN), so slideshow entries lose attribution or fail to render.
@@ -2663,6 +2657,8 @@ No route matches; the request lands in the api/images/{*path} catch-all and 404s
**Fix.** Add [HttpPost("api/images/v1/{id:long}/report")] [Authorize] that inserts a ReportEntity (ReporterPlayerId = caller, TargetPhotoId = id, TargetPlayerId = photo.UploaderPlayerId) and returns 200 with any body. **Fix.** Add [HttpPost("api/images/v1/{id:long}/report")] [Authorize] that inserts a ReportEntity (ReporterPlayerId = caller, TargetPhotoId = id, TargetPlayerId = photo.UploaderPlayerId) and returns 200 with any body.
**Status: FIXED.** `ImagesController.ReportPhoto`. ReportEntity has no TargetPhotoId column and this round adds no schema, so the photo id + blob name ride in `Message` as `[photo {id} {blobName}] …` (the same convention `ClubService.AddClubReportAsync` uses for `[club {id}]`); TargetPlayerId = uploader, RoomId = the photo's room, Category = 5 (Other — the wire carries no category). Adding a `TargetPhotoId` column later would let the admin queue link straight to the image.
##### `GET api/inventions/v1/update` — SHAPE_MISMATCH (degraded) ##### `GET api/inventions/v1/update` — SHAPE_MISMATCH (degraded)
Route, GET verb, and all four one-of query params (name/description/imgName/permission) bind and persist correctly. But the response is a bare ToWire invention object; the client deserializes BDNCJIPHHOK {"Status":Int32,"Invention":obj,"InventionVersion":obj} — none of those keys exist in the reply, so Status reads default (or the strict reader throws — UNKNOWN) and Invention is null, leaving the edit UI without the updated entity. Additionally the Invention payload itself is missing keys (see the ToWire finding). Route, GET verb, and all four one-of query params (name/description/imgName/permission) bind and persist correctly. But the response is a bare ToWire invention object; the client deserializes BDNCJIPHHOK {"Status":Int32,"Invention":obj,"InventionVersion":obj} — none of those keys exist in the reply, so Status reads default (or the strict reader throws — UNKNOWN) and Invention is null, leaving the edit UI without the updated entity. Additionally the Invention payload itself is missing keys (see the ToWire finding).
@@ -2831,11 +2827,17 @@ Handler: `DorkNet.Server\Controllers\API\Inventions\InventionsController.cs:388`
**Fix.** Return Ok(new { IsCheering = await db.Cheers.AnyAsync(c => c.FromPlayerId == pid && c.TargetInventionId == id), Invention = ToWire(i), CanEdit = ... }) — extra keys harmless. **Fix.** Return Ok(new { IsCheering = await db.Cheers.AnyAsync(c => c.FromPlayerId == pid && c.TargetInventionId == id), Invention = ToWire(i), CanEdit = ... }) — extra keys harmless.
##### `GET showcase/{accountId}` — MISSING (degraded) ##### `GET showcase/{accountId}` — FIXED
No route anywhere in the server (grep across all controllers) for the rooms-service client's showcase list. The client expects a bare JSON array of Int64 room ids; a 404 means the profile 'showcase' room list never loads for any player. Was missing everywhere (404), so the profile 'Showcase Rooms' carousel (`RoomListModel.JMGLGEKHHAK.PlayerShowcaseRooms = 7`, fed by `AccountModelController.RoomsShowcaseLinkImpl`) never loaded for any player.
**Fix.** Add GET showcase/{accountId:int} (RoomsController area, same host as rooms/*): return the account's showcased room ids — interim implementation: the player's public created rooms' ids as long[]; proper implementation: a ShowcaseRoomEntity ordering table with a set endpoint when the client's write path is traced. Now served by `RoomsController.RoomsShowcase` on both `showcase/{accountId:long}` and `roomserver/showcase/{accountId:long}` (the issuing client NLDBPDCNNCF carries the `roomserver/` prefix in this deployment).
Verb GET is the ordinal 0 moved into rdx before the dispatch call at `NLDBPDCNNCF.txt:4494`, with the literal at ISIL 021 `:4484`; no cmov, so GET only. The response is a **bare JSON array of Int64 room ids** — the issuing method returns `Task<List<System.Int64>>` (`NLDBPDCNNCF.txt:4422`). Room objects are a client-side projection: `IBEOONPEELF.EJPNHLIJNPM` (`IBEOONPEELF.txt:8952`) resolves the ids through the room cache into `FGLDKEJLAKB<IReadOnlyList<NEMINAEBALC>>`, and the cache entry `<GetRoomsShowcase>b__0` is typed `Task<IReadOnlyList<Int64>>` (`IBEOONPEELF_NestedType_HHDHJPIJBJB.txt:14`).
Backing data: `showcase/{0}` is the only showcase literal in the binary — the client reads the list but never writes it — so curation is stored in the existing player-settings table under the key `rooms:showcase` (CSV of room ids), writable via `PUT /settings/v1/{accountId}` with no new schema. Curated ids are re-validated against Rooms on each read (archived/privatised rooms drop out) and returned in the curated order; a player who never curated one falls back to the rooms they created, ordered by HotScore/VisitCount/UpdatedAt. Public rooms only, except the owner viewing their own profile.
Handler: `DorkNet.Server\Controllers\API\Rooms\V2\RoomsController.cs` (`RoomsShowcase`)
##### `GET unity_assets/{0}/{1}/{2}` — SHAPE_MISMATCH (degraded) ##### `GET unity_assets/{0}/{1}/{2}` — SHAPE_MISMATCH (degraded)
@@ -2869,11 +2871,15 @@ Handler: `DorkNet.Server\Controllers\API\Inventions\InventionsController.cs:720`
**Fix.** Return Ok(new { Success = true, Message = string.Empty }). **Fix.** Return Ok(new { Success = true, Message = string.Empty }).
##### `POST remote-run/push-to-studio` — MISSING (none) ##### `POST remote-run/push-to-studio` — IMPLEMENTED
Not registered anywhere (grep). This is the Rec Room Studio dev-flow push (paired with the 'rrs.pushtodevice' notification topic); normal gameplay never issues it, so a 404 only affects a Studio-connected developer session. Handler: `DorkNet.Server\Controllers\API\Inventions\InventionsController.cs` → `PushToStudio`, routed at both `remote-run/push-to-studio` and `roomserver/remote-run/push-to-studio`.
**Fix.** If Studio pairing is ever in scope: add POST remote-run/push-to-studio accepting FNAGBPCAGJD {SessionId, RoomId, SubRoomId, UnityAssetId, RoomData{Filename,Hash,OwnershipProof}, SubRoomData{...}, SavedByAccountId?} and returning CEELGOLBHIL {SessionId, RoomId, SubRoomId, UnityAssetId, RoomDataFilename, RoomDataHash, SubRoomDataFilename, SubRoomDataHash}; otherwise scope it out explicitly in docs. Verb POST from EHIOLHBGODG.txt:411-417 (route literal into r9, `Move rdx, 2` = HTTPMethods.Post, no cmov). Body is a RAW JSON document — :433 passes the serialized DTO to `BNDIAONDFFF.FJLLPHFOOJJ` (RawJsonForm, `application/json`) — bound through `FormOrJsonModelBinder`. Request FNAGBPCAGJD keys are literal in the generated serializer OFMNNCMPEPA.txt:535-698: `SessionId, RoomId, SubRoomId, UnityAssetId, RoomData, SubRoomData, SavedByAccountId`; each blob ref is `{Filename, Hash, OwnershipProof}` (FLELPHJDLNG.txt:255/274/290). Response is a single CEELGOLBHIL — eight keys, literal in its reader PPHLKNGCGOE.txt:599-786: `SessionId, RoomId, SubRoomId, UnityAssetId, RoomDataFilename, RoomDataHash, SubRoomDataFilename, SubRoomDataHash`; RoomId/SubRoomId are `Nullable<Int64>` (CEELGOLBHIL.txt:25/51) and the two blob refs are FLATTENED (OwnershipProof is not echoed). A non-2xx or malformed reply shows the client's "Failed to push to Rec Room Studio" toast (EHIOLHBGODG.txt:445).
This is the Studio remote-run handoff, a sibling of the normal save commit, not a replacement for it (client-side: OGPDOMCNIFM.txt:271 `rooms/{id}/subrooms/{id}/data` vs :449 push) — so the server does NOT touch `RoomEntity.CurrentDataBlobName`. It authorizes the caller like a room save (creator / admin / accepted CoOwner), requires both pushed filenames to resolve to real `RoomDataBlobEntity` rows (the client uploads them immediately beforehand), stamps the sub-room save blob with its `RoomId`/`SubRoomId` so it shows up in that sub-room's `datahistory`, records the pushed `UnityAssetId` on the `RoomSceneEntity`, writes `SessionId` into `RoomEntity.StudioSessionId`, and keeps the full push under `remoterun:{SessionId}` in `PlayerSettingEntity`. `IsRoomLinkedToRecRoomStudio` is intentionally left alone (it changes in-room MakerPen UI and the client has no unlink call).
**Still out of scope:** live relay of the details to a second logged-in session. The client's receive side (`EHIOLHBGODG.MCPBJLHFOBC(CEELGOLBHIL)` → the `Action<CEELGOLBHIL>` subscribed at MBOJJFBIAGE.txt:366/978) has no call site anywhere in the IsilDump, so the notification id carrying it cannot be established from the binary; `PushNotificationId` gets no invented member for it.
### Storefront, purchases and subscriptions ### Storefront, purchases and subscriptions
@@ -2963,9 +2969,11 @@ Handler: `DorkNet.Server/Controllers/API/Inventions/InventionsController.cs:345`
**Fix.** Accept the form field (e.g. [FromForm] long inventionId with [FromQuery] fallback) and return { Status = <success enum>, Invention = ToWire(i), InventionVersion = <latest version wire> }. **Fix.** Accept the form field (e.g. [FromForm] long inventionId with [FromQuery] fallback) and return { Status = <success enum>, Invention = ToWire(i), InventionVersion = <latest version wire> }.
##### `POST subscription/{accountId:int}` — MISSING (breaks-gameplay) ##### `POST subscription/{accountId:int}` — FIXED (was MISSING, breaks-gameplay)
No handler accepts POST or DELETE on subscription/{accountId} anywhere in the server (verified by grepping all HttpPost/HttpDelete attributes under subscription/: only /subscription/v1/* POSTs exist, whose literal 'v1' segment cannot match a numeric account id; the follow-graph mutations live at api/playersubscriptions/v1/subscribe/{targetId} which the 2023 client never calls). The client's subscribe (POST, form field roomId:Int64, omitted when -1) and unsubscribe (DELETE, no body) both 404 → 'Failed to subscribe to {0}' / 'Failed to unsubscribe from {0}' toasts; creator-club subscribing is entirely broken from the 2023 client. Implemented in `DorkNet.Server/Controllers/API/Subscriptions/SubscriptionsController.cs`: `[HttpPost("/subscription/{accountId:long}")]` binds the optional `roomId` form field through `FormOrJsonModelBinder` and `[HttpDelete("/subscription/{accountId:long}")]` takes no body. Both write/remove the canonical player→player `Subscriptions` row AND mirror into `ClubSubscriptions` for the target's creator club (oldest club they own, the same resolution `subscription/details` reports as `ClubId`) so the client's post-write refresh of `subscription/mine/member` actually shows the subscription. Both are idempotent and return a bare 200 (the client parses no body). `roomId` is attribution only — no column exists for it, so it is logged rather than persisted.
Original finding: No handler accepts POST or DELETE on subscription/{accountId} anywhere in the server (verified by grepping all HttpPost/HttpDelete attributes under subscription/: only /subscription/v1/* POSTs exist, whose literal 'v1' segment cannot match a numeric account id; the follow-graph mutations live at api/playersubscriptions/v1/subscribe/{targetId} which the 2023 client never calls). The client's subscribe (POST, form field roomId:Int64, omitted when -1) and unsubscribe (DELETE, no body) both 404 → 'Failed to subscribe to {0}' / 'Failed to unsubscribe from {0}' toasts; creator-club subscribing is entirely broken from the 2023 client.
**Fix.** Add to PlayerSubscriptionsController (or ClubsController): [HttpPost("/subscription/{accountId:long}")] reading optional form field roomId and creating the subscription row (same semantics as Subscribe), and [HttpDelete("/subscription/{accountId:long}")] removing it. Return 200 with empty/any body (client ignores it, then refreshes via subscription/mine/member). Keep the row source consistent with what subscription/mine/member reads (ClubSubscriptions vs Subscriptions — currently mine/member reads ClubSubscriptions while the playersubscriptions endpoints write Subscriptions; the new handlers must write the table mine/member reads, or the subscribe will never show up). **Fix.** Add to PlayerSubscriptionsController (or ClubsController): [HttpPost("/subscription/{accountId:long}")] reading optional form field roomId and creating the subscription row (same semantics as Subscribe), and [HttpDelete("/subscription/{accountId:long}")] removing it. Return 200 with empty/any body (client ignores it, then refreshes via subscription/mine/member). Keep the row source consistent with what subscription/mine/member reads (ClubSubscriptions vs Subscriptions — currently mine/member reads ClubSubscriptions while the playersubscriptions endpoints write Subscriptions; the new handlers must write the table mine/member reads, or the subscribe will never show up).
@@ -3089,13 +3097,13 @@ Handler: `DorkNet.Server/Controllers/API/PlayerSubscriptions/PlayerSubscriptions
**Fix.** Join each top AccountId to its creator club (db.Clubs by CreatorPlayerId) and add ClubId/clubId to the per-row dictionary. **Fix.** Join each top AccountId to its creator club (db.Clubs by CreatorPlayerId) and add ClubId/clubId to the per-row dictionary.
##### `POST api/AppIntegrity/v1/iosproducts` — VERB_MISMATCH (none) ##### `POST api/AppIntegrity/v1/iosproducts` — FIXED (was VERB_MISMATCH)
Server registers GET only; client POSTs a JSON product array and expects {Success:Boolean,Message:String} (PHMHCPEMABG). A POST would 405, and even the GET body (list of {ProductId,Price,CurrencyType,Name}) is not the expected shape. iOS-only StoreKit check — never fired by the PC build. Server registered GET only; the client POSTs a raw JSON product array (verb `rdx=2` at GAAPALMODOL.txt:106, route literal :104, raw-JSON body via BNDIAONDFFF.FJLLPHFOOJJ :138) and expects {Success:Boolean,Message:String} (PHMHCPEMABG.txt:3/23, keys GBPDOLJBABB.txt:191-218). The POST 405'd, and the GET body (list of {ProductId,Price,CurrencyType,Name}) is not the expected shape either. iOS-only StoreKit report — never fired by the PC build.
Handler: `DorkNet.Server/Controllers/API/AppIntegrity/AppIntegrityController.cs:13` Handler: `DorkNet.Server/Controllers/API/Compatibility/CompatibilityFeatureController.cs` (POST) — the pre-existing GET at `DorkNet.Server/Controllers/API/AppIntegrity/AppIntegrityController.cs:13` is left in place; the two verb constraints are disjoint so they do not collide.
**Fix.** In AppIntegrityController.cs change to [HttpPost("api/AppIntegrity/v1/iosproducts")], read (and ignore) the JSON body, return Ok(new { Success = true, Message = "" }). **Fixed.** The POST reads the top-level array of {Name:String,Price:Single,ProductId:String} (GAAPALMODOL/KOLPHINEHDD, key table KAONMCEONPF.txt:255-306) and stores the reported StoreKit catalogue as the caller's `appintegrity:iosproducts` PlayerSettings row (`{productId}={price}` pairs, entries dropped once the 1024-char column budget is spent), replacing any earlier report. Returns {Success:true, Message:""}; a malformed body returns Success=false with Message `malformed_product_list`.
### Progression, quests, leaderboards and misc ### Progression, quests, leaderboards and misc
@@ -3332,7 +3340,7 @@ DESERIALIZATION: every DTO has a generated formatter that registers each JSON ke
KEY 2023-vs-2020 DELTA: api/config/v2 reader (CONDPLKKBPI via CDEDNEPPPND) reads ONLY LevelProgressionMaps/DailyObjectives/ServerMaintenance/AutoMicMutingConfig/StorefrontConfig/RoomKeyConfig/RoomCurrencyConfig/ShareBaseUrl. The 2020-era keys DorkNet also sends (MessageOfTheDay, CdnBaseUri, PhotonConfig, MatchmakingParams, ConfigTable, ServiceUrls) are ignored by the 2023 client. New nested key: LevelProgressionMaps[].GiftDropId (Int32?). KEY 2023-vs-2020 DELTA: api/config/v2 reader (CONDPLKKBPI via CDEDNEPPPND) reads ONLY LevelProgressionMaps/DailyObjectives/ServerMaintenance/AutoMicMutingConfig/StorefrontConfig/RoomKeyConfig/RoomCurrencyConfig/ShareBaseUrl. The 2020-era keys DorkNet also sends (MessageOfTheDay, CdnBaseUri, PhotonConfig, MatchmakingParams, ConfigTable, ServiceUrls) are ignored by the 2023 client. New nested key: LevelProgressionMaps[].GiftDropId (Int32?).
SERVER GAPS FOUND (routes the 2023 client can hit that DorkNet does not register, per the server's reflected route table): (1) POST actionlink, GET actionlink/{code}, POST actionlink/{code}/consume — Link host; (2) PUT datalink, GET datalink/{code} — Link host; (3) POST feed/query — Discovery host (the existing GET "feed" is api/photos/v1/feed, unrelated); (4) GET announcements/mine and GET announcements/subscription/mine — Clubs host; (5) POST announcements/club/{clubId} (create at collection URL — ClubsController only registers POST at .../{announcementId}); (6) POST api/testcasemanagement/v1/testcase/{id}/comment. Also verify announcements/v2/*/unread handlers honor ?sendAnnouncements=true by embedding Announcements arrays. SERVER GAPS FOUND (routes the 2023 client can hit that DorkNet does not register, per the server's reflected route table): (1) POST actionlink, GET actionlink/{code}, POST actionlink/{code}/consume — Link host [NOW FIXED, LinkController.cs]; (2) PUT datalink, GET datalink/{code} — Link host [NOW FIXED, LinkController.cs]; (3) POST feed/query — Discovery host (the existing GET "feed" is api/photos/v1/feed, unrelated); (4) GET announcements/mine and GET announcements/subscription/mine — Clubs host; (5) POST announcements/club/{clubId} (create at collection URL — ClubsController only registers POST at .../{announcementId}); (6) POST api/testcasemanagement/v1/testcase/{id}/comment. Also verify announcements/v2/*/unread handlers honor ?sendAnnouncements=true by embedding Announcements arrays.
SERVER SHAPE MISMATCHES (working but lossy): api/config/v1/azurespeech — client key is "Key", server sends "SubscriptionKey" (client gets null Key); api/config/v1/amplitude — client also reads RudderStackKey/UseRudderStack/StatSigKey; api/config/v1/backtrace — client's 9 keys (ANRThresholdMs, CaptureNativeCrashes, FilterType, LogLineCount, MessageCount, MessageRegex, ReportBudget, SampleRate, VersionRegex) are mostly absent from the server response (only SampleRate/ReportBudget present); all fields silently default today. SERVER SHAPE MISMATCHES (working but lossy): api/config/v1/azurespeech — client key is "Key", server sends "SubscriptionKey" (client gets null Key); api/config/v1/amplitude — client also reads RudderStackKey/UseRudderStack/StatSigKey; api/config/v1/backtrace — client's 9 keys (ANRThresholdMs, CaptureNativeCrashes, FilterType, LogLineCount, MessageCount, MessageRegex, ReportBudget, SampleRate, VersionRegex) are mostly absent from the server response (only SampleRate/ReportBudget present); all fields silently default today.
@@ -3445,17 +3453,13 @@ Handler: `DorkNet.Server\Controllers\Clubs\ClubsController.cs:737-742`
**Fix.** Make AnnouncementsForClub return one object: Ok(new { ClubId = clubId, LastReadAnnouncementId = <caller's last-read id or 0>, Announcements = rows.OrderByDescending(CreatedAt).Select(ToWireAnnouncement) }) — always an object, never an array, LastReadAnnouncementId always a number. **Fix.** Make AnnouncementsForClub return one object: Ok(new { ClubId = clubId, LastReadAnnouncementId = <caller's last-read id or 0>, Announcements = rows.OrderByDescending(CreatedAt).Select(ToWireAnnouncement) }) — always an object, never an array, LastReadAnnouncementId always a number.
##### `GET announcements/mine` — MISSING (degraded) ##### `GET announcements/mine` — FIXED
Not registered (server-routes.json has no announcements/mine; ClubsController's only 'mine' announcement routes are the v2 unread pair). Client expects a bare JSON array of JDPPAFLFNBD announcement objects for all of the player's own clubs; 404 rejects the promise and the aggregated club-announcements surface on the watch fails. `ClubsController.AnnouncementsMine` → `ClubService.AnnouncementsForMemberClubsAsync`. Flat `List<JDPPAFLFNBD>` of `ToWireAnnouncement` rows, newest first — no per-club envelope and no unread filter (the issuing method is `FGLDKEJLAKB<List<JDPPAFLFNBD>> IOCFHCFLIMJ()` at IKMMOCKDKAF.txt:996, described in-binary as "get all announcements for clubs I'm in"). Membership rows carrying the pending (128) or ban (256) marker are excluded so a pending/banned account never sees a club's board, and disbanded clubs are re-filtered on State.
**Fix.** Add [HttpGet("/announcements/mine")] [Authorize] in ClubsController returning a flat array of ToWireAnnouncement rows for every club the caller is a member of (reuse ClubService queries), newest first. ##### `GET announcements/subscription/mine` — FIXED
##### `GET announcements/subscription/mine` — MISSING (degraded) `ClubsController.AnnouncementsSubscriptionMine` → `ClubService.AnnouncementsForSubscribedClubsAsync`: same flat shape (`IIOMOLFOLPD` at IKMMOCKDKAF.txt:1083, route literal :1162), scoped to `ClubSubscriptions` rather than memberships.
Same as announcements/mine but scoped to clubs the player is subscribed to; not registered, 404s.
**Fix.** Add [HttpGet("/announcements/subscription/mine")] [Authorize] in ClubsController returning a flat array of ToWireAnnouncement rows for subscribed clubs (clubs.UnreadSubscriptionAsync's underlying subscription query, without the unread filter).
##### `GET announcements/v2/mine/unread (+ announcements/v2/subscription/mine/unread, with and without ?sendAnnouncements=true)` — SHAPE_MISMATCH (degraded) ##### `GET announcements/v2/mine/unread (+ announcements/v2/subscription/mine/unread, with and without ?sendAnnouncements=true)` — SHAPE_MISMATCH (degraded)
@@ -3465,35 +3469,35 @@ Handler: `DorkNet.Server\Controllers\Clubs\ClubsController.cs:151-167 (rows buil
**Fix.** In ClubsController.GroupByClub emit LastAnnouncementId = ordered.First().Id and LastReadAnnouncementId = <caller's read-marker or 0L> (a number, never null). Key casing is already fine (tri-cased reader). **Fix.** In ClubsController.GroupByClub emit LastAnnouncementId = ordered.First().Id and LastReadAnnouncementId = <caller's read-marker or 0L> (a number, never null). Key casing is already fine (tri-cased reader).
##### `GET actionlink/{code}` — MISSING (degraded) ##### `GET actionlink/{code}` — FIXED
No actionlink route exists anywhere in the server (repo-wide grep; PageviewController.cs:28-32 explicitly documents actionlink/* as out-of-scope). Client expects {CreatorPlayerId:Int32, Data:String, IsValid:Boolean} (NBAFGFFIOLK). Looking up a share/promo/invite code 404s. Was unrouted (PageviewController.cs:28-32 documented actionlink/* as out-of-scope). Now `DorkNet.Server\Controllers\Link\LinkController.cs` — `[HttpGet("/actionlink/{code}")]`, `[AllowAnonymous]` because `ActionCode.ModifyPreAuthLaunchTarget` resolves an inbound deep link during BootSequence, before the player holds a token. Verb from CKBKHENHCAN.txt:793 (`045 Move rdx, 0`), host 11 at :798. Response is the camelCase anonymous object `{creatorPlayerId, data, isValid}`, which the tri-cased generated formatter DPIECEMDKPL accepts (:267/:278/:286 CreatorPlayerId, :294/:302 Data, :310/:318/:326 IsValid). Unknown/expired codes answer 200 with `IsValid:false` (never 404) so the promise resolves. Reading a code does not spend a use.
**Fix.** Add an ActionLinkController (Link host): GET /actionlink/{code} returning {CreatorPlayerId, Data, IsValid} from a new action-link table (IsValid:false for unknown codes rather than 404, so the client gets a parseable body). ##### `POST actionlink` — FIXED
##### `POST actionlink` — MISSING (degraded) Now `LinkController.ActionLinkCreate` — `[HttpPost("/actionlink")]`, `[Authorize]`. Verb from CKBKHENHCAN.txt:1226 (`106 Move rdx, 2`), host `105 Move r8, 11` at :1225. Form fields bound through `FormOrJsonModelBinder`: data (:1234), validHours (:1249), maxCount (:1261), codeType (:1273), extraDataId (:1287). Return type is `FGLDKEJLAKB<System.String>` (:860) so the body is a bare JSON string — emitted with `Content(JsonSerializer.Serialize(code), "application/json")`, because `Ok(string)` goes out as unquoted text/plain via StringOutputFormatter.
Create-code endpoint absent. Client sends url-encoded form fields data/validHours/maxCount/codeType/extraDataId (maxCount and extraDataId omitted when null) and deserializes a BARE JSON string (the generated code). 404 breaks creating share/invite codes. Codes are 8 characters from the unambiguous alphabet `23456789ABCDEFGHJKLMNPQRSTUVWXYZ` (no O/0/I/1 — players re-type these into ActionCodeConsumptionModel), reserved with a global uniqueness check before insert.
**Fix.** Same ActionLinkController: POST /actionlink reading the five form fields, generating a short unique code, persisting {code, data, creatorPlayerId, expiry=now+validHours, maxCount, codeType, extraDataId}, and returning the code serialized as a JSON string (e.g. return Ok(code) with string result → "ABC123"). ##### `POST actionlink/{code}/consume` — FIXED
##### `POST actionlink/{code}/consume` — MISSING (degraded) Now `LinkController.ActionLinkConsume` — `[HttpPost("/actionlink/{code}/consume")]`, `[AllowAnonymous]` (can fire from the pre-auth launch-target path). Verb from CKBKHENHCAN.txt:1675 (`077 Move rdx, 2`); path built by `String.Concat("actionlink/", WebUtility.UrlEncode(code), "/consume")` at :1665-1669. Form fields: id (:1684), validHours (:1692), codeType (:1720), newPlayer (:1732), newInstall (:1745).
Redeem endpoint absent. Client POSTs form fields id/validHours/codeType/newPlayer/newInstall to actionlink/{urlencoded code}/consume and expects {CreatorPlayerId, Data, IsValid}. 404 breaks code redemption (entered codes / deeplinks). Semantics established from the call sites: `validHours` is the creating ActionCode's `Configuration.AutoRenewHours`, loaded immediately before dispatch (`043 Call Configuration.get_AutoRenewHours`, RoomCode_NestedType_IBLIEDEHMPI.txt:168; ClubCode_NestedType_EAGCMJOEANN.txt:78) and null when AutoRenew is off — so a present value renews the code's expiry instead of letting it age out. `codeType` is verified against the stored type (CBKKGBIJHAL: Unknown=-1, Friend=0, Referral=1, Meetup=2, Club=3, PlayerEvent=4, Room=5, Influencer=6, Photo=7; RoomCode passes 5 at IBLIEDEHMPI.txt:175, ClubCode passes 3 at EAGCMJOEANN.txt:85) so a room payload cannot be handed to the club handler. Expiry and maxCount are enforced; a successful redeem increments the use counter.
**Fix.** Same ActionLinkController: POST /actionlink/{code}/consume — look up the code, enforce expiry/maxCount, return {CreatorPlayerId, Data, IsValid} (IsValid:false when invalid/expired). Referral codes are the only type with no other server touchpoint — ReferralCode has no HTTP routes of its own and `IncentivizedReferralsController` counts `referral:credited:{inviterId}:{inviteeId}` rows that nothing wrote. Consuming a Referral code as a signed-in `newPlayer` now writes that row (on the invitee, matching the `externalinvite:redeemed:*` convention), so incentivized-referral progress is no longer permanently zero.
##### `GET datalink/{code}` — MISSING (degraded) ##### `GET datalink/{code}` — FIXED
Absent. Client expects {Data:String} (LBBKBMPAEIO via IKBPJACLIHH, tri-cased). 404 breaks fetching share payload blobs. Now `DorkNet.Server\Controllers\Link\LinkController.cs` — `[HttpGet("/datalink/{code}")]`, `[AllowAnonymous]` (resolved from a cold-boot deep link). Verb from CKBKHENHCAN.txt:352 (`045 Move rdx, 0`), literal `"datalink/"` at :345. Response is `{data}`; the generated formatter IKBPJACLIHH registers a single member Data (:139) plus the camelCase alias (:150). An unknown code returns an empty Data rather than 404 so the strict reader still gets an object.
**Fix.** Add DataLinkController (Link host): GET /datalink/{code} returning {Data:"..."} from a datalink table. ##### `PUT datalink` — FIXED
##### `PUT datalink` — MISSING (degraded) Now `LinkController.DataLinkCreate` — `[HttpPut("/datalink")]`, `[Authorize]`. Verb from CKBKHENHCAN.txt:560 (`033 Move rdx, 3` = PUT), literal `"datalink"` at :558, sole form field `data` at :569, bound through `FormOrJsonModelBinder`. Return type `FGLDKEJLAKB<System.String>` (:419) — bare JSON string, emitted with `Content(JsonSerializer.Serialize(code), "application/json")`. Codes here are 10 characters (nobody types a datalink code; it only travels inside a URL).
Absent. Client PUTs form field data=String and deserializes a BARE JSON string (the generated code). 404 breaks storing share payloads (share flows that pair with datalink/{code}). ##### Link-host storage note
**Fix.** Same DataLinkController: PUT /datalink reading form field 'data', persisting under a generated short code, returning the code as a JSON string. All five link routes persist in `PlayerSettings` — the server's existing general-purpose per-player key/value table already used for ban-appeal codes and external friend invites — under `actionlink:{CODE}` / `datalink:{CODE}` keys owned by the creator's row, so codes survive restarts without a new table. Action-link rows pack `{d:data, e:expiry, m:maxCount, u:uses, t:codeType, x:extraDataId}` into the single Value column with one-character keys; because `PlayerSettingEntity.Value` is `[MaxLength(1024)]` (a real `varchar(1024)` on Postgres), both create routes reject over-long payloads with a logged 400 instead of blowing up at SaveChanges.
##### `POST api/testcasemanagement/v1/testcase/{id}/status` — SHAPE_MISMATCH (degraded) ##### `POST api/testcasemanagement/v1/testcase/{id}/status` — SHAPE_MISMATCH (degraded)