feat: implement the client routes that had no handler

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

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

New surface worth noting:

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

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

Also excludes DELETE /account/me from the endpoint contract probe. That route is
now a real, correctly-guarded account purge, and the probe walks every route
with one test player — so calling it destroyed the probe's own session and
turned one real event into 49 cascading foreign-key and 401 failures.
This commit is contained in:
Alexa
2026-07-28 09:11:26 +03:00
parent 90e069ee4b
commit 4b5086cdf1
21 changed files with 4474 additions and 370 deletions
@@ -2291,6 +2291,109 @@ public class RoomsController(
public async Task<IActionResult> CreatedByOther(long otherPlayerId)
=> Ok((await rooms.CreatedByAsync(otherPlayerId)).Select(RoomService.ToWireRoom).ToList());
/// <summary>Per-player showcase curation, stored in the existing generic
/// player-settings table as a CSV of room ids. Colon-namespaced to match
/// the other server-owned keys (<c>settings:partyinvite</c>).</summary>
private const string ShowcaseSettingKey = "rooms:showcase";
private const int ShowcaseMaxRooms = 50;
/// <summary>GET <c>showcase/{accountId}</c> — the profile "Showcase Rooms"
/// carousel (<c>RoomListModel.JMGLGEKHHAK.PlayerShowcaseRooms = 7</c>,
/// dump.cs:2042498, driven by
/// <c>AccountModelController.RoomsShowcaseLinkImpl</c> at dump.cs:2113041).
/// Distinct from <c>PlayerCreatedRooms = 5</c>, which is the
/// <c>rooms/createdby/{id}</c> list above.
///
/// <para><b>Verb.</b> <c>NLDBPDCNNCF.EJPNHLIJNPM</c> formats the literal
/// <c>"showcase/{0}"</c> (NLDBPDCNNCF.txt ISIL 021 at :4484) and moves the
/// HTTPMethods ordinal <c>0</c> (= GET) into rdx immediately before the
/// dispatch call (ISIL 031/032 at :4494-4495). No cmov, so GET is the only
/// verb. The path arg is boxed as <c>System.Int32</c> (ISIL 017) — the
/// account id.</para>
///
/// <para><b>Response shape.</b> The issuing method's return type is the
/// contract: <c>Task&lt;List&lt;System.Int64&gt;&gt;</c>
/// (NLDBPDCNNCF.txt:4422) — a BARE JSON array of room ids, not Room
/// objects and not a <c>{Results,TotalResults}</c> container. The Rooms the
/// UI renders are a purely client-side projection: the cache entry
/// <c>&lt;GetRoomsShowcase&gt;b__0</c> is typed
/// <c>Task&lt;IReadOnlyList&lt;Int64&gt;&gt;</c>
/// (IBEOONPEELF_NestedType_HHDHJPIJBJB.txt:14) and the outer
/// <c>IBEOONPEELF.EJPNHLIJNPM</c> resolves those ids through the room cache
/// to <c>FGLDKEJLAKB&lt;IReadOnlyList&lt;NEMINAEBALC&gt;&gt;</c>
/// (IBEOONPEELF.txt:8952) before <c>RoomListModel</c> consumes it
/// (RoomListModel.txt ISIL 742 at :5819).</para>
///
/// <para><b>Backing data.</b> <c>showcase/{0}</c> is the only showcase
/// literal in the binary — the 2023 client can read the list but never
/// writes it (curation lived on the website), so the curated order is kept
/// in the existing per-player settings store under
/// <see cref="ShowcaseSettingKey"/> and is writable through
/// <c>PUT /settings/v1/{accountId}</c> with no new schema. Curated ids are
/// re-validated against Rooms on every read so a room that has since been
/// archived or privatised drops off the profile, and the curated order is
/// preserved. A player who never curated one falls back to the rooms they
/// actually created, most prominent first — the showcase the carousel is
/// there to display.</para>
///
/// <para>Unauthenticated on purpose: this is another player's profile
/// surface, and the sibling <c>rooms/createdby/{id}</c> is open too. The
/// owner viewing their own profile additionally sees their non-public
/// picks; everyone else sees public rooms only.</para></summary>
[HttpGet("showcase/{accountId:long}")]
// roomserver/ twin: EJPNHLIJNPM lives on NLDBPDCNNCF, whose requests all
// arrive with the roomserver/ prefix in this deployment
// (docs/recroom-2023-room-save.md:83-93) — same reason every other
// NLDBPDCNNCF route in this file is registered twice.
[HttpGet("roomserver/showcase/{accountId:long}")]
public async Task<IActionResult> RoomsShowcase(long accountId)
{
var viewerIsOwner = CurrentPlayerId == accountId;
var curatedCsv = await db.PlayerSettings.AsNoTracking()
.Where(s => s.PlayerId == accountId && s.Key == ShowcaseSettingKey)
.Select(s => s.Value)
.FirstOrDefaultAsync();
var curatedIds = (curatedCsv ?? string.Empty)
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(v => long.TryParse(v, out var id) ? id : 0L)
.Where(id => id > 0)
.Distinct()
.Take(ShowcaseMaxRooms)
.ToList();
if (curatedIds.Count > 0)
{
var visible = await db.Rooms.AsNoTracking()
.Where(r => curatedIds.Contains(r.Id)
&& r.State == 0
&& !r.IsDormRoom
&& !r.HiddenFromBrowse
&& (r.Accessibility == 1 || viewerIsOwner))
.Select(r => r.Id)
.ToListAsync();
var visibleSet = visible.ToHashSet();
// Curated order is the display order — filter, don't re-sort.
var ordered = curatedIds.Where(visibleSet.Contains).ToList();
if (ordered.Count > 0) return Ok(ordered);
}
var fallback = await db.Rooms.AsNoTracking()
.Where(r => r.CreatorPlayerId == accountId
&& r.State == 0
&& !r.IsDormRoom
&& !r.HiddenFromBrowse
&& (r.Accessibility == 1 || viewerIsOwner))
.OrderByDescending(r => r.HotScore)
.ThenByDescending(r => r.VisitCount)
.ThenByDescending(r => r.UpdatedAt)
.Select(r => r.Id)
.Take(ShowcaseMaxRooms)
.ToListAsync();
return Ok(fallback);
}
[HttpGet("api/rooms/v1/visitedby/me")]
[HttpGet("api/rooms/v2/visitedby/me")]
[HttpGet("api/rooms/v3/visitedby/me")]