fix: correct 2023-03-21 client wire contracts across the API and notify surfaces

Audits every route the March-2023 client can call against what this server
actually serves, and fixes the defects that were breaking gameplay. Adds
docs/recroom-2023-client-api-complete.md as the reference: ~500 route
literals across 13 subsystems, with verb, request/response shape, and a
per-subsystem diff. 315 defects catalogued; the ones fixed here are listed
in its "Already fixed" table.

Path coverage was never the problem — 465 of 506 routes already had
handlers. The failures were in shape, and they cluster into four causes:

- Wrong verb. The verb is a BestHTTP.HTTPMethods ordinal (GET=0 .. PATCH=5),
  confirmed from that enum's static constructor. Routes registered POST-only
  while the client PUTs returned 405, which made room consumables neither
  buyable nor consumable and blocked every room-key edit.
- Bare scalar wrapped in an object. The client reads `1000`, not
  `{"Limit":1000}`; the issuing method signature says which. Wrapping makes
  the deserializer throw and the feature dies silently.
- [FromBody] vs form-urlencoded. The client sends nearly every write as form
  data, which an [ApiController] action bound [FromBody] rejects with 415
  before the handler runs. This alone broke all 13 club member-management
  endpoints. Adds Binding/FormOrJsonModelBinder so this is fixed once.
- Notify dispatch keys. Both enum overloads of the client's RegisterHandler
  lower the id via Int32.ToString() and register only that numeric key, and
  lookup is a case-sensitive TryGetValue that returns silently on a miss. We
  were sending the enum NAME for most ids, so friend-request updates, forced
  logout, maintenance broadcasts, live balance and consumable updates, and
  invention moderation pushes all evaporated with no error anywhere.

Notable behavioural bugs fixed:

- Editing a club announcement deleted it: PUT was a third binding on the
  delete handler. Split apart, and the missing create route added.
- leaderboard/CheckAndSetStat did not exist. The 2023 client has no SetStat
  literal at all, so every leaderboard score it wrote was discarded.
- Setting a club clubhouse cleared it: the handler read roomId from the query
  while the client sends it in the body, then wrote the resulting null.
- Club join requests were inverted (the enum is Open=0, InviteOnly=1,
  AskToJoin=2), so ask-to-join clubs rejected everyone and invite-only clubs
  quietly admitted them.
- Room bans were pushed as id 24, for which the client registers no handler;
  they now go out as ModerationKick with IsBan, so the ban takes effect
  immediately instead of at next login.
- Room-key ownership queries answered for the wrong player, and "has anyone
  bought this key" asked "did I buy it" — always false for the creator.

Also persists settings that were previously acknowledged and dropped
(playlist accessibility/restrictions/level-voting/warning, per-player status
visibility, VR movement mode and avoid-juniors, custom room codes), which is
what the new columns in the migration are for.

One test asserted the old nested PriceAndCurrency response shape; the
client's consumable formatter has no such key and reads Price and
PurchaseCurrencyId flat, so the test was corrected alongside the handler.
This commit is contained in:
Alexa
2026-07-27 23:27:23 +03:00
parent 674a3fc257
commit 327dcb789c
39 changed files with 9594 additions and 200 deletions
@@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using System.Security.Cryptography;
using System.Text.Json;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
@@ -13,6 +14,13 @@ namespace DorkNet.Server.Controllers.API.AgeVerification;
[Authorize]
public class AgeVerificationController(DorkNetDbContext db) : ControllerBase
{
/// <summary>The 2023-03-21 client POSTs this (verb register <c>rdx=2</c>
/// at RecNet.Runtime/ALLHCFFLMLD.txt:216-222) and deserialises the body as
/// a bare <c>String</c>: the issuing method is
/// <c>FGLDKEJLAKB&lt;IPCJLCNIBEG&lt;System.String&gt;&gt; GKEBPOPAEJC()</c>
/// (:150). Returning a JSON object here made Json.NET throw, so the
/// promise rejected and the code was never shown in the watch.
/// GET stays registered because it costs nothing.</summary>
[HttpPost("generateCode")]
[HttpGet("generateCode")]
public async Task<IActionResult> GenerateCode()
@@ -26,6 +34,6 @@ public class AgeVerificationController(DorkNetDbContext db) : ControllerBase
Value = code,
});
await db.SaveChangesAsync();
return Ok(new { Code = code, VerificationCode = code, ExpiresAt = DateTime.UtcNow.AddMinutes(15) });
return Content(JsonSerializer.Serialize(code), "application/json");
}
}
@@ -7,12 +7,31 @@ namespace DorkNet.Server.Controllers.API.Comments;
/// <summary>
/// Room-comment unread counts. Client contract (RecNet.Runtime
/// <c>DJLNMANKMAA(IReadOnlyCollection&lt;long&gt;)</c>): the caller sends a
/// collection of roomIds and expects a top-level JSON OBJECT
/// <c>Dictionary&lt;long, uint&gt;</c> mapping roomId → unread count.
/// We don't track per-player comment read-state yet, so every requested
/// room maps to 0 — a correct, non-crashing "nothing unread" answer that
/// matches the wire shape (an object, never an array).
/// <c>OPELMBNJHNO.DJLNMANKMAA(IReadOnlyCollection&lt;long&gt;)</c>).
///
/// The response is a JSON <b>ARRAY</b>, not an object. The method's return
/// type is <c>Dictionary&lt;long,uint&gt;</c>, but that dictionary is built
/// client-side: OPELMBNJHNO.txt:889 constructs a
/// <c>Func&lt;List&lt;UnreadRoomComments&gt;, Dictionary&lt;long,uint&gt;&gt;</c>
/// projection that runs over the deserialised payload. So the wire type is
/// <c>List&lt;RecNet.UnreadRoomComments&gt;</c> — each element an object with a
/// room id (Int64) and a count (UInt32), per the two properties on
/// RecNet/UnreadRoomComments.txt.
///
/// This previously returned an object map keyed by room id. Json.NET cannot
/// read a JSON object into a <c>List&lt;T&gt;</c>, so every call threw and the
/// client logged "Failed to get unread room comment counts"
/// (OPELMBNJHNO.txt:937) and fell back to an empty dictionary — the watch's
/// room-comment unread badge never appeared.
///
/// The element's exact property names are attribute-driven and are not
/// recoverable from the ISIL (see the note on key aliasing below), so each
/// element carries the plausible spellings side by side. Json.NET matches
/// case-insensitively and ignores unknown members, so the extra aliases are
/// inert whichever name the DTO actually declares.
///
/// Counts are all zero until per-player comment read-state is tracked; the
/// shape is what matters for the client not to throw.
/// </summary>
[ApiController]
[Authorize]
@@ -24,10 +43,14 @@ public class CommentsController : ControllerBase
public async Task<IActionResult> UnreadCounts()
{
var roomIds = await ReadRoomIdsAsync();
// Keys must be strings in JSON; values are the (currently always 0)
// unread counts.
var result = new Dictionary<string, uint>();
foreach (var id in roomIds) result[id.ToString()] = 0;
var result = roomIds
.Select(id => new Dictionary<string, object>
{
["RoomId"] = id,
["UnreadCount"] = 0u,
["Count"] = 0u,
})
.ToList();
return Ok(result);
}
@@ -12,6 +12,19 @@ namespace DorkNet.Server.Controllers.API.Compatibility;
[ApiController]
public class CompatibilityFeatureController(DorkNetDbContext db) : ControllerBase
{
/// <summary>The 2023-03-21 client GETs this (verb register <c>rdx=0</c>
/// at RecNet.Runtime/FPIBGPIAOBI.txt:2984, immediately before the
/// <c>"api/banappeal/generateCode"</c> literal on :2980) and deserialises
/// the response as a bare <c>String</c> — the issuing method is
/// <c>FGLDKEJLAKB&lt;System.String&gt; HBEPHMAKCDN()</c> (:2759) and the
/// continuation is <c>Func&lt;IPCJLCNIBEG&lt;String&gt;,
/// FGLDKEJLAKB&lt;String&gt;&gt;</c> (:3019).
///
/// It used to be POST-only returning <c>{value: code}</c>, so the client
/// got a 405 and, had it reached the handler, would have thrown parsing a
/// JSON object into a string. Ban-appeal code generation never worked.
/// The body is now the bare JSON string (<c>"BA-123456"</c>).</summary>
[HttpGet("api/banappeal/generateCode")]
[HttpPost("api/banappeal/generateCode")]
[Authorize]
public async Task<IActionResult> GenerateBanAppealCode()
@@ -25,7 +38,7 @@ public class CompatibilityFeatureController(DorkNetDbContext db) : ControllerBas
Value = code,
});
await db.SaveChangesAsync();
return Ok(new { value = code });
return Content(JsonSerializer.Serialize(code), "application/json");
}
[HttpPost("api/CampusCard/v1/UpdateAndGetSubscription")]
@@ -110,7 +123,7 @@ public class CompatibilityFeatureController(DorkNetDbContext db) : ControllerBas
// Prefix with "[club {id}]" so admins can tell club reports apart
// from player reports in the shared ReportEntity queue.
var message = $"[club {club.Id}] {req.Message ?? string.Empty}".Trim();
var message = $"[club {club.Id}] {req.Details ?? req.Message ?? string.Empty}".Trim();
if (message.Length > 1000) message = message[..1000];
db.Reports.Add(new ReportEntity
{
@@ -155,7 +168,13 @@ public class CompatibilityFeatureController(DorkNetDbContext db) : ControllerBas
?? form["ClubId"].FirstOrDefault(), out var clubId) ? clubId : 0,
ReportCategory = int.TryParse(form["reportCategory"].FirstOrDefault()
?? form["ReportCategory"].FirstOrDefault(), out var cat) ? cat : 5,
Message = form["message"].FirstOrDefault() ?? form["Message"].FirstOrDefault(),
// The 2023 client names the free-text field "details"
// (IKMMOCKDKAF.txt:25303-25322), not "message" — reading only
// the latter stored every club report with an empty body.
Message = form["details"].FirstOrDefault()
?? form["Details"].FirstOrDefault()
?? form["message"].FirstOrDefault()
?? form["Message"].FirstOrDefault(),
};
}
try
@@ -178,5 +197,7 @@ public class CompatibilityFeatureController(DorkNetDbContext db) : ControllerBas
public long ClubId { get; set; }
public int ReportCategory { get; set; } = 5;
public string? Message { get; set; }
/// <summary>The client's spelling of <see cref="Message"/>.</summary>
public string? Details { get; set; }
}
}
@@ -121,14 +121,18 @@ public class CustomAvatarItemsController(
skip = Math.Max(0, skip);
take = Math.Clamp(take, 1, 100);
var currentPlayerId = this.CurrentPlayerId();
var rows = await db.CustomAvatarItems
var query = db.CustomAvatarItems
.Where(i => i.CreatorPlayerId == creatorId
&& (i.IsPublic || i.CreatorPlayerId == currentPlayerId))
&& (i.IsPublic || i.CreatorPlayerId == currentPlayerId));
var total = await query.CountAsync();
var rows = await query
.OrderByDescending(i => i.UpdatedAt)
.Skip(skip)
.Take(take)
.ToListAsync();
return Ok(rows.Select(ToWire));
// Paged container, not a bare array — the client deserializes this into
// CKFLFJCNEPH {Results, TotalResults}, same as Owned() below.
return Ok(new { Results = rows.Select(ToWire), TotalResults = total });
}
[HttpGet("me")]
@@ -203,7 +207,11 @@ public class CustomAvatarItemsController(
var allowed = player is not null
&& !player.IsJunior
&& (player.BannedUntil is null || player.BannedUntil <= DateTime.UtcNow);
return Ok(new { IsCreationAllowed = allowed, Allowed = allowed });
// Bare JSON boolean, like the two sibling gates below: the client's
// issuing method is FGLDKEJLAKB<System.Boolean> GGMDLMLJDDK()
// (RecNet.Runtime/MPBLNLMCEDL.txt:6048, route literal on :6205), so an
// object body threw and the can-create gate blocked shirt creation.
return Content(allowed ? "true" : "false", "application/json");
}
[HttpGet("isCreationEnabled")]
@@ -262,6 +270,13 @@ public class CustomAvatarItemsController(
return Ok(ToWire(item));
}
/// <summary>The 2023-03-21 client buys custom items through the
/// <c>econ/</c> host prefix — <c>econ/customAvatarItems/v1/{id}/purchase</c>
/// — which was never registered, so every custom-shirt purchase 404'd. The
/// rooted route below is an alias onto the same handler. <c>requestedPrice</c>
/// arrives as a query param and is validated against the item's real price
/// so a tampered client can't buy at its own number.</summary>
[HttpPost("/econ/customAvatarItems/v1/{id:guid}/purchase")]
[HttpPost("{id:guid}/purchase")]
[Authorize]
public async Task<IActionResult> Purchase(Guid id)
@@ -43,37 +43,43 @@ public class ExternalFriendInviteController(DorkNetDbContext db) : ControllerBas
return Ok(new { Success = true, InviteCode = code, PhoneNumber = phoneNumber ?? string.Empty });
}
/// <summary>Accounts who joined via this player's platform invites.
///
/// The response is a bare array of account ids — the issuing method is
/// <c>FGLDKEJLAKB&lt;List&lt;System.Int32&gt;&gt; POJMMCCLNCB()</c>
/// (RecNet.Runtime/OMHDDLFIPLP.txt:923). Returning objects
/// (<c>{InviteCode, Kind, Value, CreatedAt}</c>) made Json.NET throw as soon
/// as any referrer existed, so the referral screen broke for exactly the
/// players who had referred someone.</summary>
[HttpGet("api/externalfriendinvite/v1/getplatformreferrers")]
[HttpPost("api/externalfriendinvite/v1/getplatformreferrers")]
public async Task<IActionResult> GetPlatformReferrers()
{
var rows = await db.PlayerSettings
.Where(s => s.PlayerId == Me && s.Key.StartsWith("externalinvite:platform:"))
.OrderByDescending(s => s.Id)
.ToListAsync();
return Ok(rows.Select(ToInviteWire));
}
=> Ok(await ReferrerIdsAsync("externalinvite:platform:"));
/// <summary>Same bare-id-array contract as
/// <see cref="GetPlatformReferrers"/>, for text-message invites.</summary>
[HttpGet("api/externalfriendinvite/v1/gettextmessagereferrers")]
[HttpPost("api/externalfriendinvite/v1/gettextmessagereferrers")]
public async Task<IActionResult> GetTextMessageReferrers()
=> Ok(await ReferrerIdsAsync("externalinvite:text:"));
/// <summary>Account ids recorded against the caller's invites of the given
/// kind. The redeeming account id is stored as the second pipe-delimited
/// field once an invite is claimed; unclaimed invites contribute nothing.</summary>
private async Task<List<int>> ReferrerIdsAsync(string keyPrefix)
{
var rows = await db.PlayerSettings
.Where(s => s.PlayerId == Me && s.Key.StartsWith("externalinvite:text:"))
.Where(s => s.PlayerId == Me && s.Key.StartsWith(keyPrefix))
.OrderByDescending(s => s.Id)
.Select(s => s.Value)
.ToListAsync();
return Ok(rows.Select(ToInviteWire));
return rows
.Select(v => v.Split('|'))
.Select(parts => int.TryParse(parts.ElementAtOrDefault(1), out var id) ? id : 0)
.Where(id => id > 0)
.Distinct()
.ToList();
}
private static object ToInviteWire(PlayerSettingEntity row)
{
var parts = row.Value.Split('|');
return new
{
InviteCode = row.Key.Split(':').Last(),
Kind = row.Key.Contains(":text:") ? "text" : "platform",
Value = parts.FirstOrDefault() ?? string.Empty,
CreatedAt = parts.LastOrDefault() ?? string.Empty,
};
}
}
@@ -618,6 +618,16 @@ public class ImagesController(
public long ImageId { get; set; }
public string? ImageName { get; set; }
public bool IsPublic { get; set; }
/// <summary>What the 2023-03-21 client actually sends:
/// 0 = Private, 1 = Public, 2 = FriendsOnly. It never sends
/// <see cref="IsPublic"/>, so reading only that flag left every photo
/// private no matter what the player picked.</summary>
public int? Accessibility { get; set; }
/// <summary>Resolved visibility: the enum when present, else the
/// legacy boolean.</summary>
public bool ResolvedIsPublic => Accessibility is int a ? a == 1 : IsPublic;
}
/// <summary>POST <c>api/images/v1/modifyaccessibility</c> — flip
@@ -630,7 +640,8 @@ public class ImagesController(
[FromForm(Name = "PhotoId")] long? photoIdForm,
[FromForm(Name = "ImageId")] long? imageIdForm,
[FromForm(Name = "ImageName")] string? imageNameForm,
[FromForm(Name = "IsPublic")] bool? isPublicForm)
[FromForm(Name = "IsPublic")] bool? isPublicForm,
[FromForm(Name = "Accessibility")] int? accessibilityForm)
{
var pid = this.RequireCurrentPlayerId();
var photoId = body?.PhotoId ?? body?.ImageId
@@ -638,7 +649,12 @@ public class ImagesController(
var imageName = body?.ImageName ?? imageNameForm;
if (photoId <= 0 && string.IsNullOrWhiteSpace(imageName))
return BadRequest(new { error = "missing photoId" });
var isPublic = body?.IsPublic ?? isPublicForm ?? true;
// Accessibility (0=Private, 1=Public, 2=FriendsOnly) is what the 2023
// client sends; IsPublic is the older boolean.
var accessibility = body?.Accessibility ?? accessibilityForm;
var isPublic = accessibility is int a
? a == 1
: body?.IsPublic ?? isPublicForm ?? true;
var photo = photoId > 0
? await db.Photos.FirstOrDefaultAsync(p => p.Id == photoId)
@@ -504,9 +504,25 @@ public class InventionsController(
});
}
/// <summary>The 2023-03-21 client names the blob field
/// <c>inventionDataFilename</c> and sends four extra cost fields plus the
/// creation room and referenced inventions. The record only had
/// <c>BlobName</c>, which the client never sends, so the required-parameter
/// check rejected every publish with 400 and no new invention version could
/// ever be saved.</summary>
public sealed record AddVersionRequest(
long InventionId, string BlobName,
int? InstantiationCost, int? LightsCost);
long InventionId,
string? BlobName,
string? InventionDataFilename,
int? InstantiationCost, int? LightsCost,
int? ChipsCost, int? CloudVariablesCost, int? AiCost,
long? CreationRoomId,
List<long>? ReferencedInventions)
{
public string? Blob => !string.IsNullOrWhiteSpace(InventionDataFilename)
? InventionDataFilename
: BlobName;
}
[HttpPost("api/inventions/v3/addversion")]
[HttpPost("api/inventions/v4/addversion")]
@@ -517,10 +533,11 @@ public class InventionsController(
var inv = await db.Inventions.FirstOrDefaultAsync(x => x.Id == req.InventionId && !x.IsDeleted);
if (inv is null) return NotFound();
if (inv.CreatorPlayerId != pid) return Forbid();
if (string.IsNullOrWhiteSpace(req.BlobName)) return BadRequest("missing BlobName");
var blobName = req.Blob;
if (string.IsNullOrWhiteSpace(blobName)) return BadRequest("missing inventionDataFilename");
var nextVer = inv.CurrentVersionNumber + 1;
inv.CurrentBlobName = req.BlobName;
inv.CurrentBlobName = blobName;
inv.CurrentVersionNumber = nextVer;
inv.UpdatedAt = DateTime.UtcNow;
@@ -529,7 +546,7 @@ public class InventionsController(
InventionId = inv.Id,
ReplicationId = Guid.NewGuid().ToString("D"),
VersionNumber = nextVer,
BlobName = req.BlobName,
BlobName = blobName,
InstantiationCost = req.InstantiationCost ?? 0,
LightsCost = req.LightsCost ?? 0,
});
@@ -664,7 +681,15 @@ public class InventionsController(
// ── POST mutations ───────────────────────────────────────────────────
public sealed record SetTagsRequest(long InventionId, string? AutoTags, string? PlayerAddedTags);
/// <summary>The client sends <c>AutoTags</c> and <c>CustomTags</c> as JSON
/// ARRAYS of strings. Binding them to <c>string</c> made deserialization
/// throw, so every tag edit 400'd. <c>PlayerAddedTags</c> is kept as an
/// alias for older callers.</summary>
public sealed record SetTagsRequest(
long InventionId,
List<string>? AutoTags,
List<string>? CustomTags,
List<string>? PlayerAddedTags);
[HttpPost("api/inventions/v1/settags")]
[Authorize]
@@ -674,8 +699,12 @@ public class InventionsController(
var inv = await db.Inventions.FirstOrDefaultAsync(x => x.Id == req.InventionId && !x.IsDeleted);
if (inv is null) return NotFound();
if (inv.CreatorPlayerId != pid) return Forbid();
var combined = string.Join(',', new[] { req.AutoTags, req.PlayerAddedTags }
.Where(s => !string.IsNullOrWhiteSpace(s))).Trim(',');
var combined = string.Join(',', new[] { req.AutoTags, req.CustomTags, req.PlayerAddedTags }
.Where(list => list is not null)
.SelectMany(list => list!)
.Select(t => t?.Trim())
.Where(t => !string.IsNullOrWhiteSpace(t))
.Distinct(StringComparer.OrdinalIgnoreCase));
inv.TagsCsv = combined;
inv.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
@@ -1,3 +1,4 @@
using System.Text.Json;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
@@ -78,8 +79,85 @@ public class InventoryController(
/// <summary>POST <c>api/equipment/v1/update</c> — toggle the
/// "favorited" flag on a piece of equipment (the watch lets the
/// player pin commonly-used tools).</summary>
/// <remarks>The 2023-03-21 client sends a JSON <b>array</b> of equipment
/// updates here, not a single object (it serialises a
/// <c>List&lt;BCINJINBBHG&gt;</c>). Binding <c>[FromBody]</c> onto one
/// object failed model binding and returned an automatic 400, so equip and
/// favourite changes never persisted for that build. The body is now read
/// leniently and both shapes are applied through the same per-item path.
/// </remarks>
[HttpPost("api/equipment/v1/update")]
public async Task<IActionResult> UpdateEquipment([FromBody] EquipmentUpdateRequest req)
[Consumes("application/json", "application/x-www-form-urlencoded", "multipart/form-data")]
public async Task<IActionResult> UpdateEquipment()
{
var requests = await ReadEquipmentUpdatesAsync();
if (requests.Count == 0) return BadRequest("missing prefab/mod identifier");
IActionResult last = Ok();
foreach (var req in requests)
{
last = await ApplyEquipmentUpdateAsync(req);
// Surface the first rejection rather than pretending the whole
// batch succeeded.
if (last is not OkObjectResult and not OkResult) return last;
}
return last;
}
/// <summary>Accepts a single object, an array of objects, or a form post.</summary>
private async Task<List<EquipmentUpdateRequest>> ReadEquipmentUpdatesAsync()
{
var opts = new JsonSerializerOptions { PropertyNameCaseInsensitive = true };
var result = new List<EquipmentUpdateRequest>();
if (Request.HasFormContentType)
{
var form = await Request.ReadFormAsync();
string? S(params string[] keys)
{
foreach (var k in keys)
if (form.TryGetValue(k, out var v) && !string.IsNullOrEmpty(v)) return v.ToString();
return null;
}
bool? B(params string[] keys)
=> bool.TryParse(S(keys), out var b) ? b : null;
result.Add(new EquipmentUpdateRequest
{
PrefabName = S("PrefabName", "prefabName"),
ModificationGuid = S("ModificationGuid", "modificationGuid"),
Favorited = B("Favorited", "favorited") ?? false,
Equipped = B("Equipped", "equipped"),
IsEquipped = B("IsEquipped", "isEquipped"),
});
return result;
}
try
{
Request.EnableBuffering();
Request.Body.Position = 0;
using var doc = await JsonDocument.ParseAsync(Request.Body);
Request.Body.Position = 0;
var root = doc.RootElement;
if (root.ValueKind == JsonValueKind.Array)
{
foreach (var el in root.EnumerateArray())
{
var one = el.Deserialize<EquipmentUpdateRequest>(opts);
if (one is not null) result.Add(one);
}
}
else if (root.ValueKind == JsonValueKind.Object)
{
var one = root.Deserialize<EquipmentUpdateRequest>(opts);
if (one is not null) result.Add(one);
}
}
catch (JsonException) { /* unparseable body → treated as empty */ }
return result;
}
private async Task<IActionResult> ApplyEquipmentUpdateAsync(EquipmentUpdateRequest req)
{
var pid = Me;
// Catalog-backed skins use a compound slug so we can round-trip
@@ -209,16 +287,28 @@ public class InventoryController(
return Ok(new { row.Id, Count = row.Quantity });
}
/// <remarks>The 2023-03-21 client serialises a <c>TransferConsumableRequest</c>
/// with <c>JsonUtility.ToJson</c> and posts it as a raw JSON body. With
/// <c>[ApiController]</c> plus <c>[FromForm]</c> parameters, an
/// <c>application/json</c> request bound nothing and the transfer was
/// rejected as invalid, so consumable gifting never worked on that build.
/// The JSON body is now read alongside the original form/query paths.</remarks>
[HttpPost("api/consumables/v1/transfer")]
[Consumes("application/json", "application/x-www-form-urlencoded", "multipart/form-data")]
public async Task<IActionResult> TransferConsumable(
[FromForm] long? id,
[FromForm] long? recipientPlayerId,
[FromForm] int quantity = 1)
{
var pid = Me;
var inventoryId = id ?? (long.TryParse(Request.Query["id"], out var qId) ? qId : 0);
var recipient = recipientPlayerId ?? (long.TryParse(Request.Query["recipientPlayerId"], out var qRecipient) ? qRecipient : 0);
var amount = Math.Max(1, quantity);
var body = await ReadTransferBodyAsync();
var inventoryId = id
?? body.Id
?? (long.TryParse(Request.Query["id"], out var qId) ? qId : 0);
var recipient = recipientPlayerId
?? body.RecipientPlayerId
?? (long.TryParse(Request.Query["recipientPlayerId"], out var qRecipient) ? qRecipient : 0);
var amount = Math.Max(1, body.Quantity ?? quantity);
if (inventoryId <= 0 || recipient <= 0 || recipient == pid)
return BadRequest("invalid_transfer");
@@ -247,6 +337,69 @@ public class InventoryController(
return Ok(new { Success = true, SourceCount = Math.Max(0, source.Quantity), RecipientCount = target.Quantity });
}
private readonly record struct TransferBody(long? Id, long? RecipientPlayerId, int? Quantity);
/// <summary>Reads the 2023 client's raw JSON transfer body. JsonUtility
/// emits field names verbatim, and the exact casing of that DTO is not
/// recoverable from the ISIL, so both conventions are accepted.</summary>
private async Task<TransferBody> ReadTransferBodyAsync()
{
if (Request.HasFormContentType) return default;
try
{
Request.EnableBuffering();
Request.Body.Position = 0;
using var doc = await JsonDocument.ParseAsync(Request.Body);
Request.Body.Position = 0;
if (doc.RootElement.ValueKind != JsonValueKind.Object) return default;
long? L(params string[] keys)
{
foreach (var k in keys)
if (doc.RootElement.TryGetProperty(k, out var e) &&
e.ValueKind == JsonValueKind.Number && e.TryGetInt64(out var v)) return v;
return null;
}
int? I(params string[] keys)
{
foreach (var k in keys)
if (doc.RootElement.TryGetProperty(k, out var e) &&
e.ValueKind == JsonValueKind.Number && e.TryGetInt32(out var v)) return v;
return null;
}
return new TransferBody(
L("Id", "id", "InventoryId", "inventoryId"),
L("RecipientPlayerId", "recipientPlayerId"),
I("Quantity", "quantity", "Count", "count"));
}
catch (JsonException) { return default; }
}
/// <summary>GET <c>api/consumables/v1/getTransferable/{playerId}</c> — the
/// consumables the caller may gift to <c>playerId</c>. No handler existed,
/// so the transfer UI 404'd and could never list anything to send.
/// Returns the same grouped shape as <c>v2/getUnlocked</c>.</summary>
[HttpGet("api/consumables/v1/getTransferable/{playerId:long}")]
public async Task<IActionResult> GetTransferableConsumables(long playerId)
{
var pid = Me;
var rows = await db.PlayerInventory
.Where(p => p.PlayerId == pid && p.Quantity > 0)
.ToListAsync();
// Can't gift to yourself; everything else the caller holds is fair game.
var transferable = playerId == pid ? [] : rows;
return Ok(transferable
.GroupBy(r => r.ItemSlug)
.Select(g => new
{
Consumable = g.Key,
Count = g.Sum(r => r.Quantity),
Id = g.Min(r => r.Id),
})
.ToList());
}
public sealed class ActivateConsumableRequest
{
public long Id { get; set; }
@@ -131,6 +131,106 @@ public class LeaderboardController(
return Ok(new { success = true, error = "" });
}
/// <summary>POST <c>/leaderboard/CheckAndSetStat</c> — the compare-and-set
/// form of <see cref="SetStat"/>, and the ONLY stat-write route the
/// 2023-03-21 client has: that build contains no <c>SetStat</c> literal at
/// all. Nothing registered this route, so every leaderboard score written
/// by a 2023 client fell through to the catch-all and was lost.
///
/// The request is <c>RecNet.SetStatRequestDTO</c>, whose property names are
/// NOT obfuscated in the dump: <c>StatChannel</c> (Int32), <c>RoomId</c>
/// (Int64), <c>StatValue</c> (Int32) and the extra
/// <c>CurrentStatValue</c> (Int32?) that distinguishes this route from
/// plain SetStat.
///
/// <c>CurrentStatValue</c> is the value the client believes is currently
/// stored. It is an optimistic-concurrency guard: the write applies only if
/// the server agrees, which stops a stale session from clobbering a newer
/// score. A null means "no expectation" and writes unconditionally, and a
/// mismatch is reported rather than silently dropped so the client can
/// re-read.
///
/// As with SetStat, the client has already decided the new value should win
/// (it applies the per-channel higher/lower-is-better rule locally), so the
/// server does not second-guess the direction.</summary>
[HttpPost("/leaderboard/CheckAndSetStat")]
[HttpPost("/api/leaderboard/CheckAndSetStat")]
[Authorize]
public async Task<IActionResult> CheckAndSetStat([FromBody] CheckAndSetStatRequest req)
{
var pid = this.RequireCurrentPlayerId();
var roomId = ResolveRoomId(req.RoomId, pid);
var row = await db.LeaderboardStats.FirstOrDefaultAsync(s =>
s.RoomId == roomId && s.PlayerId == pid && s.StatChannel == req.StatChannel);
if (req.CurrentStatValue is int expected && row is not null && row.Value != expected)
{
return Ok(new
{
success = false,
error = "stat_value_mismatch",
StatValue = row.Value,
CurrentStatValue = row.Value,
});
}
if (row is null)
{
row = new LeaderboardStatEntity
{
PlayerId = pid,
RoomId = roomId,
StatChannel = req.StatChannel,
Value = req.StatValue,
};
db.LeaderboardStats.Add(row);
}
else
{
row.Value = req.StatValue;
row.UpdatedAt = DateTime.UtcNow;
}
await EnsureChannelMetaAsync(roomId, req.StatChannel);
await db.SaveChangesAsync();
return Ok(new
{
success = true,
error = "",
StatValue = row.Value,
CurrentStatValue = row.Value,
});
}
public sealed class CheckAndSetStatRequest
{
public int StatChannel { get; set; }
public long RoomId { get; set; }
public int StatValue { get; set; }
/// <summary>The value the client believes is stored. Null = write
/// unconditionally.</summary>
public int? CurrentStatValue { get; set; }
}
/// <summary>Stamp a placeholder channel-meta row the first time a channel
/// reports, so the admin SPA's per-room Leaderboards tab surfaces it.</summary>
private async Task EnsureChannelMetaAsync(long roomId, int statChannel)
{
if (roomId <= 0) return;
var meta = await db.LeaderboardChannelMeta
.FirstOrDefaultAsync(c => c.RoomId == roomId && c.Channel == statChannel);
if (meta is not null) return;
db.LeaderboardChannelMeta.Add(new LeaderboardChannelMetaEntity
{
Channel = statChannel,
RoomId = roomId,
Name = $"Channel {statChannel}",
LowerIsBetter = false,
ValueFormat = "count",
});
}
public sealed class GetPlayerRankRequest
{
public int PlayerId { get; set; }
@@ -1,3 +1,4 @@
using System.Text.Json;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
@@ -105,9 +106,23 @@ public class InGameModerationController(
// ── /v1/instantKick — admin one-shot ─────────────────────────────
/// <summary>
/// Admin/dev one-shot kick. The watch posts either
/// Admin/dev one-shot kick. The 2020.12 watch posts either
/// <c>?playerId=N</c> for a single target (PlayerReporting.txt:3417)
/// or a list body for bulk kicks (3590). Either form lands here.
/// or a list body for bulk kicks (3590).
///
/// The 2023-03-21 client posts neither: it serialises a
/// <c>RecNet.KickPlayerDTO</c> with <c>JsonUtility.ToJson</c> and sends it
/// as a RAW JSON body (RecNet.Runtime/FPIBGPIAOBI.txt:5012 JsonUtility.ToJson,
/// :5018 BNDIAONDFFF.FJLLPHFOOJJ raw-body setter, :5034 verb rdx=2).
/// KickPlayerDTO's fields are not obfuscated — <c>public long GameSessionId</c>
/// and <c>public List&lt;int&gt; PlayerIds</c> — so the body is exactly
/// <c>{"GameSessionId":123,"PlayerIds":[456]}</c>. Unity's JsonUtility emits
/// field names verbatim.
///
/// None of the query/form parameters bound against that body, so the target
/// list came back empty and every 2023 instant-kick returned
/// "missing_target" and kicked nobody.
///
/// Gated to <see cref="PlayerEntity.IsAdmin"/> /
/// <see cref="PlayerEntity.IsDeveloper"/>.
/// </summary>
@@ -130,6 +145,7 @@ public class InGameModerationController(
foreach (var part in formIds.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
if (long.TryParse(part, out var v) && v > 0) ids.Add(v);
}
ids.AddRange(await ReadKickPlayerDtoIdsAsync());
ids = ids.Distinct().ToList();
if (ids.Count == 0) return Ok(new { success = false, error = "missing_target" });
@@ -143,6 +159,47 @@ public class InGameModerationController(
return Ok(new { success = true, error = "" });
}
/// <summary>Read the 2023 client's raw <c>KickPlayerDTO</c> JSON body.
/// Returns an empty list for a form post or an unparseable body so the
/// older query/form paths are unaffected.</summary>
private async Task<List<long>> ReadKickPlayerDtoIdsAsync()
{
var ids = new List<long>();
if (Request.HasFormContentType) return ids;
try
{
Request.EnableBuffering();
Request.Body.Position = 0;
using var doc = await JsonDocument.ParseAsync(Request.Body);
Request.Body.Position = 0;
if (doc.RootElement.ValueKind != JsonValueKind.Object) return ids;
foreach (var name in new[] { "PlayerIds", "playerIds" })
{
if (!doc.RootElement.TryGetProperty(name, out var arr) ||
arr.ValueKind != JsonValueKind.Array) continue;
foreach (var el in arr.EnumerateArray())
if (el.ValueKind == JsonValueKind.Number && el.TryGetInt64(out var v) && v > 0)
ids.Add(v);
break;
}
foreach (var name in new[] { "PlayerId", "playerId" })
{
if (doc.RootElement.TryGetProperty(name, out var one) &&
one.ValueKind == JsonValueKind.Number &&
one.TryGetInt64(out var v) && v > 0)
{
ids.Add(v);
break;
}
}
}
catch (JsonException) { /* not a JSON body */ }
return ids;
}
// ── /v1/deviceId — log mismatched device id ──────────────────────
[HttpPost("api/PlayerReporting/v1/deviceId")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
@@ -192,11 +249,21 @@ public class InGameModerationController(
"[mod] ban by {Caller}: target={Target} reason={Reason} until={Until} display={Display}",
caller, target, reason, p.BannedUntil, displayReason);
// Push a ModerationRoomBan notification so the target watch
// shows the ban dialog and disconnects (PlayerReporting.txt:2253).
await notifications.NotifyAsync(
// Push the ban as a ModerationKick (22) with IsBan set, NOT as
// ModerationRoomBan (24): the 2023-03-21 client's moderation manager
// registers only 22, "ModerationUnkick" and 23
// (RecNet.Runtime/FPIBGPIAOBI.txt:310, :323, :335) — there is no handler
// for 24, so that push landed nowhere and the banned player kept playing
// until their next login. The 22 handler reads Msg.Reason as a
// ModerationBlockDetail, which ModerationKickPayload already produces.
await notifications.KickPlayerAsync(
target,
Models.Notification.PushNotificationId.ModerationRoomBan);
// `reason` is the numeric report category; DisplayReason is the
// moderator's text and is what the ban dialog shows.
string.IsNullOrWhiteSpace(displayReason)
? "You have been banned from this room."
: displayReason.Trim(),
isBan: true);
return Ok(new { success = true, error = "" });
}
@@ -24,19 +24,99 @@ public class PlayerWarningsController(DorkNetDbContext db) : ControllerBase
.ToList());
}
/// <summary>Moderator issues a warning against a player. The 2023-03-21
/// client POSTs this (verb register <c>rdx=2</c> at
/// RecNet.Runtime/FPIBGPIAOBI.txt:3455) with the form keys
/// <c>WarnedPlayerId</c>, <c>ReportCategory</c>, <c>DisplayReason</c> and
/// <c>ModeratorNote</c> (:3468-3520), matching the issuing method
/// <c>FGLDKEJLAKB&lt;DJMHAFPGLLN&gt; OEJIMKKDMBI(Int32, BDOGOIGCKMK,
/// String, String)</c> (:3102).
///
/// Only GET was registered here, so every warning the moderator tools
/// tried to create came back 405 and silently failed.</summary>
[HttpPost("api/playerwarnings")]
[Consumes("application/json", "application/x-www-form-urlencoded", "multipart/form-data")]
public async Task<IActionResult> CreateWarning(
[FromForm(Name = "WarnedPlayerId")] long? warnedPlayerId,
[FromForm(Name = "ReportCategory")] int? reportCategory,
[FromForm(Name = "DisplayReason")] string? displayReason,
[FromForm(Name = "ModeratorNote")] string? moderatorNote)
{
var me = this.RequireCurrentPlayerId();
var moderator = await db.Players
.Where(p => p.Id == me)
.Select(p => (bool?)(p.IsAdmin || p.IsDeveloper || p.IsCommunityTeam))
.FirstOrDefaultAsync();
if (moderator != true)
return Ok(new { Success = false, Message = "not_authorized" });
var target = warnedPlayerId
?? (long.TryParse(Request.Query["WarnedPlayerId"].FirstOrDefault(), out var q) ? q : 0);
if (target <= 0) return Ok(new { Success = false, Message = "missing_player" });
if (!await db.Players.AnyAsync(p => p.Id == target))
return Ok(new { Success = false, Message = "player_not_found" });
// Same "warning:{id}|message|category|createdAt|acknowledged" layout the
// GET list handler parses in ToWarningWire.
var reason = (displayReason ?? string.Empty).Trim();
var note = (moderatorNote ?? string.Empty).Trim();
var message = string.IsNullOrEmpty(note) ? reason : $"{reason} ({note})";
var id = DateTime.UtcNow.ToString("yyyyMMddHHmmssfff");
db.PlayerSettings.Add(new DorkNet.Server.Data.Entities.PlayerSettingEntity
{
PlayerId = target,
Key = $"warning:{id}",
Value = string.Join('|',
message.Replace('|', '/'),
(reportCategory ?? 0).ToString(),
DateTime.UtcNow.ToString("O"),
"false"),
});
await db.SaveChangesAsync();
return Ok(new { Success = true, Message = string.Empty, WarningId = id });
}
/// <summary>The 2023-03-21 client's acknowledge call takes NO arguments —
/// the issuing method is <c>FGLDKEJLAKB&lt;DJMHAFPGLLN&gt; DOFADJPFBMK()</c>
/// (FPIBGPIAOBI.txt:3524) and it POSTs an empty body (:3590-3592). It means
/// "acknowledge my pending warning".
///
/// Demanding a warningId made every call 400, so the modal was never marked
/// read and re-appeared on each login. With no id supplied we now
/// acknowledge the newest unacknowledged warning, and acknowledging nothing
/// is a success rather than an error.</summary>
[HttpPost("api/playerwarnings/acknowledge")]
[Consumes("application/json", "application/x-www-form-urlencoded", "multipart/form-data")]
public async Task<IActionResult> Acknowledge([FromForm] string? warningId)
{
var me = this.RequireCurrentPlayerId();
var id = warningId ?? Request.Query["warningId"].FirstOrDefault() ?? Request.Query["id"].FirstOrDefault();
if (string.IsNullOrWhiteSpace(id)) return BadRequest("missing_warning");
var row = await db.PlayerSettings
.FirstOrDefaultAsync(s => s.PlayerId == me && s.Key == $"warning:{id}");
if (row is null) return NotFound();
DorkNet.Server.Data.Entities.PlayerSettingEntity? row;
if (string.IsNullOrWhiteSpace(id))
{
// Acknowledged state lives inside the packed Value string, so the
// filter has to run client-side after materialising.
var rows = await db.PlayerSettings
.Where(s => s.PlayerId == me && s.Key.StartsWith("warning:"))
.OrderByDescending(s => s.Id)
.ToListAsync();
row = rows.FirstOrDefault(s => !ToWarningWire(s).Acknowledged);
if (row is null) return Ok(new { Success = true, Message = string.Empty });
}
else
{
row = await db.PlayerSettings
.FirstOrDefaultAsync(s => s.PlayerId == me && s.Key == $"warning:{id}");
if (row is null) return Ok(new { Success = true, Message = string.Empty });
}
var parts = row.Value.Split('|');
row.Value = string.Join('|', parts.Take(3).Append("true"));
await db.SaveChangesAsync();
return Ok(new { Success = true, WarningId = id });
return Ok(new { Success = true, Message = string.Empty, WarningId = row.Key["warning:".Length..] });
}
private static WarningWire ToWarningWire(DorkNet.Server.Data.Entities.PlayerSettingEntity row)
@@ -21,6 +21,9 @@ public class PlayersController(PlayerService playerService, NotificationService
return Ok(PlayerService.ToProfile(player));
}
// The 2023 client PUTs this right after setting an email; GET-only meant a
// 405 on every contact-preferences write.
[HttpPut("/api/players/v4/current/contact")]
[HttpGet("/api/players/v4/current/contact")]
public async Task<IActionResult> CurrentContact()
{
@@ -73,56 +73,180 @@ public class PlaylistsController(
public sealed class BoolFieldRequest { public bool? Value { get; set; } }
public sealed class TagsRequest { public string? Tags { get; set; } public List<string>? TagsList { get; set; } }
// The 2023-03-21 client sends these as form-urlencoded PUTs. A
// non-optional [FromBody] parameter made [ApiController] demand JSON and
// reject every one with 415 before the action ran, so renaming, re-imaging,
// re-tagging and publishing a playlist all failed silently. Values are now
// read from form, query or JSON body, whichever arrived.
[HttpPost("/playlists/{playlistId:long}/name")]
[HttpPut("/playlists/{playlistId:long}/name")]
[Authorize]
public Task<IActionResult> PlaylistName(long playlistId, [FromBody] StringFieldRequest req,
[FromForm(Name = "Name")] string? formValue) =>
ApplyMutation(playlistId, p => p.Name = (req?.Value ?? formValue ?? p.Name).Trim());
public async Task<IActionResult> PlaylistName(long playlistId)
{
var value = await ReadFieldAsync("name", "Name", "value", "Value");
return await ApplyMutation(playlistId, p => p.Name = (value ?? p.Name).Trim());
}
[HttpPost("/playlists/{playlistId:long}/description")]
[HttpPut("/playlists/{playlistId:long}/description")]
[Authorize]
public Task<IActionResult> PlaylistDescription(long playlistId, [FromBody] StringFieldRequest req,
[FromForm(Name = "Description")] string? formValue) =>
ApplyMutation(playlistId, p => p.Description = req?.Value ?? formValue ?? p.Description);
public async Task<IActionResult> PlaylistDescription(long playlistId)
{
var value = await ReadFieldAsync("description", "Description", "value", "Value");
return await ApplyMutation(playlistId, p => p.Description = value ?? p.Description);
}
[HttpPost("/playlists/{playlistId:long}/image")]
[HttpPut("/playlists/{playlistId:long}/image")]
[Authorize]
public Task<IActionResult> PlaylistImage(long playlistId, [FromBody] StringFieldRequest req,
[FromForm(Name = "ImageName")] string? formValue) =>
ApplyMutation(playlistId, p => p.ImageName = req?.Value ?? formValue ?? p.ImageName);
public async Task<IActionResult> PlaylistImage(long playlistId)
{
var value = await ReadFieldAsync("imageName", "ImageName", "value", "Value");
return await ApplyMutation(playlistId, p => p.ImageName = value ?? p.ImageName);
}
/// <summary>The client sends REPEATED form fields <c>tag</c> and
/// <c>autoTag</c>, not a single CSV <c>Tags</c> value.</summary>
[HttpPost("/playlists/{playlistId:long}/tags")]
[HttpPut("/playlists/{playlistId:long}/tags")]
[Authorize]
public Task<IActionResult> PlaylistTags(long playlistId, [FromBody] TagsRequest? req,
[FromForm(Name = "Tags")] string? formValue) =>
ApplyMutation(playlistId, p =>
public async Task<IActionResult> PlaylistTags(long playlistId)
{
var tags = await ReadFieldsAsync("tag", "Tag", "autoTag", "AutoTag", "tags", "Tags");
return await ApplyMutation(playlistId, p =>
{
var csv = req?.Tags
?? (req?.TagsList is { Count: > 0 } list ? string.Join(',', list) : null)
?? formValue
?? p.TagsCsv;
p.TagsCsv = csv;
if (tags.Count > 0)
p.TagsCsv = string.Join(',', tags.Distinct(StringComparer.OrdinalIgnoreCase));
});
}
/// <summary>Accessibility / visibility / level-voting / restrictions /
/// warning are persisted on the playlist row even though the 2020
/// PlaylistEntity doesn't have dedicated columns for each — the wire
/// shape always reports the union-entry defaults, so for now the
/// mutation is a no-op acknowledgement that returns the unchanged
/// playlist. Real per-field columns can be added when we have a
/// playlist-settings UI to drive them.</summary>
[HttpPost("/playlists/{playlistId:long}/accessibility")]
[HttpPut("/playlists/{playlistId:long}/accessibility")]
[HttpPost("/playlists/{playlistId:long}/visibility")]
[HttpPost("/playlists/{playlistId:long}/levelvoting")]
[HttpPost("/playlists/{playlistId:long}/restrictions")]
[HttpPost("/playlists/{playlistId:long}/warning")]
[HttpPut("/playlists/{playlistId:long}/visibility")]
[Authorize]
public Task<IActionResult> PlaylistAck(long playlistId) =>
ApplyMutation(playlistId, _ => { /* no-op: field not persisted yet */ });
public async Task<IActionResult> PlaylistAccessibility(long playlistId)
{
var value = await ReadFieldAsync(
"accessibility", "Accessibility", "visibility", "Visibility", "value", "Value");
return await ApplyMutation(playlistId, p =>
{
if (int.TryParse(value, out var v)) p.Accessibility = v;
});
}
[HttpPost("/playlists/{playlistId:long}/levelvoting")]
[HttpPut("/playlists/{playlistId:long}/levelvoting")]
[Authorize]
public async Task<IActionResult> PlaylistLevelVoting(long playlistId)
{
var value = await ReadFieldAsync("supportsLevelVoting", "SupportsLevelVoting", "value", "Value");
return await ApplyMutation(playlistId, p =>
{
if (bool.TryParse(value, out var v)) p.SupportsLevelVoting = v;
});
}
[HttpPost("/playlists/{playlistId:long}/restrictions")]
[HttpPut("/playlists/{playlistId:long}/restrictions")]
[Authorize]
public async Task<IActionResult> PlaylistRestrictions(long playlistId)
{
var juniors = await ReadFieldAsync("supportsJuniors", "SupportsJuniors");
var screens = await ReadFieldAsync("supportsScreens", "SupportsScreens");
var teleport = await ReadFieldAsync("supportsTeleportVR", "SupportsTeleportVR");
var walk = await ReadFieldAsync("supportsWalkVR", "SupportsWalkVR");
return await ApplyMutation(playlistId, p =>
{
if (bool.TryParse(juniors, out var j)) p.SupportsJuniors = j;
if (bool.TryParse(screens, out var c)) p.SupportsScreens = c;
if (bool.TryParse(teleport, out var t)) p.SupportsTeleportVR = t;
if (bool.TryParse(walk, out var w)) p.SupportsWalkVR = w;
});
}
[HttpPost("/playlists/{playlistId:long}/warning")]
[HttpPut("/playlists/{playlistId:long}/warning")]
[Authorize]
public async Task<IActionResult> PlaylistWarning(long playlistId)
{
var mask = await ReadFieldAsync("warningMask", "WarningMask");
var custom = await ReadFieldAsync("customWarning", "CustomWarning");
return await ApplyMutation(playlistId, p =>
{
if (int.TryParse(mask, out var m)) p.WarningMask = m;
if (custom is not null) p.CustomWarning = custom.Length > 512 ? custom[..512] : custom;
});
}
/// <summary>First value present under any of the given names, looking at
/// the form, the query string, then a JSON body. Null means "not sent",
/// which every caller treats as "leave this field alone".</summary>
private async Task<string?> ReadFieldAsync(params string[] names)
{
var all = await ReadFieldsAsync(names);
return all.Count > 0 ? all[0] : null;
}
/// <summary>Every value present under any of the given names — the client
/// repeats a field name to send a list.</summary>
private async Task<List<string>> ReadFieldsAsync(params string[] names)
{
var found = new List<string>();
if (Request.HasFormContentType)
{
var form = await Request.ReadFormAsync();
foreach (var n in names)
foreach (var v in form[n])
if (!string.IsNullOrWhiteSpace(v)) found.Add(v!);
}
foreach (var n in names)
foreach (var v in Request.Query[n])
if (!string.IsNullOrWhiteSpace(v)) found.Add(v!);
if (found.Count > 0) return found;
if (!Request.HasFormContentType)
{
try
{
Request.EnableBuffering();
Request.Body.Position = 0;
using var doc = await System.Text.Json.JsonDocument.ParseAsync(Request.Body);
Request.Body.Position = 0;
var root = doc.RootElement;
// The tags endpoint sends a bare JSON array of strings.
if (root.ValueKind == System.Text.Json.JsonValueKind.Array)
{
foreach (var el in root.EnumerateArray())
if (el.ValueKind == System.Text.Json.JsonValueKind.String)
found.Add(el.GetString()!);
return found;
}
if (root.ValueKind == System.Text.Json.JsonValueKind.Object)
{
foreach (var n in names)
{
if (!root.TryGetProperty(n, out var v)) continue;
if (v.ValueKind == System.Text.Json.JsonValueKind.Array)
{
foreach (var el in v.EnumerateArray()) found.Add(el.ToString());
}
else if (v.ValueKind != System.Text.Json.JsonValueKind.Null)
{
found.Add(v.ToString());
}
if (found.Count > 0) break;
}
}
}
catch (System.Text.Json.JsonException) { /* no usable body */ }
}
return found;
}
private async Task<IActionResult> ApplyMutation(long playlistId, Action<PlaylistEntity> mutator)
{
@@ -72,6 +72,30 @@ public class RoleController(DorkNetDbContext db) : ControllerBase
return Ok(isAdmin ?? false);
}
/// <summary>The March-2023 client probes this one in addition to
/// <c>role/developer</c>. Both calls funnel through the same helper
/// (<c>OPMAPIOEIFG.ILHILCFCNFF</c>, which formats <c>"role/{0}/{1}"</c>
/// and issues it with HTTPMethods.GET); the two call sites differ only
/// in the role literal they pass — <c>"developer"</c> at
/// RecNet.Runtime/OPMAPIOEIFG.txt:9402 and <c>"moderator"</c> at
/// :9471. The 2020 build never referenced the moderator probe, so it
/// used to 404 for every remote player on the 2023 client.
///
/// DorkNet has no dedicated moderator bit, so admins and community-team
/// members answer true — they are the accounts that actually hold
/// moderation powers here.</summary>
[HttpGet("/role/moderator/{accountId:long}")]
[HttpGet("/api/role/moderator/{accountId:long}")]
[AllowAnonymous]
public async Task<ActionResult<bool>> IsModerator(long accountId)
{
var isMod = await db.Players
.Where(p => p.Id == accountId)
.Select(p => (bool?)(p.IsAdmin || p.IsCommunityTeam))
.FirstOrDefaultAsync();
return Ok(isMod ?? false);
}
/// <summary>Future-proofing for a hypothetical
/// <c>role/communityteam/{id}</c> probe — not referenced by the 2020
/// build, but mirrors the developer shape so a patched watch can
@@ -227,6 +227,11 @@ public class RoomConsumablesController(DorkNetDbContext db, LevelService level)
/// — buy one stack unit with tokens. Response is
/// <c>{ OperationResult, BalanceUpdateResult, TokenBalanceResponse:
/// { CurrencyType, Balance, Platform } }</c>.</summary>
// The 2023-03-21 client PUTs this (verb literal 3 = PUT, at the route
// construction in RecNet.Runtime/DCFKEFHJAGC.txt instrs 097-108), so a
// POST-only mapping returned 405 and token-priced consumables were
// unbuyable. Both verbs are accepted now.
[HttpPut("api/roomconsumables/v1/roomconsumable/{publicId:guid}/purchase/tokens")]
[HttpPost("api/roomconsumables/v1/roomconsumable/{publicId:guid}/purchase/tokens")]
[Authorize]
public async Task<IActionResult> PurchaseTokens(Guid publicId)
@@ -263,6 +268,9 @@ public class RoomConsumablesController(DorkNetDbContext db, LevelService level)
/// — buy with a room currency. Response is <c>{ OperationResult,
/// BalanceUpdateResult, CurrencyBalanceResponse: { AccountId,
/// CurrencyId, Balance, ModifiedAt } }</c>.</summary>
// PUT per the client (verb literal 3 at DCFKEFHJAGC.txt instrs 112-123);
// POST-only returned 405 and currency-priced consumables were unbuyable.
[HttpPut("api/roomconsumables/v1/roomconsumable/{publicId:guid}/purchase/currency")]
[HttpPost("api/roomconsumables/v1/roomconsumable/{publicId:guid}/purchase/currency")]
[Authorize]
public async Task<IActionResult> PurchaseCurrency(Guid publicId)
@@ -304,6 +312,10 @@ public class RoomConsumablesController(DorkNetDbContext db, LevelService level)
/// NewConcurrencyCode }</c>; response <c>{ Status, InventoryItem }</c>.
/// On a code mismatch the current row is returned so the client can
/// resync.</summary>
// PUT per the client (route format "{0}/v1/roomConsumable/{1}/consume"
// with verb literal 3 at RecNet.Runtime/LPNHMEFDAAG.txt:1449-1460);
// POST-only returned 405, so no consumable could ever be consumed.
[HttpPut("api/roomconsumables/v1/roomConsumable/{publicId:guid}/consume")]
[HttpPost("api/roomconsumables/v1/roomConsumable/{publicId:guid}/consume")]
[Authorize]
public async Task<IActionResult> Consume(Guid publicId)
@@ -404,6 +416,18 @@ public class RoomConsumablesController(DorkNetDbContext db, LevelService level)
},
};
/// <summary>Consumable descriptor as the client READS it.
///
/// The price is FLAT here — the client's response formatter FCIBLPCOODP
/// reads <c>Price</c>, <c>PurchaseCurrencyId</c> and <c>ModifiedAt</c>
/// directly off the descriptor (RecNet.Runtime/FCIBLPCOODP.txt:670-710).
/// Emitting a nested <c>PriceAndCurrency</c> object left every item in
/// every room shop reading back Price=0 with a null currency, so the whole
/// catalogue looked token-priced and free.
///
/// Note the asymmetry: REQUEST bodies really do nest PriceAndCurrency, and
/// ReadBodyAsync below still flattens that on the way in. Only the response
/// shape changed.</summary>
private static object ToDescWire(RoomConsumableEntity c) => new
{
RoomConsumableId = c.PublicId,
@@ -411,7 +435,9 @@ public class RoomConsumablesController(DorkNetDbContext db, LevelService level)
c.Name,
c.Description,
c.ImageName,
PriceAndCurrency = new { c.Price, c.CurrencyId },
c.Price,
PurchaseCurrencyId = c.CurrencyId,
ModifiedAt = DateTime.SpecifyKind(c.UpdatedAt, DateTimeKind.Utc),
};
private static object ToInventoryWire(RoomConsumableOwnershipEntity own, RoomConsumableEntity item) => new
@@ -45,7 +45,7 @@ public class RoomCurrenciesController(DorkNetDbContext db, LevelService level) :
Name = name,
Description = Trim(ReadString(fields, "description", "Description"), 512),
ImageName = Trim(ReadString(fields, "imageName", "ImageName"), 256),
DailyLimit = Math.Max(0, ReadInt(fields, "dailyLimit", "DailyLimit") ?? 0),
DailyLimit = Math.Max(0, ReadInt(fields, "limit", "Limit", "dailyLimit", "DailyLimit") ?? 0),
};
db.RoomCurrencies.Add(currency);
await db.SaveChangesAsync();
@@ -65,7 +65,7 @@ public class RoomCurrenciesController(DorkNetDbContext db, LevelService level) :
if (ReadString(fields, "name", "Name") is { } name) currency.Name = Trim(name, 64);
if (ReadString(fields, "description", "Description") is { } description) currency.Description = Trim(description, 512);
if (ReadString(fields, "imageName", "ImageName") is { } imageName) currency.ImageName = Trim(imageName, 256);
if (ReadInt(fields, "dailyLimit", "DailyLimit") is int dailyLimit) currency.DailyLimit = Math.Max(0, dailyLimit);
if (ReadInt(fields, "limit", "Limit", "dailyLimit", "DailyLimit") is int dailyLimit) currency.DailyLimit = Math.Max(0, dailyLimit);
currency.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return Ok(ToWire(currency));
@@ -77,13 +77,20 @@ public class RoomCurrenciesController(DorkNetDbContext db, LevelService level) :
var fields = await ReadFieldsAsync();
var currency = await FindCurrencyAsync(fields);
if (currency is null) return NotFound();
var playerId = ReadLong(fields, "playerId", "PlayerId") ?? Me;
// The client sends "accountId" and can ask about ANOTHER player
// (circuits read other players' balances); reading only playerId made
// every such query silently answer for the caller instead.
var playerId = ReadLong(fields, "accountId", "AccountId", "playerId", "PlayerId") ?? Me;
var balance = await GetBalanceAsync(playerId, currency.Id);
return Ok(new
{
AccountId = (int)playerId,
CurrencyId = currency.PublicId,
Balance = balance,
ModifiedAt = DateTime.UtcNow,
// legacy aliases
PlayerId = (int)playerId,
RoomCurrencyId = currency.PublicId,
Balance = balance,
});
}
@@ -104,9 +111,13 @@ public class RoomCurrenciesController(DorkNetDbContext db, LevelService level) :
.ToListAsync();
return Ok(rows.Select(x => new
{
AccountId = (int)playerId,
CurrencyId = x.c.PublicId,
x.b.Balance,
ModifiedAt = DateTime.SpecifyKind(x.b.UpdatedAt, DateTimeKind.Utc),
// legacy aliases
PlayerId = (int)playerId,
RoomCurrencyId = x.c.PublicId,
x.b.Balance,
}));
}
@@ -373,33 +384,62 @@ public class RoomCurrenciesController(DorkNetDbContext db, LevelService level) :
return s.Length <= max ? s : s[..max];
}
/// <summary>Room currency as the client READS it (formatter PEOAOMGOMGC):
/// <c>CurrencyId</c>, <c>RoomId</c>, <c>Name</c>, <c>Description</c>,
/// <c>CurrencyType</c>, <c>Limit</c>, <c>ImageName</c>, <c>CreatedAt</c>,
/// <c>ModifiedAt</c>.
///
/// The previous names were server inventions (<c>RoomCurrencyId</c>,
/// <c>DailyLimit</c>, <c>UpdatedAt</c>) that shared almost no keys with
/// what the client reads, so every currency came back with an empty
/// CurrencyId and a zero limit — the room currency HUD could not match a
/// balance to a currency. The legacy aliases are kept alongside the correct
/// names because unknown members are ignored client-side and other DorkNet
/// surfaces (admin UI) still read them.</summary>
private static object ToWire(RoomCurrencyEntity currency) => new
{
CurrencyId = currency.PublicId,
currency.RoomId,
currency.Name,
currency.Description,
CurrencyType = 300,
Limit = (long)currency.DailyLimit,
currency.ImageName,
CreatedAt = DateTime.SpecifyKind(currency.CreatedAt, DateTimeKind.Utc),
ModifiedAt = DateTime.SpecifyKind(currency.UpdatedAt, DateTimeKind.Utc),
// legacy aliases
RoomCurrencyId = currency.PublicId,
Id = currency.PublicId,
InternalId = currency.Id,
currency.RoomId,
CreatorPlayerId = (int)currency.CreatorPlayerId,
currency.Name,
currency.Description,
currency.ImageName,
currency.DailyLimit,
currency.CreatedAt,
currency.UpdatedAt,
};
/// <summary>Purchase offer as the client READS it (formatter AALDCAANEMM):
/// <c>CurrencyPurchaseOfferId</c>, <c>CurrencyId</c>, <c>Name</c>,
/// <c>CurrencyAmount</c>, <c>Price</c>, <c>CurrencyType</c>,
/// <c>CreatedAt</c>, <c>ModifiedAt</c>. Legacy aliases retained as in
/// <see cref="ToWire"/>.</summary>
private static object ToOfferWire(RoomCurrencyPurchaseOfferEntity offer, RoomCurrencyEntity currency) => new
{
CurrencyPurchaseOfferId = offer.PublicId,
CurrencyId = currency.PublicId,
offer.Name,
CurrencyAmount = offer.Amount,
offer.Price,
offer.CurrencyType,
CreatedAt = DateTime.SpecifyKind(offer.CreatedAt, DateTimeKind.Utc),
ModifiedAt = DateTime.SpecifyKind(offer.UpdatedAt, DateTimeKind.Utc),
// legacy aliases
PurchaseOfferId = offer.PublicId,
RoomCurrencyPackageId = offer.PublicId,
Id = offer.PublicId,
InternalId = offer.Id,
RoomCurrencyId = currency.PublicId,
offer.Name,
offer.Amount,
offer.Price,
offer.CurrencyType,
offer.CreatedAt,
offer.UpdatedAt,
};
}
@@ -31,12 +31,21 @@ public class RoomKeysController(DorkNetDbContext db) : ControllerBase
return Ok(rows.Select(ToWire));
}
/// <summary>The watch key ring — keys the caller HOLDS. This used to return
/// keys the caller CREATED, which is a different set: a creator never buys
/// their own key, so players saw none of the keys they had actually
/// purchased. Keys the caller created are unioned in so room owners still
/// see their own.</summary>
[HttpGet("api/roomkeys/v1/mine")]
public async Task<IActionResult> Mine()
{
var pid = Me;
var purchasedIds = await db.RoomKeyPurchases
.Where(p => p.PlayerId == pid)
.Select(p => p.RoomKeyId)
.ToListAsync();
var rows = await db.RoomKeys
.Where(k => k.CreatorPlayerId == pid && !k.IsDeleted)
.Where(k => !k.IsDeleted && (purchasedIds.Contains(k.Id) || k.CreatorPlayerId == pid))
.OrderByDescending(k => k.UpdatedAt)
.ToListAsync();
return Ok(rows.Select(ToWire));
@@ -93,6 +102,25 @@ public class RoomKeysController(DorkNetDbContext db) : ControllerBase
return Ok(RoomKeyResponse(RoomKeyStatus.Success, key));
}
/// <summary>The 2023-03-21 client does not call <c>update</c>. It builds
/// <c>"api/roomkeys/v1/" + action</c> with verb PUT and a <c>RoomKeyId</c>
/// field, where action is one of <c>updateAll</c>, <c>updateName</c>,
/// <c>updateDescription</c> or <c>updatePrice</c>
/// (RecNet.Runtime/AHMBBJNANBP.txt: prefix concat at instr 058, verb 3 at
/// 067, <c>"RoomKeyId"</c> at 079; action literals at :1345, :1559, :1772,
/// :2043). None were registered, so every room-key edit 405'd.
///
/// All four share this handler: the request DTO's fields are already
/// optional, so a partial update naturally leaves the others untouched.</summary>
[HttpPut("api/roomkeys/v1/updateAll")]
[HttpPost("api/roomkeys/v1/updateAll")]
[HttpPut("api/roomkeys/v1/updateName")]
[HttpPost("api/roomkeys/v1/updateName")]
[HttpPut("api/roomkeys/v1/updateDescription")]
[HttpPost("api/roomkeys/v1/updateDescription")]
[HttpPut("api/roomkeys/v1/updatePrice")]
[HttpPost("api/roomkeys/v1/updatePrice")]
[HttpPut("api/roomkeys/v1/update")]
[HttpPost("api/roomkeys/v1/update")]
public async Task<IActionResult> Update()
{
@@ -140,35 +168,104 @@ public class RoomKeysController(DorkNetDbContext db) : ControllerBase
return Content(((int)RoomKeyStatus.Success).ToString(), "application/json");
}
/// <summary>"Has anyone bought this key?" — the creator calls this before a
/// destructive edit. It previously answered "has the CALLER bought it",
/// which is always false for the creator, so the guard never fired and
/// creators could silently invalidate keys players had paid for.</summary>
[HttpGet("api/roomkeys/v1/purchased/{roomKeyId:long}")]
public async Task<IActionResult> Purchased(long roomKeyId)
{
var pid = Me;
var owns = await db.RoomKeyPurchases.AnyAsync(p => p.RoomKeyId == roomKeyId && p.PlayerId == pid);
return Content(owns ? "true" : "false", "application/json");
var anyPurchase = await db.RoomKeyPurchases.AnyAsync(p => p.RoomKeyId == roomKeyId);
return Content(anyPurchase ? "true" : "false", "application/json");
}
/// <summary>The client sends both <c>roomKeyId</c> and <c>playerId</c> — key
/// doors evaluate OTHER players' ownership, not just the caller's. Ignoring
/// playerId meant a door asked about someone else and got the caller's
/// answer.</summary>
[HttpGet("api/roomkeys/v1/owns")]
public async Task<IActionResult> Owns([FromQuery] long roomKeyId)
public async Task<IActionResult> Owns([FromQuery] long roomKeyId, [FromQuery] long? playerId = null)
{
var pid = Me;
var owns = await db.RoomKeyPurchases.AnyAsync(p => p.RoomKeyId == roomKeyId && p.PlayerId == pid);
var subject = playerId is long p && p > 0 ? p : Me;
var owns = await db.RoomKeyPurchases.AnyAsync(x => x.RoomKeyId == roomKeyId && x.PlayerId == subject);
return Content(owns ? "true" : "false", "application/json");
}
/// <summary>Bulk key-ownership probe, used by key doors to gate a whole
/// room of players at once.
///
/// The 2023-03-21 client POSTs a JSON <b>array</b> of
/// <c>{RoomKeyId, AccountId}</c> pairs as a raw body
/// (RecNet.Runtime/AHMBBJNANBP_NestedType_JIPDBBEFAPN.txt instrs 044-065),
/// so each element names the player it is asking about. The old
/// query/form-only reader saw an empty id list and answered for the caller,
/// meaning key doors evaluated the wrong player — or nobody at all.</summary>
[HttpGet("api/roomkeys/v1/owns/bulk")]
[HttpPost("api/roomkeys/v1/owns/bulk")]
[Consumes("application/json", "application/x-www-form-urlencoded", "multipart/form-data")]
public async Task<IActionResult> OwnsBulk()
{
var ids = await ReadRoomKeyIdsAsync();
if (ids.Count == 0) return Ok(Array.Empty<object>());
var pid = Me;
var owned = await db.RoomKeyPurchases
.Where(p => p.PlayerId == pid && ids.Contains(p.RoomKeyId))
.Select(p => p.RoomKeyId)
.ToListAsync();
var ownedSet = owned.ToHashSet();
return Ok(ids.Select(id => new { RoomKeyId = id, Owns = ownedSet.Contains(id) }).ToList());
var pairs = await ReadOwnershipPairsAsync();
if (pairs.Count == 0)
{
// Legacy query/form path: ids only, always about the caller.
var ids = await ReadRoomKeyIdsAsync();
if (ids.Count == 0) return Ok(Array.Empty<object>());
pairs = ids.Select(id => (RoomKeyId: id, AccountId: Me)).ToList();
}
var keyIds = pairs.Select(p => p.RoomKeyId).Distinct().ToList();
var accountIds = pairs.Select(p => p.AccountId).Distinct().ToList();
var owned = (await db.RoomKeyPurchases
.Where(p => keyIds.Contains(p.RoomKeyId) && accountIds.Contains(p.PlayerId))
.Select(p => new { p.RoomKeyId, p.PlayerId })
.ToListAsync())
.Select(p => (p.RoomKeyId, p.PlayerId))
.ToHashSet();
return Ok(pairs.Select(p => new
{
p.RoomKeyId,
AccountId = (int)p.AccountId,
DoesPlayerOwnRoomKey = owned.Contains((p.RoomKeyId, p.AccountId)),
// legacy alias
Owns = owned.Contains((p.RoomKeyId, p.AccountId)),
}).ToList());
}
/// <summary>Parse the 2023 client's JSON array of
/// <c>{RoomKeyId, AccountId}</c> pairs. Returns empty for a form post or a
/// body that isn't an array, so the legacy path still applies.</summary>
private async Task<List<(long RoomKeyId, long AccountId)>> ReadOwnershipPairsAsync()
{
var pairs = new List<(long, long)>();
if (Request.HasFormContentType) return pairs;
try
{
Request.EnableBuffering();
Request.Body.Position = 0;
using var doc = await System.Text.Json.JsonDocument.ParseAsync(Request.Body);
Request.Body.Position = 0;
if (doc.RootElement.ValueKind != System.Text.Json.JsonValueKind.Array) return pairs;
foreach (var el in doc.RootElement.EnumerateArray())
{
if (el.ValueKind != System.Text.Json.JsonValueKind.Object) continue;
long? Read(params string[] names)
{
foreach (var n in names)
if (el.TryGetProperty(n, out var v) &&
v.ValueKind == System.Text.Json.JsonValueKind.Number &&
v.TryGetInt64(out var x)) return x;
return null;
}
var keyId = Read("RoomKeyId", "roomKeyId");
if (keyId is not long k || k <= 0) continue;
pairs.Add((k, Read("AccountId", "accountId", "PlayerId", "playerId") ?? Me));
}
}
catch (System.Text.Json.JsonException) { /* not a JSON array body */ }
return pairs;
}
public static object RoomKeyResponse(RoomKeyStatus status, RoomKeyEntity? key) => new
@@ -300,6 +397,8 @@ public class RoomKeysController(DorkNetDbContext db) : ControllerBase
public sealed class UpdateRoomKeyRequest
{
// The 2023 client spells this "RoomKeyId"; System.Text.Json is
// configured case-insensitively so both land on this property.
[JsonPropertyName("roomKeyId")] public long RoomKeyId { get; set; }
[JsonPropertyName("name")] public string? Name { get; set; }
[JsonPropertyName("description")] public string? Description { get; set; }
@@ -304,7 +304,11 @@ public class RoomsController(
/// </summary>
[HttpGet("api/rooms/v2/baserooms")]
[HttpGet("api/rooms/v3/baserooms")]
// The 2023 client issues this from NLDBPDCNNCF, whose requests all carry
// the roomserver/ prefix in this deployment — without the twin the base/AG
// room list 404s and the room browser's base section is empty.
[HttpGet("rooms/base")]
[HttpGet("roomserver/rooms/base")]
public async Task<IActionResult> BaseRooms()
{
var names = await serverSettings.GetBaseRoomNamesAsync();
@@ -2532,7 +2536,9 @@ public class RoomsController(
/// <c>api/rooms/v3/featured</c>). Returning the
/// <see cref="Featured"/> shape here throws KeyNotFoundException on
/// the watch's group-id lookup.</summary>
// Same roomserver/ prefix requirement as rooms/base.
[HttpGet("featuredrooms/current")]
[HttpGet("roomserver/featuredrooms/current")]
public async Task<IActionResult> FeaturedRoomsCurrent()
{
var ids = await rooms.FeaturedAgRoomIdsAsync(12);
@@ -2540,10 +2546,22 @@ public class RoomsController(
.Where(r => ids.Contains(r.Id))
.ToListAsync();
var byId = featuredRooms.ToDictionary(r => r.Id);
// The featured-tile reader looks for RoomName, which ToWireRoom does not
// emit — every tile's caption came back null. Re-project the wire object
// into a dictionary so the extra key sits alongside the real ones rather
// than nesting them.
var wireRooms = ids
.Select(id => byId.TryGetValue(id, out var room) ? RoomService.ToWireRoom(room) : null)
.Where(room => room is not null)
.Cast<object>()
.Where(byId.ContainsKey)
.Select(id =>
{
var room = byId[id];
var wire = RoomService.ToWireRoom(room);
var map = new Dictionary<string, object?>();
foreach (var prop in wire.GetType().GetProperties())
map[prop.Name] = prop.GetValue(wire);
map["RoomName"] = room.Name;
return (object)map;
})
.ToList();
return Ok(new
{
@@ -10,22 +10,50 @@ namespace DorkNet.Server.Controllers.API.ScreenShareReports;
[Authorize]
public class ScreenShareReportsController(DorkNetDbContext db) : ControllerBase
{
/// <summary>Screen-share (photo) report. The 2023-03-21 client posts the
/// form keys <c>ImageName</c>, <c>ReportedPlayerId</c>, <c>RoomId</c>,
/// <c>RoomInstanceId</c>, <c>RoomInstanceType</c> and <c>Details</c>
/// (RecNet.Runtime/KAEAEIODGBG.txt:281-360). It sends no
/// <c>reportCategory</c> at all.
///
/// The old signature bound <c>targetPlayerId</c>, so the reported player
/// was stored as 0 on every screen-share report and the reported image was
/// dropped entirely — the report was unactionable. Form binding is
/// case-insensitive, so <c>RoomId</c>/<c>Details</c> did land; the names
/// that differ are the ones spelled out explicitly below.</summary>
[HttpPost("api/screensharereports/v1/report")]
public async Task<IActionResult> Report(
[FromForm(Name = "ReportedPlayerId")] long? reportedPlayerId,
[FromForm] long? targetPlayerId,
[FromForm] long? roomId,
[FromForm(Name = "RoomInstanceId")] long? roomInstanceId,
[FromForm(Name = "RoomInstanceType")] int? roomInstanceType,
[FromForm(Name = "ImageName")] string? imageName,
[FromForm] int? reportCategory,
[FromForm] string? details)
{
var reporter = this.RequireCurrentPlayerId();
var target = targetPlayerId
?? (long.TryParse(Request.Query["targetPlayerId"].FirstOrDefault(), out var qTarget) ? qTarget : 0);
var target = reportedPlayerId
?? targetPlayerId
?? (long.TryParse(Request.Query["ReportedPlayerId"].FirstOrDefault()
?? Request.Query["targetPlayerId"].FirstOrDefault(), out var qTarget) ? qTarget : 0);
var room = roomId
?? (long.TryParse(Request.Query["roomId"].FirstOrDefault(), out var qRoom) ? qRoom : 0);
// The client never sends a category for screen-share reports; 5 is the
// server's existing "other" bucket.
var category = reportCategory
?? (int.TryParse(Request.Query["reportCategory"].FirstOrDefault(), out var qCategory) ? qCategory : 5);
var message = details ?? Request.Query["details"].FirstOrDefault() ?? string.Empty;
// Keep the evidence (image + instance) in the stored text: ReportEntity
// has no dedicated columns for them and a report without the offending
// image is not actionable by a moderator.
var context = new List<string>();
if (!string.IsNullOrWhiteSpace(imageName)) context.Add($"image={imageName}");
if (roomInstanceId is > 0) context.Add($"instance={roomInstanceId}");
if (roomInstanceType is not null) context.Add($"instanceType={roomInstanceType}");
if (context.Count > 0) message = $"[screenshare {string.Join(' ', context)}] {message}".Trim();
db.Reports.Add(new ReportEntity
{
ReporterPlayerId = reporter,
@@ -11,9 +11,17 @@ namespace DorkNet.Server.Controllers.API.UgcPurchasables;
[ApiController]
public class UgcPurchasablesController(DorkNetDbContext db) : ControllerBase
{
/// <summary>The 2023-03-21 client asks for a room's in-room store with the
/// path form <c>api/ugcPurchasables/v1/items/room/{roomId}</c>, not the
/// <c>?roomId=</c> query this handler was originally written for. Without
/// the path route it 404'd and the in-room UGC store never populated on
/// room join.</summary>
[HttpGet("api/ugcPurchasables/v1/items/room/{roomId:long}")]
[HttpGet("api/ugcPurchasables")]
[AllowAnonymous]
public async Task<IActionResult> List([FromQuery] long roomId = 0)
// No explicit binding source: roomId comes from the route on the path form
// above and from the query string on the bare route.
public async Task<IActionResult> List(long roomId = 0)
{
var rows = await db.UgcPurchasables
.Where(i => !i.IsDeleted && (roomId <= 0 || i.RoomId == roomId))
@@ -175,11 +175,18 @@ public class AccountsController(
[HttpGet("/account/search")]
[HttpGet("/account/v1/search")]
[Microsoft.AspNetCore.Authorization.AllowAnonymous]
public async Task<IActionResult> Search([FromQuery] string? query, [FromQuery] int take = 20)
// The 2023-03-21 client names the search term "name"; binding only "query"
// meant every player search came back empty (the handler short-circuited on
// a null term before it ever reached the service).
public async Task<IActionResult> Search(
[FromQuery] string? query,
[FromQuery] string? name,
[FromQuery] int take = 20)
{
if (string.IsNullOrWhiteSpace(query)) return Ok(Array.Empty<object>());
var term = !string.IsNullOrWhiteSpace(name) ? name : query;
if (string.IsNullOrWhiteSpace(term)) return Ok(Array.Empty<object>());
var clamped = Math.Clamp(take, 1, 50);
var matches = await playerService.SearchAsync(query.Trim(), clamped);
var matches = await playerService.SearchAsync(term.Trim(), clamped);
return Ok(matches
.Select(p => BuildAccount(p.Id, p.Username, p.DisplayName, p.ProfileImageName))
.ToList());
@@ -338,6 +345,10 @@ public class AccountsController(
/// cutoff is &lt;13 years old; we recompute it on every birthday
/// write so a freshly turned 13 player loses the junior flag the
/// next time they update.</summary>
// The 2023 client PUTs birthday (verb 3 at OPMAPIOEIFG.txt:7950-7954);
// POST-only returned 405 and the birthday step of onboarding always failed.
[HttpPut("/account/v1/birthday")]
[HttpPut("/account/me/birthday")]
[HttpPost("/account/v1/birthday")]
[HttpPost("/account/me/birthday")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
@@ -325,6 +325,9 @@ public class AuthController(
return Ok(results);
}
// Sign-out / "forget this account" sends DELETE (LBNJFPOLCDL.txt:4190,
// verb 4); GET-only returned 405 and the forget-account promise rejected.
[HttpDelete("/cachedlogin/current")]
[HttpGet("/cachedlogin/current")]
public async Task<IActionResult> CurrentCachedLogin()
{
@@ -334,6 +334,9 @@ public class ChatController(
/// inbox. DMs can't be left — there are only two participants and
/// removing one effectively ends the conversation; the watch
/// surfaces a "delete chat" option for DMs instead.</summary>
// The 2023-03-21 client POSTs this; DELETE-only returned 405 and leaving a
// group chat was impossible on that build.
[HttpPost("/thread/{chatThreadId}/leave")]
[HttpDelete("/thread/{chatThreadId}/leave")]
public async Task<IActionResult> Leave(string chatThreadId)
{
@@ -272,6 +272,20 @@ public class ClubsController(ClubService clubs) : ControllerBase
{
if (HttpMethods.IsPut(Request.Method) || HttpMethods.IsDelete(Request.Method))
{
// The client sends roomId in the form-urlencoded BODY, not the query
// string. Reading only the query bound null, and the PUT branch then
// wrote that null straight through — so "set clubhouse" silently
// CLEARED the clubhouse instead of setting it.
if (roomId is null && Request.HasFormContentType)
{
var form = await Request.ReadFormAsync();
foreach (var key in new[] { "roomId", "RoomId" })
if (long.TryParse(form[key].FirstOrDefault(), out var fromForm) && fromForm > 0)
{
roomId = fromForm;
break;
}
}
try
{
var updated = await clubs.ModifyAsync(clubId, Me, c =>
@@ -368,7 +382,7 @@ public class ClubsController(ClubService clubs) : ControllerBase
[HttpPost("/club/{clubId:long}/modifydetails")]
[HttpPut("/club/{clubId:long}/modifydetails")]
[Authorize]
public async Task<IActionResult> ClubModify(long clubId, [FromBody] ModifyClubRequest req)
public async Task<IActionResult> ClubModify(long clubId, [ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))] ModifyClubRequest req)
{
try
{
@@ -396,7 +410,7 @@ public class ClubsController(ClubService clubs) : ControllerBase
[HttpPost("/club/{clubId:long}/mainimage")]
[HttpPut("/club/{clubId:long}/mainimage")]
[Authorize]
public async Task<IActionResult> ClubMainImage(long clubId, [FromBody] MainImageRequest req)
public async Task<IActionResult> ClubMainImage(long clubId, [ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))] MainImageRequest req)
{
try
{
@@ -417,7 +431,7 @@ public class ClubsController(ClubService clubs) : ControllerBase
[HttpPost("/club/{clubId:long}/additionalimage/{slot:int}")]
[HttpPut("/club/{clubId:long}/additionalimage/{slot:int}")]
[Authorize]
public async Task<IActionResult> ClubAdditionalImage(long clubId, int slot, [FromBody] MainImageRequest req)
public async Task<IActionResult> ClubAdditionalImage(long clubId, int slot, [ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))] MainImageRequest req)
{
var club = await clubs.GetByIdAsync(clubId);
if (club is null) return NotFound();
@@ -450,10 +464,15 @@ public class ClubsController(ClubService clubs) : ControllerBase
{
var club = await clubs.GetByIdAsync(clubId);
if (club is null) return NotFound();
// Joinability enum, confirmed from the 2023 dump (enum FEHIHCMDOLN):
// Open = 0, InviteOnly = 1, AskToJoin = 2.
// This used to treat 1 as request-to-join and 2 as invite-only, i.e.
// exactly backwards: "Ask to join" clubs rejected every request while
// invite-only clubs quietly accepted them.
var perms = club.Joinability switch
{
0 => 0, // Open: instant member
1 => 128, // RequestToJoin: pending
2 => 128, // AskToJoin: pending approval
_ => -1, // InviteOnly: rejected
};
if (perms == -1) return Forbid();
@@ -503,7 +522,7 @@ public class ClubsController(ClubService clubs) : ControllerBase
[HttpPost("/club/{clubId:long}/members/invite")]
[HttpPut("/club/{clubId:long}/members/invite")]
[Authorize]
public async Task<IActionResult> MemberInvite(long clubId, [FromBody] MemberInviteRequest req)
public async Task<IActionResult> MemberInvite(long clubId, [ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))] MemberInviteRequest req)
{
if (!await clubs.CanManageAsync(clubId, Me)) return Forbid();
var target = req.PlayerId ?? req.AccountId ?? 0;
@@ -524,7 +543,7 @@ public class ClubsController(ClubService clubs) : ControllerBase
[HttpPost("/club/{clubId:long}/members/invitemembers")]
[Authorize]
public async Task<IActionResult> MemberInviteMembers(long clubId, [FromBody] InviteMembersRequest req)
public async Task<IActionResult> MemberInviteMembers(long clubId, [ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))] InviteMembersRequest req)
{
if (!await clubs.CanManageAsync(clubId, Me)) return Forbid();
var ids = (req.PlayerIds ?? req.AccountIds ?? new List<int>()).Distinct().ToList();
@@ -568,7 +587,7 @@ public class ClubsController(ClubService clubs) : ControllerBase
/// target's row from Pending to Member.</summary>
[HttpPost("/club/{clubId:long}/members/acceptrequest")]
[Authorize]
public async Task<IActionResult> MemberAcceptRequest(long clubId, [FromBody] MemberTargetRequest req)
public async Task<IActionResult> MemberAcceptRequest(long clubId, [ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))] MemberTargetRequest req)
{
if (!await clubs.CanManageAsync(clubId, Me)) return Forbid();
var target = req.PlayerId ?? req.AccountId ?? 0;
@@ -588,7 +607,7 @@ public class ClubsController(ClubService clubs) : ControllerBase
[HttpPost("/club/{clubId:long}/members/acceptrequests")]
[Authorize]
public async Task<IActionResult> MemberAcceptRequests(long clubId, [FromBody] BulkTargetRequest req)
public async Task<IActionResult> MemberAcceptRequests(long clubId, [ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))] BulkTargetRequest req)
{
if (!await clubs.CanManageAsync(clubId, Me)) return Forbid();
var ids = (req.PlayerIds ?? req.AccountIds ?? new List<int>()).Distinct().ToList();
@@ -603,7 +622,7 @@ public class ClubsController(ClubService clubs) : ControllerBase
[HttpPost("/club/{clubId:long}/members/denyrequest")]
[Authorize]
public async Task<IActionResult> MemberDenyRequest(long clubId, [FromBody] MemberTargetRequest req)
public async Task<IActionResult> MemberDenyRequest(long clubId, [ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))] MemberTargetRequest req)
{
if (!await clubs.CanManageAsync(clubId, Me)) return Forbid();
var target = req.PlayerId ?? req.AccountId ?? 0;
@@ -614,7 +633,7 @@ public class ClubsController(ClubService clubs) : ControllerBase
[HttpPost("/club/{clubId:long}/members/denyrequests")]
[Authorize]
public async Task<IActionResult> MemberDenyRequests(long clubId, [FromBody] BulkTargetRequest req)
public async Task<IActionResult> MemberDenyRequests(long clubId, [ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))] BulkTargetRequest req)
{
if (!await clubs.CanManageAsync(clubId, Me)) return Forbid();
var ids = (req.PlayerIds ?? req.AccountIds ?? new List<int>()).Distinct().ToList();
@@ -625,7 +644,7 @@ public class ClubsController(ClubService clubs) : ControllerBase
[HttpPost("/club/{clubId:long}/members/remove")]
[Authorize]
public async Task<IActionResult> MemberRemove(long clubId, [FromBody] MemberTargetRequest req)
public async Task<IActionResult> MemberRemove(long clubId, [ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))] MemberTargetRequest req)
{
var club = await clubs.GetByIdAsync(clubId);
if (club is null) return NotFound();
@@ -643,7 +662,7 @@ public class ClubsController(ClubService clubs) : ControllerBase
/// request-to-join flow can refuse them later.</summary>
[HttpPost("/club/{clubId:long}/members/ban")]
[Authorize]
public async Task<IActionResult> MemberBan(long clubId, [FromBody] MemberTargetRequest req)
public async Task<IActionResult> MemberBan(long clubId, [ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))] MemberTargetRequest req)
{
var club = await clubs.GetByIdAsync(clubId);
if (club is null) return NotFound();
@@ -658,7 +677,7 @@ public class ClubsController(ClubService clubs) : ControllerBase
[HttpPost("/club/{clubId:long}/members/unban")]
[Authorize]
public async Task<IActionResult> MemberUnban(long clubId, [FromBody] MemberTargetRequest req)
public async Task<IActionResult> MemberUnban(long clubId, [ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))] MemberTargetRequest req)
{
if (!await clubs.CanManageAsync(clubId, Me)) return Forbid();
var target = req.PlayerId ?? req.AccountId ?? 0;
@@ -674,7 +693,7 @@ public class ClubsController(ClubService clubs) : ControllerBase
[HttpPost("/club/{clubId:long}/members/changetype")]
[HttpPut("/club/{clubId:long}/members/changetype")]
[Authorize]
public async Task<IActionResult> MemberChangeType(long clubId, [FromBody] MemberTargetRequest req)
public async Task<IActionResult> MemberChangeType(long clubId, [ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))] MemberTargetRequest req)
{
var club = await clubs.GetByIdAsync(clubId);
if (club is null) return NotFound();
@@ -759,7 +778,6 @@ public class ClubsController(ClubService clubs) : ControllerBase
/// double-fires are safe.</summary>
[HttpDelete("/announcements/club/{clubId:long}/{announcementId:long}")]
[HttpPost("/announcements/club/{clubId:long}/{announcementId:long}")]
[HttpPut("/announcements/club/{clubId:long}/{announcementId:long}")]
[Authorize]
public async Task<IActionResult> AnnouncementDelete(long clubId, long announcementId)
{
@@ -767,6 +785,72 @@ public class ClubsController(ClubService clubs) : ControllerBase
return Ok();
}
/// <summary>PUT <c>/announcements/club/{clubId}/{announcementId}</c> — EDIT
/// an announcement.
///
/// PUT used to be a third binding on <see cref="AnnouncementDelete"/>, so
/// editing an announcement silently DELETED it — the edit form's save
/// button destroyed the post it was editing. It is its own handler now.</summary>
[HttpPut("/announcements/club/{clubId:long}/{announcementId:long}")]
[Authorize]
public async Task<IActionResult> AnnouncementEdit(long clubId, long announcementId)
{
var (title, body, imageName) = await ReadAnnouncementFieldsAsync();
if (!await clubs.UpdateAnnouncementAsync(clubId, announcementId, Me, title, body, imageName))
return Forbid();
return Ok();
}
/// <summary>POST <c>/announcements/club/{clubId}</c> — CREATE an
/// announcement. The client posts the fields to the COLLECTION url and
/// reads the response as a bare Int64 (the new announcement id). No POST
/// was registered at that template, so posting a club announcement 404'd.</summary>
[HttpPost("/announcements/club/{clubId:long}")]
[Authorize]
public async Task<IActionResult> AnnouncementCreate(long clubId)
{
var (title, body, imageName) = await ReadAnnouncementFieldsAsync();
var id = await clubs.CreateAnnouncementAsync(
clubId, Me, title ?? string.Empty, body ?? string.Empty, imageName ?? string.Empty);
if (id is null) return Forbid();
return Content(id.Value.ToString(), "application/json");
}
/// <summary>Announcement fields as the client sends them: form-urlencoded,
/// with a JSON body accepted as a fallback. Null means "not supplied", so
/// an edit can leave a field alone.</summary>
private async Task<(string? Title, string? Body, string? ImageName)> ReadAnnouncementFieldsAsync()
{
if (Request.HasFormContentType)
{
var form = await Request.ReadFormAsync();
string? F(params string[] keys)
{
foreach (var k in keys)
if (form.TryGetValue(k, out var v) && v.Count > 0) return v.ToString();
return null;
}
return (F("title", "Title"), F("body", "Body"), F("imageName", "ImageName"));
}
try
{
Request.EnableBuffering();
Request.Body.Position = 0;
using var doc = await System.Text.Json.JsonDocument.ParseAsync(Request.Body);
Request.Body.Position = 0;
string? P(params string[] keys)
{
foreach (var k in keys)
if (doc.RootElement.TryGetProperty(k, out var v) &&
v.ValueKind == System.Text.Json.JsonValueKind.String) return v.GetString();
return null;
}
return (P("title", "Title"), P("body", "Body"), P("imageName", "ImageName"));
}
catch (System.Text.Json.JsonException) { return (null, null, null); }
}
// ── Wire-shape mappers ──────────────────────────────────────────
/// <summary>
@@ -98,12 +98,14 @@ public class EconController(DorkNetDbContext db, LevelService level) : Controlle
});
}
/// <summary>The client reads this body as a BARE integer — the issuing
/// method is <c>FGLDKEJLAKB&lt;System.Int32&gt; KPOENLDFAJD()</c>
/// (RecNet.Runtime/MPBLNLMCEDL.txt:2881, route literal on :2955).
/// Returning a JSON object made the deserialize throw, so the ownership-cap
/// check that runs before a custom-shirt purchase always errored out.</summary>
[HttpGet("/econ/customAvatarItems/v1/itemOwnershipLimit")]
public IActionResult CustomAvatarItemOwnershipLimit() => Ok(new
{
Limit = 1000,
ItemOwnershipLimit = 1000,
});
public IActionResult CustomAvatarItemOwnershipLimit()
=> Content("1000", "application/json");
private static object ToCustomAvatarItemWire(DorkNet.Server.Data.Entities.CustomAvatarItemEntity i) => new
{
@@ -323,6 +323,10 @@ public class MatchController(
/// PrivateInstance row. The watch only needs a RecNet result
/// wrapper, but the server persists the privacy state so later
/// invite joins are enforced by <see cref="PrivateInstanceService"/>.</summary>
// The 2023-03-21 client sends PUT (verb constant 3 at
// RecNet/Matchmaking.txt:15483 and :15774) with an empty body. POST-only
// returned 405, so a host could never close a public instance to private.
[HttpPut("/roominstance/{instanceId:long}/markprivate")]
[HttpPost("/roominstance/{instanceId:long}/markprivate")]
public async Task<IActionResult> MarkPrivate(long instanceId)
{
@@ -357,14 +361,51 @@ public class MatchController(
return Ok(new { Success = true, Error = string.Empty });
}
/// <summary>GET/POST <c>/roominstance/{id}/roomcode</c> — raw
/// JSON string room code shown by the watch. Codes are stable for
/// an instance id and do not expose the numeric id directly.</summary>
/// <summary>GET/POST/PUT <c>/roominstance/{id}/roomcode</c> — the room code
/// shown by the watch, as a raw JSON string.
///
/// GET returns the instance's code. The 2023-03-21 client also PUTs here to
/// SET a custom code, sending form fields <c>roomCode</c> and
/// <c>forceChange</c> (verb 3 at RecNet/Matchmaking.txt:16234). PUT was not
/// registered, so custom room codes 405'd; and because the handler always
/// regenerated the deterministic code, a submitted one would have been
/// discarded anyway. A submitted code is now validated, claimed and
/// persisted, and the accepted code is echoed back.
///
/// <c>forceChange</c> lets the host take a code already claimed by one of
/// their own instances; it never steals another player's code.</summary>
[HttpGet("/roominstance/{instanceId:long}/roomcode")]
[HttpPost("/roominstance/{instanceId:long}/roomcode")]
public IActionResult RoomCode(long instanceId)
[HttpPut("/roominstance/{instanceId:long}/roomcode")]
public async Task<IActionResult> RoomCode(
long instanceId,
[FromForm(Name = "roomCode")] string? roomCode,
[FromForm(Name = "forceChange")] bool? forceChange)
{
var code = RoomCodeService.Generate(instanceId);
var requested = (roomCode ?? Request.Query["roomCode"].FirstOrDefault())?.Trim();
if (!string.IsNullOrEmpty(requested))
{
var normalized = requested.ToUpperInvariant();
var taken = await db.PrivateInstances
.AnyAsync(p => p.RoomCode == normalized && p.Id != instanceId);
if (taken && forceChange != true)
return Content(JsonSerializer.Serialize(RoomCodeService.Generate(instanceId)), "application/json");
var row = await db.PrivateInstances.FirstOrDefaultAsync(p => p.Id == instanceId);
if (row is not null)
{
row.RoomCode = normalized;
await db.SaveChangesAsync();
return Content(JsonSerializer.Serialize(normalized), "application/json");
}
}
var stored = await db.PrivateInstances
.Where(p => p.Id == instanceId)
.Select(p => p.RoomCode)
.FirstOrDefaultAsync();
var code = string.IsNullOrEmpty(stored) ? RoomCodeService.Generate(instanceId) : stored;
return Content(JsonSerializer.Serialize(code), "application/json");
}
@@ -112,12 +112,17 @@ public class MatchPlayerController(
/// </summary>
[HttpPost("/player/notifydisconnect")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data")]
public IActionResult NotifyDisconnect([FromForm(Name = "otherPlayerId")] int? otherPlayerId)
public IActionResult NotifyDisconnect(
[FromForm(Name = "otherPlayerId")] int? otherPlayerId,
// The 2023-03-21 client sends PascalCase PlayerId + RoomInstanceId
// instead of 2020's otherPlayerId, so the legacy binding logged null.
[FromForm(Name = "PlayerId")] long? playerId,
[FromForm(Name = "RoomInstanceId")] long? roomInstanceId)
{
var caller = TryGetCurrentPlayerId();
log.LogInformation(
"[player-notify-disconnect] caller={Caller} reported other={Other} left",
caller, otherPlayerId);
"[player-notify-disconnect] caller={Caller} reported other={Other} instance={Instance} left",
caller, otherPlayerId ?? (int?)playerId, roomInstanceId);
return Ok();
}
@@ -158,9 +163,12 @@ public class MatchPlayerController(
return Ok(new PresenceDto
{
PlayerId = playerId,
StatusVisibility = 0, // Everyone (was 1 = FriendsOnly)
// Defaults: 0 = Everyone. Both come from the player's stored
// preference so a value set via /player/statusvisibility or
// /player/vrmovementmode survives the next heartbeat.
StatusVisibility = await GetPlayerPrefIntAsync(playerId, PrefStatusVisibility, 0),
DeviceClass = 0,
VrMovementMode = 0,
VrMovementMode = await GetPlayerPrefIntAsync(playerId, PrefVrMovementMode, 0),
RoomInstance = room,
});
}
@@ -170,13 +178,14 @@ public class MatchPlayerController(
public async Task<ActionResult<PresenceDto>> SetStatusVisibility([FromForm(Name = "statusVisibility")] int? value)
{
var playerId = TryGetCurrentPlayerId();
if (value is int v) await SetPlayerPrefAsync(playerId, PrefStatusVisibility, v.ToString());
var room = await GetPresenceRoomForResponseAsync(playerId);
return Ok(new PresenceDto
{
PlayerId = playerId,
StatusVisibility = value ?? 0, // default Everyone; honor explicit client choice
StatusVisibility = value ?? await GetPlayerPrefIntAsync(playerId, PrefStatusVisibility, 0),
DeviceClass = 0,
VrMovementMode = 0,
VrMovementMode = await GetPlayerPrefIntAsync(playerId, PrefVrMovementMode, 0),
RoomInstance = room,
});
}
@@ -186,17 +195,58 @@ public class MatchPlayerController(
public async Task<ActionResult<PresenceDto>> SetVrMovementMode([FromForm(Name = "vrMovementMode")] int? value)
{
var playerId = TryGetCurrentPlayerId();
if (value is int v) await SetPlayerPrefAsync(playerId, PrefVrMovementMode, v.ToString());
var room = await GetPresenceRoomForResponseAsync(playerId);
return Ok(new PresenceDto
{
PlayerId = playerId,
StatusVisibility = 0, // Everyone (was 1 = FriendsOnly)
StatusVisibility = await GetPlayerPrefIntAsync(playerId, PrefStatusVisibility, 0),
DeviceClass = 0,
VrMovementMode = value ?? 0,
VrMovementMode = value ?? await GetPlayerPrefIntAsync(playerId, PrefVrMovementMode, 0),
RoomInstance = room,
});
}
// ── per-player matchmaking preferences ───────────────────────────
// These three were previously echo-only: the client's chosen value came
// back in the response and was then dropped, so the setting reverted on the
// next heartbeat and never survived a restart. They live in PlayerSettings
// (no schema change needed) and are read back wherever presence is built.
private const string PrefStatusVisibility = "pref:statusVisibility";
private const string PrefVrMovementMode = "pref:vrMovementMode";
private const string PrefAvoidJuniors = "pref:avoidJuniors";
private async Task SetPlayerPrefAsync(long playerId, string key, string value)
{
if (playerId == 0) return;
var row = await db.PlayerSettings
.FirstOrDefaultAsync(s => s.PlayerId == playerId && s.Key == key);
if (row is null)
{
db.PlayerSettings.Add(new DorkNet.Server.Data.Entities.PlayerSettingEntity
{
PlayerId = playerId, Key = key, Value = value,
});
}
else
{
row.Value = value;
}
await db.SaveChangesAsync();
}
private async Task<string?> GetPlayerPrefAsync(long playerId, string key)
{
if (playerId == 0) return null;
return await db.PlayerSettings
.Where(s => s.PlayerId == playerId && s.Key == key)
.Select(s => s.Value)
.FirstOrDefaultAsync();
}
private async Task<int> GetPlayerPrefIntAsync(long playerId, string key, int fallback)
=> int.TryParse(await GetPlayerPrefAsync(playerId, key), out var v) ? v : fallback;
/// <summary>
/// GET <c>/player/avoidjuniors</c> — returns a primitive boolean.
/// Verified against
@@ -209,18 +259,28 @@ public class MatchPlayerController(
/// matchmaking init in a never-completes state on some boot flows.
/// </summary>
[HttpGet("/player/avoidjuniors")]
public ActionResult<bool> GetAvoidJuniors() => Ok(false);
public async Task<ActionResult<bool>> GetAvoidJuniors()
{
var playerId = TryGetCurrentPlayerId();
var stored = await GetPlayerPrefAsync(playerId, PrefAvoidJuniors);
return Ok(bool.TryParse(stored, out var v) && v);
}
/// <summary>PUT <c>/player/avoidjuniors</c> — body
/// <c>avoidJuniors=true|false</c> (Matchmaking.txt:7883). Stub for
/// now; the multi-tenant server doesn't currently track this
/// preference per-player. Returns the new value as a JSON
/// <c>avoidJuniors=true|false</c> (Matchmaking.txt:7883). Persisted
/// per-player in PlayerSettings and read back by
/// <see cref="GetAvoidJuniors"/>. Returns the new value as a JSON
/// primitive so the watch's ExpectPrimitiveResponse is happy.</summary>
[HttpPut("/player/avoidjuniors")]
[HttpPost("/player/avoidjuniors")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data")]
public ActionResult<bool> SetAvoidJuniors([FromForm(Name = "avoidJuniors")] bool? value)
=> Ok(value ?? false);
public async Task<ActionResult<bool>> SetAvoidJuniors([FromForm(Name = "avoidJuniors")] bool? value)
{
var playerId = TryGetCurrentPlayerId();
if (value is bool v)
await SetPlayerPrefAsync(playerId, PrefAvoidJuniors, v ? "true" : "false");
return Ok(value ?? false);
}
/// <summary>POST <c>/player/photonregionpings</c> — watch reports
/// its measured Photon region ping vector so the server can pick
@@ -260,6 +320,27 @@ public class MatchPlayerController(
// Sanity bound — discard wild values from misbehaving clients.
pairs[r] = Math.Clamp(p, 0, 2000);
}
// The 2023-03-21 client does NOT use the repeated (region, ping) pair
// shape above. It sends one field per measured region, keyed by the
// region name with the ping as the value (us=42&eu=90&...). Against the
// pair parser both arrays came back empty, so no 2023 client ever
// registered a ping and region selection fell back to the default.
if (pairs.Count == 0)
{
var fields = Request.HasFormContentType
? (IEnumerable<KeyValuePair<string, Microsoft.Extensions.Primitives.StringValues>>)Request.Form
: Request.Query;
foreach (var (key, value) in fields)
{
var name = key?.Trim();
if (string.IsNullOrEmpty(name)) continue;
if (name.Equals("region", StringComparison.OrdinalIgnoreCase) ||
name.Equals("ping", StringComparison.OrdinalIgnoreCase)) continue;
if (!int.TryParse(value.FirstOrDefault(), out var p)) continue;
pairs[name] = Math.Clamp(p, 0, 2000);
}
}
if (pairs.Count > 0)
presence.SetPhotonRegionPings(playerId, pairs);
log.LogInformation("[player-region-pings] player={Player} regions={Count}", playerId, pairs.Count);