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
@@ -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; }