mirror of
https://github.com/DorkSquadRR/DorkNet.git
synced 2026-10-25 23:18:44 -07:00
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:
@@ -18,7 +18,9 @@ public sealed class GameClientEndpointContractTests :
|
||||
[Fact]
|
||||
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);
|
||||
|
||||
using var client = _factory.CreateClient(new()
|
||||
@@ -48,6 +50,26 @@ public sealed class GameClientEndpointContractTests :
|
||||
+ 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(
|
||||
HttpClient client,
|
||||
EndpointContract route,
|
||||
|
||||
@@ -1,59 +1,470 @@
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using DorkNet.Server.Auth;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
|
||||
namespace DorkNet.Server.Controllers.API.Comments;
|
||||
|
||||
/// <summary>
|
||||
/// Room-comment unread counts. Client contract (RecNet.Runtime
|
||||
/// <c>OPELMBNJHNO.DJLNMANKMAA(IReadOnlyCollection<long>)</c>).
|
||||
/// Room comments ("feedback"/"idea" notes players leave in a room, shown by
|
||||
/// the watch's RoomCommentsScreen / RoomCommentsPagedGrid).
|
||||
///
|
||||
/// The response is a JSON <b>ARRAY</b>, not an object. The method's return
|
||||
/// type is <c>Dictionary<long,uint></c>, but that dictionary is built
|
||||
/// client-side: OPELMBNJHNO.txt:889 constructs a
|
||||
/// <c>Func<List<UnreadRoomComments>, Dictionary<long,uint>></c>
|
||||
/// projection that runs over the deserialised payload. So the wire type is
|
||||
/// <c>List<RecNet.UnreadRoomComments></c> — each element an object with a
|
||||
/// room id (Int64) and a count (UInt32), per the two properties on
|
||||
/// RecNet/UnreadRoomComments.txt.
|
||||
/// Every route here is issued by RecNet.Runtime's room-comment service
|
||||
/// <c>OPELMBNJHNO</c> against service host <c>12 = RoomComments</c>
|
||||
/// (<see cref="Services.ConfigService"/> maps that to the <c>roomcomments</c>
|
||||
/// subdomain). The five literals, with the <c>BNDIAONDFFF</c> ctor verb
|
||||
/// ordinal moved into <c>rdx</c> immediately before the builder call
|
||||
/// (BestHTTP.HTTPMethods: 0=GET, 2=POST, 3=PUT, 4=DELETE):
|
||||
///
|
||||
/// This previously returned an object map keyed by room id. Json.NET cannot
|
||||
/// read a JSON object into a <c>List<T></c>, so every call threw and the
|
||||
/// client logged "Failed to get unread room comment counts"
|
||||
/// (OPELMBNJHNO.txt:937) and fell back to an empty dictionary — the watch's
|
||||
/// room-comment unread badge never appeared.
|
||||
/// <list type="bullet">
|
||||
/// <item><c>OPELMBNJHNO.txt:485</c> "comments/get/{0}" — rdx=0 (GET), host [rdx+12]</item>
|
||||
/// <item><c>OPELMBNJHNO.txt:811</c> "comments/unreadcounts" — dynamic GET/POST</item>
|
||||
/// <item><c>OPELMBNJHNO.txt:1216</c> "comments/create/{0}" — rdx=2 (POST), host [r12+12]</item>
|
||||
/// <item><c>OPELMBNJHNO.txt:1627</c> "comments/read/{0}/{1}" — rdx=3 (PUT), host [rbx+12]</item>
|
||||
/// <item><c>OPELMBNJHNO.txt:1935</c> "comments/delete/{0}" — rdx=4 (DELETE), r8=12</item>
|
||||
/// </list>
|
||||
///
|
||||
/// 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 all zero until per-player comment read-state is tracked; the
|
||||
/// shape is what matters for the client not to throw.
|
||||
/// STORAGE. There is no <c>RoomCommentEntity</c> and adding entities
|
||||
/// needs a migration, so comments live in the <see cref="PlayerSettingEntity"/>
|
||||
/// side-table exactly like the PlayerEvent extras
|
||||
/// (<c>PlayerEventsController.EventExtras</c>): one row per comment with
|
||||
/// <c>PlayerId</c> = the author, <c>Key = roomcomment:{roomId}:{commentId}</c>
|
||||
/// and <c>Value</c> = the JSON payload. Per-player read state is a second,
|
||||
/// disjoint namespace — <c>roomcommentread:{roomId}</c> holding the last-read
|
||||
/// comment id — which is what finally lets
|
||||
/// <see cref="UnreadCounts"/> report real numbers instead of zeroes.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
public class CommentsController : 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<Vector3></c> property is <c>[IgnoreDataMember]</c> and is
|
||||
/// rebuilt after deserialisation from three internal
|
||||
/// <c>Nullable<float></c> fields (RecNet/RoomComment.txt:495
|
||||
/// <c>FKDDCNLJOLF</c> reads the HasValue byte at <c>[rbx+0x6C]</c>, i.e. the
|
||||
/// first of the three at 0x68/0x70/0x78).
|
||||
///
|
||||
/// Public (not private) so System.Text.Json's reflection-based
|
||||
/// (de)serialiser can see the type and its accessors.</summary>
|
||||
public sealed class CommentPayload
|
||||
{
|
||||
public long? SubRoomId { get; set; }
|
||||
public string Message { get; set; } = string.Empty;
|
||||
/// <summary><c>RoomComment.EKHGDABMJOJ</c>: Feedback=0, Idea=1, BugReport=2
|
||||
/// (RecNet/RoomComment.cs enum member order).</summary>
|
||||
public int Style { get; set; }
|
||||
public float? X { get; set; }
|
||||
public float? Y { get; set; }
|
||||
public float? Z { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
}
|
||||
|
||||
// ── Reads ────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Comment list for a room.
|
||||
///
|
||||
/// <c>OPELMBNJHNO.GMBPJHCBPDP(Int64 roomId, Int32 count, Int64 minId)</c>
|
||||
/// returns <c>FGLDKEJLAKB<List<RecNet.RoomComment>></c>
|
||||
/// (OPELMBNJHNO.txt:341), so the body is a bare JSON <b>array</b> — not a
|
||||
/// paged <c>{Results,TotalResults}</c> container. Verb is GET (rdx=0 at
|
||||
/// instr 044) and both parameters go on the query string, because
|
||||
/// <c>BNDIAONDFFF.AFGEDDANEKP</c> only promotes fields to a form body for
|
||||
/// non-GET verbs: literals "count" (:506) and "minId" (:518).
|
||||
///
|
||||
/// <c>minId</c> is a lower bound on the comment id (the only caller,
|
||||
/// FeedbackManager.txt:948, passes count=100 and a carried-forward id), so
|
||||
/// it is applied inclusively and the newest <c>count</c> matches are
|
||||
/// returned newest-first — which is the order the paged grid renders.
|
||||
///
|
||||
/// Failure toast if this 404s or the body will not parse:
|
||||
/// "Failed to get room comments for room: {0}" (:537).</summary>
|
||||
[HttpGet("/comments/get/{roomId:long}")]
|
||||
public async Task<IActionResult> Get(long roomId, [FromQuery] int count = 100, [FromQuery] long minId = 0)
|
||||
{
|
||||
if (count <= 0) count = 100;
|
||||
if (count > 500) count = 500;
|
||||
|
||||
var rows = await CommentRowsForRoomAsync(roomId);
|
||||
|
||||
var wire = rows
|
||||
.Where(r => r.CommentId >= minId)
|
||||
.OrderByDescending(r => r.CommentId)
|
||||
.Take(count)
|
||||
.Select(r => ToWire(r.CommentId, roomId, r.AuthorPlayerId, r.Payload, r.Unread))
|
||||
.ToList();
|
||||
|
||||
return Ok(wire);
|
||||
}
|
||||
|
||||
/// <summary>Room-comment unread counts. Client contract (RecNet.Runtime
|
||||
/// <c>OPELMBNJHNO.DJLNMANKMAA(IReadOnlyCollection<long>)</c>).
|
||||
///
|
||||
/// The response is a JSON <b>ARRAY</b>, not an object. The method's return
|
||||
/// type is <c>Dictionary<long,uint></c>, but that dictionary is built
|
||||
/// client-side: OPELMBNJHNO.txt:889 constructs a
|
||||
/// <c>Func<List<UnreadRoomComments>, Dictionary<long,uint>></c>
|
||||
/// projection that runs over the deserialised payload. So the wire type is
|
||||
/// <c>List<RecNet.UnreadRoomComments></c> — each element an object with a
|
||||
/// room id (Int64) and a count (UInt32), per the two properties on
|
||||
/// RecNet/UnreadRoomComments.txt.
|
||||
///
|
||||
/// This previously returned an object map keyed by room id. Json.NET cannot
|
||||
/// read a JSON object into a <c>List<T></c>, so every call threw and the
|
||||
/// client logged "Failed to get unread room comment counts"
|
||||
/// (OPELMBNJHNO.txt:937) and fell back to an empty dictionary — the watch's
|
||||
/// room-comment unread badge never appeared.
|
||||
///
|
||||
/// The element's exact property names are attribute-driven and are not
|
||||
/// recoverable from the ISIL (see the note on key aliasing below), so each
|
||||
/// element carries the plausible spellings side by side. Json.NET matches
|
||||
/// case-insensitively and ignores unknown members, so the extra aliases are
|
||||
/// inert whichever name the DTO actually declares.
|
||||
///
|
||||
/// Counts are real now that <see cref="MarkRead"/> persists per-player read
|
||||
/// state: a room's count is the number of comments authored by somebody
|
||||
/// else whose id is above the caller's last-read id for that room.
|
||||
/// </summary>
|
||||
[HttpPost("/comments/unreadcounts")]
|
||||
[HttpGet("/comments/unreadcounts")]
|
||||
[Consumes("application/json", "application/x-www-form-urlencoded", "multipart/form-data")]
|
||||
public async Task<IActionResult> UnreadCounts()
|
||||
{
|
||||
var roomIds = await ReadRoomIdsAsync();
|
||||
if (roomIds.Count == 0) return Ok(new List<Dictionary<string, object>>());
|
||||
|
||||
var me = Me;
|
||||
var lastRead = await LastReadAsync(me);
|
||||
|
||||
// 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
|
||||
.Select(id => new Dictionary<string, object>
|
||||
{
|
||||
["RoomId"] = id,
|
||||
["UnreadCount"] = 0u,
|
||||
["Count"] = 0u,
|
||||
["UnreadCount"] = counts[id],
|
||||
["Count"] = counts[id],
|
||||
})
|
||||
.ToList();
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
// ── Writes ───────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Posts a new comment into a room.
|
||||
///
|
||||
/// <c>OPELMBNJHNO.JHABIKEMJHK(Int64 roomId, Int64 subRoomId, String message,
|
||||
/// RoomComment/EKHGDABMJOJ style, Nullable<Vector3> position)</c>
|
||||
/// (OPELMBNJHNO.txt:940) returns <c>FGLDKEJLAKB<RecNet.RoomComment></c> —
|
||||
/// a <b>single</b> comment object, not a list and not a status envelope.
|
||||
///
|
||||
/// Verb is POST (rdx=2 at instr 069), so every <c>AFGEDDANEKP</c> field is
|
||||
/// an <c>application/x-www-form-urlencoded</c> body field, never a query
|
||||
/// param. Field literals, in emission order: "message" (:1234),
|
||||
/// "subRoomId" (:1249, boxed Int64), "style" (:1261, boxed <b>UInt32</b>),
|
||||
/// then — only when the <c>Vector3?</c> has a value (the
|
||||
/// <c>Compare [rdi+12], r12 / JumpIfEqual</c> at :1268) — "positionX"
|
||||
/// (:1292), "positionY" (:1314), "positionZ" (:1336), each formatted with
|
||||
/// <c>Single.ToString(CultureInfo.InvariantCulture)</c> (:1285/1289), which
|
||||
/// is why the three floats are parsed invariantly here rather than left to
|
||||
/// model binding.
|
||||
///
|
||||
/// Failure toast: "Failed to Create Comment" (:1359).</summary>
|
||||
[HttpPost("/comments/create/{roomId:long}")]
|
||||
public async Task<IActionResult> Create(
|
||||
long roomId,
|
||||
[FromForm] string? message,
|
||||
[FromForm] long? subRoomId,
|
||||
[FromForm] int style = 0,
|
||||
[FromForm] string? positionX = null,
|
||||
[FromForm] string? positionY = null,
|
||||
[FromForm] string? positionZ = null)
|
||||
{
|
||||
if (!await db.Rooms.AnyAsync(r => r.Id == roomId)) return NotFound();
|
||||
|
||||
message = (message ?? string.Empty).Trim();
|
||||
if (message.Length == 0) return BadRequest();
|
||||
// PlayerSettingEntity.Value is MaxLength(1024) and also carries the
|
||||
// rest of the payload; keep well clear of the column width.
|
||||
if (message.Length > 700) message = message[..700];
|
||||
|
||||
var payload = new CommentPayload
|
||||
{
|
||||
SubRoomId = subRoomId is > 0 ? subRoomId : null,
|
||||
Message = message,
|
||||
Style = style,
|
||||
X = ParseFloat(positionX),
|
||||
Y = ParseFloat(positionY),
|
||||
Z = ParseFloat(positionZ),
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
};
|
||||
// The client only ever sends all three or none; drop a partial set so
|
||||
// the client's Vector3 reassembly (RoomComment.FKDDCNLJOLF) sees a
|
||||
// consistent null.
|
||||
if (payload.X is null || payload.Y is null || payload.Z is null)
|
||||
payload.X = payload.Y = payload.Z = null;
|
||||
|
||||
var me = Me;
|
||||
var commentId = await NextCommentIdAsync();
|
||||
|
||||
db.PlayerSettings.Add(new PlayerSettingEntity
|
||||
{
|
||||
PlayerId = me,
|
||||
Key = $"{CommentPrefix}{roomId}:{commentId}",
|
||||
Value = JsonSerializer.Serialize(payload),
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
// Authoring a comment implies you have read up to it.
|
||||
await SetLastReadAsync(me, roomId, commentId);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return Ok(ToWire(commentId, roomId, me, payload, unread: false));
|
||||
}
|
||||
|
||||
/// <summary>Records the newest comment the caller has read in a room, which
|
||||
/// is what clears the watch's unread badge (and what makes
|
||||
/// <see cref="UnreadCounts"/> return non-zero numbers for everyone else).
|
||||
///
|
||||
/// <c>OPELMBNJHNO.MCOIDIBFHIP(Int64 roomId, Int64 commentId)</c>
|
||||
/// (OPELMBNJHNO.txt:1375) returns the 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()
|
||||
{
|
||||
var ids = new HashSet<long>();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using DorkNet.Server.Auth;
|
||||
@@ -109,6 +110,109 @@ public class CompatibilityFeatureController(DorkNetDbContext db) : ControllerBas
|
||||
public IActionResult Ps5RecRoomPlusEnabledForAllPlayers()
|
||||
=> 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<GAAPALMODOL/KOLPHINEHDD></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<PHMHCPEMABG></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")]
|
||||
[Authorize]
|
||||
[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; }
|
||||
}
|
||||
|
||||
/// <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
|
||||
{
|
||||
public long ClubId { get; set; }
|
||||
|
||||
@@ -590,6 +590,236 @@ public class CustomAvatarItemsController(
|
||||
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<Int32>. 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)
|
||||
{
|
||||
if (await db.CustomAvatarItemOwnership.AnyAsync(o =>
|
||||
@@ -971,6 +1201,18 @@ public class CustomAvatarItemsController(
|
||||
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<Int32> (+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 Guid? Id { get; set; }
|
||||
|
||||
@@ -322,6 +322,41 @@ public class ImagesController(
|
||||
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<IReadOnlyList<Int32>></c>, but that is a
|
||||
/// CLIENT-SIDE projection: a
|
||||
/// <c>Func<LGLCPNPJCEC, IReadOnlyList<Int32>></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
|
||||
/// <c>Images.DownloadNamedImageMappings</c> endpoint. Returns a
|
||||
/// list of <c>NamedImageDTO</c> entries; an empty list is a valid
|
||||
@@ -522,6 +557,51 @@ public class ImagesController(
|
||||
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) ─────────
|
||||
|
||||
public sealed class CheerImageRequest
|
||||
@@ -530,23 +610,48 @@ public class ImagesController(
|
||||
public long PhotoId { get; set; }
|
||||
}
|
||||
|
||||
public sealed class BulkCheeredRequest
|
||||
{
|
||||
public List<long>? SavedImageIds { get; set; }
|
||||
public List<long>? ImageIds { get; set; }
|
||||
}
|
||||
/// <summary>Field names the cheer-state batch accepts. The 2023-03-21
|
||||
/// client sends the singular <c>"id"</c>
|
||||
/// (<c>KLJOGJHBONK.txt:3763 Move rdx, "id"</c>); the older spellings are
|
||||
/// 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<Int64>)</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<LAKGLIDCEDE></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/v5/cheered/bulk")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> CheeredBulk([FromBody] BulkCheeredRequest? body)
|
||||
public async Task<IActionResult> CheeredBulk()
|
||||
{
|
||||
var pid = this.RequireCurrentPlayerId();
|
||||
var ids = (body?.SavedImageIds ?? body?.ImageIds ?? new())
|
||||
.Where(id => id > 0)
|
||||
.Distinct()
|
||||
.Take(200)
|
||||
.ToList();
|
||||
var ids = await ReadCheerBulkIdsAsync();
|
||||
if (ids.Count == 0) return Ok(Array.Empty<object>());
|
||||
|
||||
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>&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.
|
||||
/// Was previously Ack-only; now persists a <c>CheerEntity</c> row
|
||||
/// 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 });
|
||||
}
|
||||
|
||||
// ── 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<Int32></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<CEELGOLBHIL></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<Int64></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<CEELGOLBHIL></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 ─────────────────────────────────────────────────
|
||||
|
||||
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();
|
||||
|
||||
/// <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]
|
||||
public async Task<IActionResult> Mine()
|
||||
{
|
||||
await EnsureLoginKeepsakeAsync(Me);
|
||||
var rows = await db.Keepsakes
|
||||
.Where(k => k.PlayerId == Me)
|
||||
.Where(k => k.PlayerId == Me && k.Category != CollectionCategory)
|
||||
.OrderByDescending(k => k.EarnedAt)
|
||||
.ToListAsync();
|
||||
return Ok(rows.Select(ToWire));
|
||||
}
|
||||
|
||||
// KeepsakeCategoryConfigDTO = {KeepsakeCategoryId(enum), VisualId, LimitPerRoom, XpValue,
|
||||
// IconOutlineImageName, IconFilledImageName} - dump.cs:1234842-1234878; the accepted wire key
|
||||
// names are enumerated by its Utf8Json formatter (IsilDump/RecNet.Runtime/HIKOJENMDKE.txt:66-160
|
||||
// - "KeepsakeCategoryId"/"VisualId"/"LimitPerRoom"/"XpValue" plus camel/lower aliases).
|
||||
// KeepsakeCategoryId is the PIHCLHIKEPH enum (dump.cs:1199524: Explore=0, GreenPowerCore=1,
|
||||
// Present=2, PurplePowerCore=3, UnnamedKeepsakeNumber1..5 = 4..8), and the client folds this
|
||||
// list into a Dictionary<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")]
|
||||
[AllowAnonymous]
|
||||
public IActionResult Categories()
|
||||
{
|
||||
var results = 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 },
|
||||
new { KeepsakeCategoryId = 2, VisualId = "room", LimitPerRoom = 64, XpValue = 0, IconOutlineImageName = string.Empty, IconFilledImageName = string.Empty },
|
||||
};
|
||||
var results = CategoryConfigs
|
||||
.Select(c => new
|
||||
{
|
||||
KeepsakeCategoryId = c.Id,
|
||||
c.VisualId,
|
||||
LimitPerRoom = KeepsakeRoomLimit,
|
||||
XpValue = KeepsakeXpValue,
|
||||
IconOutlineImageName = string.Empty,
|
||||
IconFilledImageName = string.Empty,
|
||||
})
|
||||
.ToArray();
|
||||
return Ok(new
|
||||
{
|
||||
Results = results,
|
||||
@@ -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
|
||||
// JSON names live in Newtonsoft attribute metadata the dumps do not render, so they are
|
||||
// unverified - Json.NET matching is case-insensitive and ignores unknown members.
|
||||
//
|
||||
// CollectionRecords is what the CALLER has picked up, which is a different set from what the
|
||||
// caller PLACED: it is fed by the collect handler below (Category=CollectionCategory rows).
|
||||
var eventInstanceIds = rows
|
||||
.Select(r => TryParseInstanceKey(r.EventKey, out _, out _, out _, out var id) ? id : StableKeepsakeInstanceId(r.Id))
|
||||
.ToHashSet();
|
||||
var collected = (await LoadMyCollectionsAsync())
|
||||
.Where(c => eventInstanceIds.Contains(c.InstanceId))
|
||||
.GroupBy(c => c.RoomId)
|
||||
.Select(group => (object)new
|
||||
{
|
||||
RoomId = group.Key,
|
||||
KeepsakeInstanceIds = group.Select(c => c.InstanceId).Distinct().Select(id => id.ToString("D")).ToList(),
|
||||
})
|
||||
.ToList();
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
Instances = GroupInstanceIdsByRoom(rows),
|
||||
CollectionRecords = 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)
|
||||
.ToListAsync();
|
||||
var instances = rows.Select(row => ToRoomInstanceWire(row, roomId)).ToList();
|
||||
|
||||
// KeepsakeCollectionRecordDTO = {Int32 AccountId, Guid KeepsakeInstanceId, DateTime
|
||||
// CollectedAt} - dump.cs:1234642-1234666, wire keys confirmed by its Utf8Json formatter
|
||||
// (IsilDump/RecNet.Runtime/CNNAFLNKDCL.txt:48,75,99). Scoped to the caller: the DTO carries
|
||||
// AccountId so the client can filter, but returning only the caller's rows is correct under
|
||||
// either reading and keeps other players' pickups out of the payload.
|
||||
var collectPrefix = $"collect:{roomIdText}:";
|
||||
var me = Me;
|
||||
var collectionRecords = await db.Keepsakes
|
||||
.Where(k => k.PlayerId == me
|
||||
&& k.Category == CollectionCategory
|
||||
&& EF.Functions.Like(k.EventKey, collectPrefix + "%"))
|
||||
.ToListAsync();
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
Instances = instances,
|
||||
CollectionRecords = 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>(),
|
||||
});
|
||||
}
|
||||
|
||||
/// <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<DHNBKMHDANK></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")]
|
||||
[AllowAnonymous]
|
||||
public IActionResult GlobalConfig() => Ok(new
|
||||
{
|
||||
KeepsakeFeatureEnabled = true,
|
||||
KeepsakeRoomLimit = 64,
|
||||
SocialXpBoostEnabled = false,
|
||||
KeepsakeRoomLimit,
|
||||
SocialXpBoostEnabled,
|
||||
});
|
||||
|
||||
[HttpPost]
|
||||
@@ -353,6 +532,100 @@ public class KeepsakesController(DorkNetDbContext db, DomainConfig domain) : Con
|
||||
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) =>
|
||||
string.Equals(category, "event", StringComparison.OrdinalIgnoreCase) ? 1 :
|
||||
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.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
@@ -240,6 +242,318 @@ public class PhotosController(
|
||||
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<T>
|
||||
/// {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 ──────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Trim/dedupe tagged ids and drop the uploader's own id.
|
||||
|
||||
@@ -33,7 +33,8 @@ namespace DorkNet.Server.Controllers.API.PlayerEvents;
|
||||
[Authorize]
|
||||
public class PlayerEventsController(
|
||||
DorkNetDbContext db,
|
||||
NotificationService notifications) : ControllerBase
|
||||
NotificationService notifications,
|
||||
PlayerPresenceService presence) : ControllerBase
|
||||
{
|
||||
private long Me => this.RequireCurrentPlayerId();
|
||||
|
||||
@@ -351,6 +352,101 @@ public class PlayerEventsController(
|
||||
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+<GetEventInstanceBrowser>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<List<PNDCMIMEJLD>></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<Int32>/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 ───────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>The PlayerEvent fields the 2023 client carries that
|
||||
|
||||
@@ -1,15 +1,24 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using DorkNet.Server.Auth;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
using DorkNet.Server.Services;
|
||||
|
||||
namespace DorkNet.Server.Controllers.API.ProgressionEvents;
|
||||
|
||||
[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")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> Progress()
|
||||
@@ -39,30 +48,29 @@ public class ProgressionEventsController(DorkNetDbContext db, ServerSettingsServ
|
||||
|
||||
var weekly = await serverSettings.GetWeeklyChallengesAsync();
|
||||
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
|
||||
{
|
||||
ProgressionEventId = activeId,
|
||||
Name = "Weekly Progression",
|
||||
Rewards = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
ProgressionEventRewardId = activeId * 1000L,
|
||||
GiftDropId = weekly.Reward.GiftDropId != 0 ? weekly.Reward.GiftDropId : activeId,
|
||||
ImageName = string.Empty,
|
||||
Xp = weekly.Reward.Xp,
|
||||
RewardIndex = 0,
|
||||
IsBonus = false,
|
||||
},
|
||||
},
|
||||
Rewards = BuildRewards(weekly.Reward, activeId),
|
||||
KeepsakeRoomLists = Array.Empty<object>(),
|
||||
StartTime = start,
|
||||
EndTime = end,
|
||||
CollectionEndTime = end,
|
||||
UsesBoost = false,
|
||||
UsesBoost = headline is not null,
|
||||
BoostDailyGameplayMinutesLimit = 0,
|
||||
BoostXpMultiplier = 1.0,
|
||||
PurchasableXpBoostId = (Guid?)null,
|
||||
// ProgressionEventDTO.BoostXpMultiplier is a float at +0x50
|
||||
// (dump.cs:1198957) — emit a real number, not an integer token.
|
||||
BoostXpMultiplier = (double)(headline?.XpMultiplier ?? 1),
|
||||
PurchasableXpBoostId = headline?.Id,
|
||||
ActiveExperiment = string.Empty,
|
||||
ChallengesIconImageName = string.Empty,
|
||||
RewardsPipImageName = string.Empty,
|
||||
@@ -88,24 +96,349 @@ public class ProgressionEventsController(DorkNetDbContext db, ServerSettingsServ
|
||||
{
|
||||
var me = this.RequireCurrentPlayerId();
|
||||
|
||||
// Count this player's completed progression-event objectives so the
|
||||
// record reflects real progress rather than a flat zero.
|
||||
var completed = await db.ObjectiveProgress
|
||||
// Count the reward-claim sentinels this player owns FOR THIS EVENT
|
||||
// (written by Collect below). The previous query counted every
|
||||
// `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
|
||||
&& o.IsCompleted
|
||||
&& o.Key.StartsWith("progressionEvent:"));
|
||||
&& o.Key.StartsWith(claimPrefix));
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
ProgressionEventId = progressionEventId,
|
||||
Xp = 0,
|
||||
ClaimedRewardIndex = completed > 0 ? completed - 1 : -1,
|
||||
ClaimedRewardIndex = collected > 0 ? collected - 1 : -1,
|
||||
PurchasedXpBoostCount = 0,
|
||||
DailyBoostGameplayMinutes = 0,
|
||||
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<FFFIMAGLKEG/FHMABOHAEED></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<ProgressionEventPurchasableXpBoostDTO></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<System.Int32></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()
|
||||
{
|
||||
var (start, _) = CurrentWeekWindow();
|
||||
|
||||
@@ -2291,6 +2291,109 @@ public class RoomsController(
|
||||
public async Task<IActionResult> CreatedByOther(long otherPlayerId)
|
||||
=> 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<List<System.Int64>></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><GetRoomsShowcase>b__0</c> is typed
|
||||
/// <c>Task<IReadOnlyList<Int64>></c>
|
||||
/// (IBEOONPEELF_NestedType_HHDHJPIJBJB.txt:14) and the outer
|
||||
/// <c>IBEOONPEELF.EJPNHLIJNPM</c> resolves those ids through the room cache
|
||||
/// to <c>FGLDKEJLAKB<IReadOnlyList<NEMINAEBALC>></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/v2/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<IPCJLCNIBEG<Int64>, …></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,
|
||||
NotificationService notifications,
|
||||
ServerSettingsService settings,
|
||||
PlayerPresenceService presence,
|
||||
ILogger<AccountsController> logger) : ControllerBase
|
||||
{
|
||||
[HttpGet("/account/v1/savedlogins")]
|
||||
@@ -62,6 +63,102 @@ public class AccountsController(
|
||||
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>
|
||||
/// Resolves the player tied to the bearer token on the request. Returns
|
||||
/// 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 });
|
||||
}
|
||||
|
||||
/// <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<System.String></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 <= 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")]
|
||||
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<Boolean></c>
|
||||
/// over <c>FGLDKEJLAKB<Boolean></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("/account/v1/privacysettings/{accountId:long}")]
|
||||
|
||||
@@ -3325,132 +3325,12 @@ public class AdminController(
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
private async Task<string> DeletePlayerDataAsync(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}";
|
||||
}
|
||||
/// <summary>Delegates to <see cref="PlayerAccountPurge.PurgeAsync"/> —
|
||||
/// the cascade is shared with the 2023 client's self-service
|
||||
/// <c>DELETE account/me</c> so both paths erase exactly the same set
|
||||
/// of rows.</summary>
|
||||
private Task<string> DeletePlayerDataAsync(long playerId, long systemPlayerId)
|
||||
=> PlayerAccountPurge.PurgeAsync(db, playerId, systemPlayerId);
|
||||
|
||||
private Task LogAsync(string action, string targetType, long targetId, string reason)
|
||||
{
|
||||
|
||||
@@ -30,11 +30,23 @@ public class ChatController(
|
||||
DorkNetDbContext db,
|
||||
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 ChatInvalidArguments = 1;
|
||||
private const int ChatThreadNotFound = 2;
|
||||
private const int ChatMembershipNotFound = 3;
|
||||
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();
|
||||
|
||||
/// <summary>List the caller's chat threads, most-recently-active
|
||||
@@ -64,10 +76,34 @@ public class ChatController(
|
||||
.Distinct()
|
||||
.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
|
||||
.Concat(memberKeys)
|
||||
.Concat(activeClubKeys)
|
||||
.Distinct()
|
||||
.Where(k => !k.StartsWith("group:") || memberKeys.Contains(k))
|
||||
.Where(k => ClubIdFromKey(k) == 0 || activeClubKeys.Contains(k))
|
||||
.ToList();
|
||||
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))
|
||||
.ToDictionaryAsync(m => m.ThreadKey, m => m);
|
||||
var participants = await LoadParticipantsAsync(keys);
|
||||
var favorites = await LoadFavoritesAsync(keys);
|
||||
|
||||
return Ok(latest
|
||||
.OrderByDescending(c => c.SentAt)
|
||||
@@ -95,7 +132,8 @@ public class ChatController(
|
||||
meta,
|
||||
member,
|
||||
playerIds ?? new List<int>(),
|
||||
latestMessage: c);
|
||||
latestMessage: c,
|
||||
isFavorited: favorites.Contains(c.ThreadKey));
|
||||
})
|
||||
.ToList());
|
||||
}
|
||||
@@ -119,11 +157,83 @@ public class ChatController(
|
||||
.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(ToWireThread(
|
||||
meta,
|
||||
member,
|
||||
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<List<JNMKLHDFPOJ>></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
|
||||
@@ -228,20 +338,8 @@ public class ChatController(
|
||||
// the sender's chat UI doesn't show its own outgoing message
|
||||
// until the next thread refresh. DM keys encode the two
|
||||
// participants directly; group chat threads fan out across the
|
||||
// explicit membership table.
|
||||
var recipients = new List<long>();
|
||||
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();
|
||||
}
|
||||
// explicit membership table; club chats fan out over the roster.
|
||||
var recipients = await ThreadRecipientsAsync(key);
|
||||
recipients.Add(Me);
|
||||
foreach (var rid in recipients)
|
||||
{
|
||||
@@ -341,7 +439,11 @@ public class ChatController(
|
||||
public async Task<IActionResult> Leave(string 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);
|
||||
var removed = await db.ChatThreadMembers
|
||||
.Where(m => m.ThreadKey == chatThreadId && m.PlayerId == Me)
|
||||
@@ -349,24 +451,40 @@ public class ChatController(
|
||||
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
|
||||
/// of the thread can rename. For DMs the watch typically nudges
|
||||
/// 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<MGGNHLHPHOF></c>
|
||||
/// (instr 162) — a BARE integer body, not a wrapper object.</summary>
|
||||
[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);
|
||||
var name = (body.Name ?? string.Empty).Trim();
|
||||
var name = (body?.Name ?? string.Empty).Trim();
|
||||
if (name.Length > 128) name = name[..128];
|
||||
|
||||
var iAmMember = chatThreadId.StartsWith("dm:")
|
||||
? IsDmParticipant(chatThreadId, Me)
|
||||
: await db.ChatThreadMembers
|
||||
.AnyAsync(m => m.ThreadKey == chatThreadId && m.PlayerId == Me);
|
||||
if (!iAmMember) return Ok(ChatMembershipNotFound);
|
||||
if (!await IsThreadMemberAsync(chatThreadId)) return Ok(ChatMembershipNotFound);
|
||||
|
||||
var meta = await db.ChatThreads.FirstOrDefaultAsync(t => t.ThreadKey == chatThreadId);
|
||||
if (meta is null)
|
||||
@@ -384,6 +502,112 @@ public class ChatController(
|
||||
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<GLIKNMPPEHL, MGGNHLHPHOF></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:<threadKey></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);
|
||||
|
||||
/// <summary>Create a group chat. Generates a fresh
|
||||
@@ -456,7 +680,10 @@ public class ChatController(
|
||||
.ToListAsync();
|
||||
var meta = await EnsureThreadRowAsync(key);
|
||||
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
|
||||
@@ -491,19 +718,7 @@ public class ChatController(
|
||||
// Notify every participant, sender INCLUDED — see SendMessageJson
|
||||
// above for the rationale; the 2020 watch's chat send path needs
|
||||
// the self-push to refresh its own UI.
|
||||
var recipients = new List<long>();
|
||||
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();
|
||||
}
|
||||
var recipients = await ThreadRecipientsAsync(key);
|
||||
recipients.Add(Me);
|
||||
foreach (var rid in recipients)
|
||||
{
|
||||
@@ -513,9 +728,11 @@ public class ChatController(
|
||||
}
|
||||
var participants = await LoadParticipantsAsync(new[] { key });
|
||||
participants.TryGetValue(key, out var playerIds);
|
||||
var favorites = await LoadFavoritesAsync(new[] { key });
|
||||
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,
|
||||
});
|
||||
}
|
||||
@@ -545,19 +762,7 @@ public class ChatController(
|
||||
db.ChatMessages.Add(entry);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var recipients = new List<long>();
|
||||
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();
|
||||
}
|
||||
var recipients = await ThreadRecipientsAsync(key);
|
||||
// Include the sender so the form-POST send path also refreshes
|
||||
// its own chat thread — same reason as the JSON send path above.
|
||||
recipients.Add(Me);
|
||||
@@ -761,15 +966,109 @@ public class ChatController(
|
||||
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;
|
||||
}
|
||||
|
||||
/// <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(
|
||||
ChatThreadEntity thread,
|
||||
ChatThreadMemberEntity? member,
|
||||
List<int> playerIds,
|
||||
ChatMessageEntity? latestMessage = null,
|
||||
List<ChatMessageEntity>? messages = null)
|
||||
List<ChatMessageEntity>? messages = null,
|
||||
bool isFavorited = false)
|
||||
{
|
||||
var dto = new Dictionary<string, object?>
|
||||
{
|
||||
@@ -778,11 +1077,15 @@ public class ChatController(
|
||||
["lastReadMessageId"] = member?.LastReadMessageId ?? 0,
|
||||
["snoozedUntil"] = member?.SnoozeUntil,
|
||||
["playerIds"] = playerIds,
|
||||
["isFavorited"] = isFavorited,
|
||||
["clubId"] = ClubIdFromKey(thread.ThreadKey),
|
||||
};
|
||||
|
||||
if (messages is not null)
|
||||
{
|
||||
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)
|
||||
{
|
||||
@@ -792,6 +1095,18 @@ public class ChatController(
|
||||
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()
|
||||
{
|
||||
["chatMessageId"] = c.Id,
|
||||
|
||||
@@ -18,13 +18,33 @@ namespace DorkNet.Server.Controllers.Clubs;
|
||||
/// URL surface (verb + template) verified against
|
||||
/// <c>Cpp2IL_ISIL/.../JDJGIBLMFKK.txt</c>:
|
||||
/// GET /club/home/me
|
||||
/// PUT /club/home/me (2023, form clubId)
|
||||
/// DELETE /club/home/me (2023)
|
||||
/// GET /club/mine/member
|
||||
/// GET /club/mine/created
|
||||
/// 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/subscription/mine/unread
|
||||
/// 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):
|
||||
/// Club (2020 <c>PLILLKHMNDA</c> / 2023 <c>FOIJDINBPFG</c>):
|
||||
/// ClubId, Name, Description, MainImageName, State,
|
||||
@@ -67,6 +87,50 @@ public class ClubsController(ClubService clubs, DorkNetDbContext db) : Controlle
|
||||
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<Int64></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<Int64>)</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")]
|
||||
[Authorize]
|
||||
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))));
|
||||
}
|
||||
|
||||
/// <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<List<JDPPAFLFNBD>> 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<JDPPAFLFNBD></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")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> UnreadSubscription()
|
||||
@@ -271,6 +364,50 @@ public class ClubsController(ClubService clubs, DorkNetDbContext db) : Controlle
|
||||
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<System.Boolean> 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
|
||||
/// payload. Wire shape is <c>PIHMJGCGNLP</c> with required keys
|
||||
/// <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(); }
|
||||
}
|
||||
|
||||
/// <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
|
||||
/// 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));
|
||||
}
|
||||
|
||||
// ── 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<List<CADEIMCFIIG>> 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<ICPNBOOIDLI></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<AADIHDCMEDB></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<CADEIMCFIIG></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<AADIHDCMEDB></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) ───────────────────────────
|
||||
|
||||
/// <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<Int32></c> —
|
||||
/// omitted when null), <c>codeType</c> (:1273) and <c>extraDataId</c>
|
||||
/// (:1287, boxed <c>Nullable<Int64></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<System.String> 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<NBAFGFFIOLK></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<Int32></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<System.String></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<LBBKBMPAEIO></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; }
|
||||
}
|
||||
@@ -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) |
|
||||
| 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 | `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/`
|
||||
|
||||
|
||||
@@ -185,19 +185,41 @@ public class ClubService(DorkNetDbContext db)
|
||||
.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>
|
||||
/// Composite home-tab payload for <c>GET /club/home/me</c>. The
|
||||
/// watch's deserialiser for this endpoint expects a single
|
||||
/// <c>RecNet.Club</c> object (<c>typeof(System.Action`1<PLILLKHMNDA>)</c>
|
||||
/// per ISIL) — typically the player's "home club" (set via
|
||||
/// <c>SetMyHomeClub</c>) rendered as the headline card. We pick the
|
||||
/// first club the player is a member of, ordered by membership
|
||||
/// recency. Returns null when the player has no club memberships
|
||||
/// — the controller turns that into <see cref="Microsoft.AspNetCore.Mvc.NoContentResult"/>
|
||||
/// so the watch knows to render the "join a club" empty state.
|
||||
/// <c>SetMyHomeClub</c>) rendered as the headline card. An explicit
|
||||
/// pick made through <see cref="SetHomeClubAsync"/> always wins; when
|
||||
/// nothing is stored (or the stored club has since been disbanded) we
|
||||
/// fall back to the most recently joined club so the tab is never
|
||||
/// 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>
|
||||
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
|
||||
// /create flow inserts an owner-permissions=127 membership
|
||||
// row), so a brand-new club creator sees their fresh club here.
|
||||
@@ -209,6 +231,112 @@ public class ClubService(DorkNetDbContext db)
|
||||
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>
|
||||
/// Upserts a read-marker so subsequent <see cref="UnreadDirectAsync"/>
|
||||
/// / <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}";
|
||||
}
|
||||
}
|
||||
@@ -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 |
|
||||
| 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 | `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 |
|
||||
| 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 |
|
||||
| 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 (`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) |
|
||||
| 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) |
|
||||
| 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}/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 |
|
||||
| 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 |
|
||||
|
||||
#### 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.
|
||||
|
||||
##### `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).
|
||||
|
||||
##### `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.
|
||||
**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}/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.
|
||||
|
||||
##### `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.
|
||||
|
||||
**Fix.** Add [HttpGet("/club/{clubId:long}/hasDisabledClubChat")] returning a bare JSON boolean (false, or !ClubChatEnabled once persisted).
|
||||
`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.
|
||||
|
||||
##### `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.
|
||||
|
||||
##### `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.
|
||||
|
||||
**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).
|
||||
`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.
|
||||
|
||||
##### `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}].
|
||||
|
||||
##### `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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
##### `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.** 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.
|
||||
**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.
|
||||
|
||||
##### `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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
##### `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.
|
||||
|
||||
**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)
|
||||
|
||||
@@ -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).
|
||||
|
||||
##### `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
|
||||
|
||||
@@ -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.
|
||||
|
||||
##### `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)
|
||||
|
||||
@@ -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).
|
||||
|
||||
##### `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)
|
||||
|
||||
@@ -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).
|
||||
|
||||
**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)
|
||||
|
||||
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"].
|
||||
|
||||
**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)
|
||||
|
||||
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.
|
||||
|
||||
**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)
|
||||
|
||||
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.
|
||||
|
||||
##### `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)
|
||||
|
||||
@@ -2869,11 +2871,15 @@ Handler: `DorkNet.Server\Controllers\API\Inventions\InventionsController.cs:720`
|
||||
|
||||
**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
|
||||
|
||||
@@ -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> }.
|
||||
|
||||
##### `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).
|
||||
|
||||
@@ -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.
|
||||
|
||||
##### `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
|
||||
|
||||
@@ -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?).
|
||||
|
||||
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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
##### `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)
|
||||
|
||||
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).
|
||||
`ClubsController.AnnouncementsSubscriptionMine` → `ClubService.AnnouncementsForSubscribedClubsAsync`: same flat shape (`IIOMOLFOLPD` at IKMMOCKDKAF.txt:1083, route literal :1162), scoped to `ClubSubscriptions` rather than memberships.
|
||||
|
||||
##### `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).
|
||||
|
||||
##### `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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user