diff --git a/DorkNet.Server/Controllers/API/Avatar/V2/AvatarGiftsController.cs b/DorkNet.Server/Controllers/API/Avatar/V2/AvatarGiftsController.cs
index ebba4d5..28df979 100644
--- a/DorkNet.Server/Controllers/API/Avatar/V2/AvatarGiftsController.cs
+++ b/DorkNet.Server/Controllers/API/Avatar/V2/AvatarGiftsController.cs
@@ -59,7 +59,11 @@ public class AvatarGiftsController(
/// that room has no pool — or for non-game free-gift tiles — it falls
/// back to a random wardrobe item. The rolled item is stamped as a
/// gift package; opening the box (gifts/consume) grants it.
+ // The 2023 client posts the gift-chest generate to v3 (GET gifts is
+ // already fanned across v2/v3/v4, but generate only routed v2 — the v3
+ // POST 404'd, breaking reward chests on that path). Same request body.
[HttpPost("api/avatar/v2/gifts/generate")]
+ [HttpPost("api/avatar/v3/gifts/generate")]
[Consumes("application/json", "application/x-www-form-urlencoded", "multipart/form-data")]
public async Task Generate([FromBody] GenerateGiftRequest? req)
{
diff --git a/DorkNet.Server/Controllers/API/Comments/CommentsController.cs b/DorkNet.Server/Controllers/API/Comments/CommentsController.cs
new file mode 100644
index 0000000..ad355e2
--- /dev/null
+++ b/DorkNet.Server/Controllers/API/Comments/CommentsController.cs
@@ -0,0 +1,77 @@
+using System.Text.Json;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+using DorkNet.Server.Auth;
+
+namespace DorkNet.Server.Controllers.API.Comments;
+
+///
+/// Room-comment unread counts. Client contract (RecNet.Runtime
+/// DJLNMANKMAA(IReadOnlyCollection<long>)): the caller sends a
+/// collection of roomIds and expects a top-level JSON OBJECT
+/// Dictionary<long, uint> 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).
+///
+[ApiController]
+[Authorize]
+public class CommentsController : ControllerBase
+{
+ [HttpPost("/comments/unreadcounts")]
+ [HttpGet("/comments/unreadcounts")]
+ [Consumes("application/json", "application/x-www-form-urlencoded", "multipart/form-data")]
+ public async Task UnreadCounts()
+ {
+ var roomIds = await ReadRoomIdsAsync();
+ // Keys must be strings in JSON; values are the (currently always 0)
+ // unread counts.
+ var result = new Dictionary();
+ foreach (var id in roomIds) result[id.ToString()] = 0;
+ return Ok(result);
+ }
+
+ private async Task> ReadRoomIdsAsync()
+ {
+ var ids = new HashSet();
+
+ void AddCsv(string? s)
+ {
+ if (string.IsNullOrWhiteSpace(s)) return;
+ foreach (var part in s.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
+ if (long.TryParse(part, out var v) && v > 0) ids.Add(v);
+ }
+
+ // Query / form: roomIds=1,2,3
+ foreach (var k in new[] { "roomIds", "roomId", "ids", "id" })
+ {
+ AddCsv(Request.Query[k].ToString());
+ if (Request.HasFormContentType) AddCsv(Request.Form[k].ToString());
+ }
+
+ // JSON body: either a bare array [1,2,3] or { "roomIds": [1,2,3] }.
+ if (!Request.HasFormContentType)
+ {
+ try
+ {
+ Request.EnableBuffering();
+ Request.Body.Position = 0;
+ using var doc = await JsonDocument.ParseAsync(Request.Body);
+ var root = doc.RootElement;
+ JsonElement arr = default;
+ var haveArr = false;
+ if (root.ValueKind == JsonValueKind.Array) { arr = root; haveArr = true; }
+ else if (root.ValueKind == JsonValueKind.Object)
+ {
+ foreach (var k in new[] { "roomIds", "RoomIds", "ids", "Ids" })
+ if (root.TryGetProperty(k, out arr) && arr.ValueKind == JsonValueKind.Array) { haveArr = true; break; }
+ }
+ if (haveArr)
+ foreach (var el in arr.EnumerateArray())
+ if (el.ValueKind == JsonValueKind.Number && el.TryGetInt64(out var v) && v > 0) ids.Add(v);
+ }
+ catch { /* non-JSON / empty body */ }
+ }
+ return ids;
+ }
+}
diff --git a/DorkNet.Server/Controllers/API/Compatibility/CompatibilityFeatureController.cs b/DorkNet.Server/Controllers/API/Compatibility/CompatibilityFeatureController.cs
index eac001d..dc7702a 100644
--- a/DorkNet.Server/Controllers/API/Compatibility/CompatibilityFeatureController.cs
+++ b/DorkNet.Server/Controllers/API/Compatibility/CompatibilityFeatureController.cs
@@ -108,7 +108,9 @@ public class CompatibilityFeatureController(DorkNetDbContext db) : ControllerBas
: null;
if (club is null) return Ok(new { Success = false, Message = "club_not_found" });
- var message = $"Club report: {req.Message ?? string.Empty}".Trim();
+ // 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();
if (message.Length > 1000) message = message[..1000];
db.Reports.Add(new ReportEntity
{
diff --git a/DorkNet.Server/Controllers/API/Config/V1/ConfigController.cs b/DorkNet.Server/Controllers/API/Config/V1/ConfigController.cs
index 1aab71d..52222c5 100644
--- a/DorkNet.Server/Controllers/API/Config/V1/ConfigController.cs
+++ b/DorkNet.Server/Controllers/API/Config/V1/ConfigController.cs
@@ -117,6 +117,27 @@ public class ConfigController(ConfigService configService) : ControllerBase
});
}
+ /// GET /voice/requiresModeration — bare Boolean the
+ /// client reads to decide whether voice needs moderation. ToxMod is
+ /// disabled server-side, so no moderation is required.
+ [HttpGet("/voice/requiresModeration")]
+ [Microsoft.AspNetCore.Authorization.AllowAnonymous]
+ public IActionResult VoiceRequiresModeration() => Content("false", "application/json");
+
+ /// GET /emojiConfig/whitelistedEmojis — the allow-list of
+ /// emoji permitted in text chat (RRUI.EmojiPicker, IReadOnlyList<string>).
+ /// Returns a broad default set so the picker is populated.
+ [HttpGet("/emojiConfig/whitelistedEmojis")]
+ [Microsoft.AspNetCore.Authorization.AllowAnonymous]
+ public IActionResult WhitelistedEmojis() => Ok(new[]
+ {
+ "😀", "😁", "😂", "🤣", "😊", "😇", "🙂", "😉", "😍", "😘",
+ "😜", "🤪", "😎", "🤩", "🥳", "😏", "😢", "😭", "😡", "🤯",
+ "😱", "😴", "🤔", "🤗", "🙄", "😬", "👍", "👎", "👏", "🙌",
+ "🙏", "💪", "✌️", "🤞", "👋", "🤝", "❤️", "🔥", "⭐", "✨",
+ "🎉", "🎊", "💯", "😈", "👻", "🤖", "🎮", "🏆",
+ });
+
/// GET api/config/v1/freegiftbutton — the
/// 2020.12 client deserializes this endpoint as a raw Boolean.
/// This server does not run the timed free-gift campaign, so the
diff --git a/DorkNet.Server/Controllers/API/Images/ImagesController.cs b/DorkNet.Server/Controllers/API/Images/ImagesController.cs
index ba0fb3f..5697a21 100644
--- a/DorkNet.Server/Controllers/API/Images/ImagesController.cs
+++ b/DorkNet.Server/Controllers/API/Images/ImagesController.cs
@@ -303,17 +303,23 @@ public class ImagesController(
return Ok(rows.Select(p => BuildImageInfo(p, p.UploaderPlayerId)));
}
+ /// GET api/images/v6?name={blobName} — single-image
+ /// lookup by name. Client contract (RecNet.Runtime
+ /// KLJOGJHBONK.INHMKKAJJKO(string)) returns ONE
+ /// ICOFKEGOGOD ImageDTO, not a list; a bare array crashes its
+ /// reader. Not found (or private + not the owner) → 404 so the promise
+ /// rejects cleanly rather than delivering a wrong-shaped body.
[HttpGet("api/images/v6")]
[AllowAnonymous]
- public async Task ImagesV6([FromQuery] int take = 50)
+ public async Task ImagesV6([FromQuery] string? name)
{
- take = Math.Clamp(take, 1, 100);
- var rows = await db.Photos
- .Where(p => p.IsPublic && p.DeletedAt == null)
- .OrderByDescending(p => p.CreatedAt)
- .Take(take)
- .ToListAsync();
- return Ok(rows.Select(p => BuildImageInfo(p, p.UploaderPlayerId)));
+ if (string.IsNullOrWhiteSpace(name)) return NotFound();
+ var pid = this.CurrentPlayerId();
+ var photo = await db.Photos.FirstOrDefaultAsync(p =>
+ p.BlobName == name && p.DeletedAt == null &&
+ (p.IsPublic || (pid != null && p.UploaderPlayerId == pid.Value)));
+ if (photo is null) return NotFound();
+ return Ok(BuildImageInfo(photo, photo.UploaderPlayerId));
}
/// GET api/images/v2/named — the watch's
diff --git a/DorkNet.Server/Controllers/API/IncentivizedReferrals/IncentivizedReferralsController.cs b/DorkNet.Server/Controllers/API/IncentivizedReferrals/IncentivizedReferralsController.cs
index 366866a..6ac11b8 100644
--- a/DorkNet.Server/Controllers/API/IncentivizedReferrals/IncentivizedReferralsController.cs
+++ b/DorkNet.Server/Controllers/API/IncentivizedReferrals/IncentivizedReferralsController.cs
@@ -34,17 +34,23 @@ public class IncentivizedReferralsController(DorkNetDbContext db, LevelService l
});
}
+ // Client contract: the claim response deserializes as a top-level
+ // List where each entry is
+ // { rewardSelectionId: long, rewardType: string } (RecNet.Runtime
+ // AFEFBIKADAP/GLOFCEJBIGB). Returning a {Success,...} object crashes the
+ // strict array reader. When there's nothing to grant, return an empty
+ // array — a valid empty list, not an error object.
[HttpPost("claim")]
public async Task Claim()
{
var referralCount = await db.PlayerSettings
.CountAsync(s => s.Key.StartsWith($"referral:credited:{Me}:"));
- if (referralCount < 1)
- return Ok(new { Success = false, Error = "not_enough_referrals" });
- if (await db.PlayerSettings.AnyAsync(s => s.PlayerId == Me && s.Key == "referral:reward_claimed"))
- return Ok(new { Success = false, Error = "already_claimed" });
+ var alreadyClaimed = await db.PlayerSettings
+ .AnyAsync(s => s.PlayerId == Me && s.Key == "referral:reward_claimed");
+ if (referralCount < 1 || alreadyClaimed)
+ return Ok(Array.Empty
[HttpGet("api/rooms/v1/search")]
[HttpGet("api/rooms/v2/search")]
- [HttpGet("rooms/search")]
public async Task Search(
[FromQuery(Name = "query")] string? query,
[FromQuery(Name = "value")] string? value)
=> Ok((await rooms.SearchAsync(query ?? value ?? string.Empty))
.Select(RoomService.ToWireRoom).ToList());
+ /// GET rooms/search — the room-server search route.
+ /// Unlike the api/rooms/v* variants (flat List<Room>),
+ /// this one's client contract is a PAGED wrapper object
+ /// { Results, TotalResults } (IDLBPALJJDJ : PagedResult<Room>);
+ /// returning a bare array crashes the watch's key-based reader.
+ [HttpGet("rooms/search")]
+ public async Task SearchRoomServer(
+ [FromQuery(Name = "query")] string? query,
+ [FromQuery(Name = "value")] string? value,
+ [FromQuery] int skip = 0,
+ [FromQuery] int take = 100)
+ {
+ var all = (await rooms.SearchAsync(query ?? value ?? string.Empty)).ToList();
+ var page = all
+ .Skip(Math.Max(skip, 0))
+ .Take(Math.Clamp(take, 1, 100))
+ .Select(RoomService.ToWireRoom)
+ .ToList();
+ return Ok(new
+ {
+ Results = page,
+ TotalResults = all.Count,
+ });
+ }
+
/// GET api/rooms/v2/live — currently-active rooms
/// (rooms with at least one player presence). Same shape as Hot
/// — order by HotScore as a proxy for "live activity" since we
diff --git a/DorkNet.Server/Controllers/Accounts/AccountsController.cs b/DorkNet.Server/Controllers/Accounts/AccountsController.cs
index 6ce13ea..9baa336 100644
--- a/DorkNet.Server/Controllers/Accounts/AccountsController.cs
+++ b/DorkNet.Server/Controllers/Accounts/AccountsController.cs
@@ -301,14 +301,31 @@ public class AccountsController(
return Ok(accounts);
}
- // Accounts.GetNameGenerationOptions → NameGenDTO {Nouns, Adjectives}
+ // Accounts.GetNameGenerationOptions → NameGenDTO {Nouns, Adjectives}
+ // (two List). The 2023 client also calls the newer root path
+ // `namegen/options` (RecNet.Runtime EAHBKJEMHEM, two List);
+ // route it here too. Keys are duplicated in lower-case as a safety net
+ // because the 2023 DTO's obfuscated members give no literal key names —
+ // LitJson ignores keys it doesn't recognise, so extras are harmless.
[HttpGet("/account/v1/namegen")]
[HttpGet("/account/v1/namegeneration")]
- public IActionResult NameGen() => Ok(new
+ [HttpGet("/namegen/options")]
+ public IActionResult NameGen()
{
- Nouns = new[] { "Player", "Hero", "Star", "Fox", "Wolf" },
- Adjectives = new[] { "Brave", "Swift", "Clever", "Bold", "Bright" },
- });
+ var nouns = new[]
+ {
+ "Player", "Hero", "Star", "Fox", "Wolf", "Comet", "Tiger", "Falcon",
+ "Panda", "Dragon", "Ninja", "Robot", "Ghost", "Rocket", "Shark", "Raven",
+ };
+ var adjectives = new[]
+ {
+ "Brave", "Swift", "Clever", "Bold", "Bright", "Cosmic", "Silent", "Mighty",
+ "Turbo", "Lucky", "Sneaky", "Epic", "Fuzzy", "Wild", "Golden", "Neon",
+ };
+ // Anonymous objects serialize with the server's camelCase policy, so
+ // these become {"nouns":[…],"adjectives":[…]} on the wire.
+ return Ok(new { Nouns = nouns, Adjectives = adjectives });
+ }
// Phone-confirm is the one mutation we don't persist — there's no
// SMS provider in the loop. Acknowledge silently.
@@ -670,27 +687,102 @@ public class AccountsController(
[HttpGet("/accountprivacysettings/{accountId:long}")]
[HttpGet("/account/v1/privacysettings/{accountId:long}")]
[HttpGet("/account/{accountId:long}/privacysettings")]
- public IActionResult GetAccountPrivacySettings(long accountId) => Ok(new
+ public async Task GetAccountPrivacySettings(long accountId)
{
- AccountId = (int)accountId,
- IsProfileVisible = true,
- IsOnlineStatusVisible = true,
- IsActivityVisible = true,
- ShowOnlineStatus = true,
- ShowActivity = true,
- ShowCurrentRoom = true,
- ReceiveFriendRequests = true,
- ReceiveInvites = true,
- ReceiveMessages = true,
- WhoCanSeeOnlineStatus = 0,
- WhoCanSeeActivity = 0,
- WhoCanSeeCurrentRoom = 0,
- WhoCanSendFriendRequests = 0,
- WhoCanInviteMe = 0,
- WhoCanMessageMe = 0,
- VoicePrivacy = 0,
- TextChatPrivacy = 0,
- });
+ // Recent-history visibility is the one privacy bit the watch can
+ // actually toggle (setter below); read it back so the client's
+ // GetRecentHistoryVisibility reader sees the persisted value.
+ var recentHistoryVisible = await GetRecentHistoryVisibleAsync(accountId);
+ return Ok(new
+ {
+ AccountId = (int)accountId,
+ IsProfileVisible = true,
+ IsOnlineStatusVisible = true,
+ IsActivityVisible = true,
+ ShowOnlineStatus = true,
+ ShowActivity = true,
+ ShowCurrentRoom = true,
+ ReceiveFriendRequests = true,
+ ReceiveInvites = true,
+ ReceiveMessages = true,
+ IsRecentHistoryVisible = recentHistoryVisible,
+ WhoCanSeeOnlineStatus = 0,
+ WhoCanSeeActivity = 0,
+ WhoCanSeeCurrentRoom = 0,
+ WhoCanSendFriendRequests = 0,
+ WhoCanInviteMe = 0,
+ WhoCanMessageMe = 0,
+ VoicePrivacy = 0,
+ TextChatPrivacy = 0,
+ });
+ }
+
+ private const string RecentHistoryVisibleKey = "privacy:recenthistoryvisible";
+
+ private async Task GetRecentHistoryVisibleAsync(long accountId)
+ {
+ var row = await db.PlayerSettings
+ .FirstOrDefaultAsync(s => s.PlayerId == accountId && s.Key == RecentHistoryVisibleKey);
+ // Default: visible (true) when never toggled.
+ return row is null || !string.Equals(row.Value, "false", StringComparison.OrdinalIgnoreCase);
+ }
+
+ /// POST/PUT /accountprivacysettings/recenthistoryvisibility
+ /// — the watch's ChangeRecentHistoryVisibility(bool). Body carries
+ /// isRecentHistoryVisible (accepts the PascalCase variant and a
+ /// raw bool body too). The client's return type is the fire-and-forget
+ /// response handle (body ignored), but we echo the persisted object so a
+ /// GET-style consumer is also satisfied. The existing
+ /// /accountprivacysettings/{accountId:long} route can never match
+ /// this literal segment (the :long constraint rejects it), so
+ /// without this handler the toggle 404'd ("Failed to modify …").
+ [HttpPost("/accountprivacysettings/recenthistoryvisibility")]
+ [HttpPut("/accountprivacysettings/recenthistoryvisibility")]
+ [Microsoft.AspNetCore.Authorization.Authorize]
+ [Consumes("application/json", "application/x-www-form-urlencoded", "multipart/form-data")]
+ public async Task SetRecentHistoryVisibility()
+ {
+ var me = this.RequireCurrentPlayerId();
+ var visible = await ReadRecentHistoryVisibleBodyAsync();
+ await SetPlayerSettingAsync(me, RecentHistoryVisibleKey, visible ? "true" : "false");
+ return Ok(new
+ {
+ AccountId = (int)me,
+ IsRecentHistoryVisible = visible,
+ });
+ }
+
+ private async Task ReadRecentHistoryVisibleBodyAsync()
+ {
+ // Form body (isRecentHistoryVisible / IsRecentHistoryVisible).
+ if (Request.HasFormContentType)
+ {
+ foreach (var k in new[] { "isRecentHistoryVisible", "IsRecentHistoryVisible" })
+ if (bool.TryParse(Request.Form[k], out var fv)) return fv;
+ }
+ // JSON body: { "isRecentHistoryVisible": true } or a bare `true`.
+ try
+ {
+ Request.EnableBuffering();
+ Request.Body.Position = 0;
+ using var doc = await System.Text.Json.JsonDocument.ParseAsync(Request.Body);
+ var root = doc.RootElement;
+ if (root.ValueKind == System.Text.Json.JsonValueKind.True) return true;
+ if (root.ValueKind == System.Text.Json.JsonValueKind.False) return false;
+ if (root.ValueKind == System.Text.Json.JsonValueKind.Object)
+ {
+ foreach (var k in new[] { "isRecentHistoryVisible", "IsRecentHistoryVisible" })
+ if (root.TryGetProperty(k, out var v) &&
+ (v.ValueKind == System.Text.Json.JsonValueKind.True ||
+ v.ValueKind == System.Text.Json.JsonValueKind.False))
+ return v.GetBoolean();
+ }
+ }
+ catch { /* non-JSON / empty body */ }
+ // Query fallback.
+ if (bool.TryParse(Request.Query["isRecentHistoryVisible"], out var qv)) return qv;
+ return true;
+ }
// Previously had a /{*path} catch-all that returned [] / {}. Removed —
// unknown accounts URLs now 404 so we notice them in logs and wire
diff --git a/DorkNet.Server/Controllers/Clubs/ClubsController.cs b/DorkNet.Server/Controllers/Clubs/ClubsController.cs
index e9da4d4..6b2e305 100644
--- a/DorkNet.Server/Controllers/Clubs/ClubsController.cs
+++ b/DorkNet.Server/Controllers/Clubs/ClubsController.cs
@@ -105,6 +105,19 @@ public class ClubsController(ClubService clubs) : ControllerBase
});
}
+ /// GET /club/mostactivetoday — the Clubs discover tab's
+ /// "most active today" carousel. Client contract is a bare
+ /// IReadOnlyList<Club> (not the search envelope), so this
+ /// returns a flat array of the canonical shape,
+ /// ordered by the club service's default activity sort.
+ [HttpGet("/club/mostactivetoday")]
+ public async Task MostActiveToday([FromQuery] int? count)
+ {
+ var result = await clubs.SearchAsync(null, null, sort: 0, count: Math.Clamp(count ?? 30, 1, 100));
+ var counts = await clubs.MemberCountsAsync(result.Clubs.Select(r => r.Id));
+ return Ok(result.Clubs.Select(c => ToWireClub(c, counts.GetValueOrDefault(c.Id))).ToList());
+ }
+
/// GET /club?name=Foo — direct name lookup used by
/// the client when resolving a club by display name. Returns the
/// single PLILLKHMNDA Club wire shape.
@@ -708,27 +721,12 @@ public class ClubsController(ClubService clubs) : ControllerBase
}));
}
- public sealed class ClubReportRequest
- {
- public long? ClubId { get; set; }
- public int? Category { get; set; }
- public string? Details { get; set; }
- }
-
- /// POST /api/clubreporting/v1/report — file a
- /// moderation report against a club. Persists to the same
- /// table
- /// the admin queue reads from; the controller prefixes the message
- /// with "[club {id}]" so admins can tell club reports apart from
- /// player reports without a schema change.
- [HttpPost("/api/clubreporting/v1/report")]
- [Authorize]
- public async Task ClubReport([FromBody] ClubReportRequest req)
- {
- if (req.ClubId is not long cid) return BadRequest(new { error = "missing_club_id" });
- await clubs.AddClubReportAsync(cid, Me, req.Category ?? 0, req.Details ?? string.Empty);
- return Ok();
- }
+ // NOTE: POST /api/clubreporting/v1/report is served by
+ // CompatibilityFeatureController.ClubReport, which persists the same
+ // ReportEntity AND returns the { Success, Message } object the 2023
+ // client requires. A second handler used to live here; having both bind
+ // the identical verb+template made every club report 500 with
+ // AmbiguousMatchException, so this duplicate was removed.
// ── Announcements (per-club feed + per-announcement) ─────────────
diff --git a/DorkNet.Server/Controllers/PlayerSettings/PlayerSettingsController.cs b/DorkNet.Server/Controllers/PlayerSettings/PlayerSettingsController.cs
index 8f08672..2fbddb2 100644
--- a/DorkNet.Server/Controllers/PlayerSettings/PlayerSettingsController.cs
+++ b/DorkNet.Server/Controllers/PlayerSettings/PlayerSettingsController.cs
@@ -43,6 +43,46 @@ public class PlayerSettingsController(DorkNetDbContext db, ILoggerGET /settings/partyinvite — the caller's party-invite
+ /// privacy setting. Client contract (RecNet.Runtime BMGICFICCPN())
+ /// is a BARE Int32 (e.g. 0 = everyone, 1 = friends, 2 = nobody), not an
+ /// object. Default 0 when never set.
+ [HttpGet("/settings/partyinvite")]
+ public async Task GetPartyInvite()
+ {
+ var row = await db.PlayerSettings
+ .FirstOrDefaultAsync(s => s.PlayerId == CurrentPlayerId && s.Key == PartyInviteKey);
+ var value = row is not null && int.TryParse(row.Value, out var v) ? v : 0;
+ return Content(value.ToString(), "application/json");
+ }
+
+ /// POST/PUT /settings/partyinvite — set the party-invite
+ /// privacy value (raw int body, ?value= query, or form field).
+ [HttpPost("/settings/partyinvite")]
+ [HttpPut("/settings/partyinvite")]
+ public async Task SetPartyInvite()
+ {
+ int value = 0;
+ if (Request.HasFormContentType && int.TryParse(Request.Form["value"], out var fv)) value = fv;
+ else if (int.TryParse(Request.Query["value"], out var qv)) value = qv;
+ else
+ {
+ try
+ {
+ Request.EnableBuffering();
+ Request.Body.Position = 0;
+ using var doc = await JsonDocument.ParseAsync(Request.Body);
+ if (doc.RootElement.ValueKind == JsonValueKind.Number &&
+ doc.RootElement.TryGetInt32(out var bv)) value = bv;
+ }
+ catch { /* non-numeric body */ }
+ }
+ await UpsertSettingAsync(CurrentPlayerId, PartyInviteKey, value.ToString());
+ return Content(value.ToString(), "application/json");
+ }
+
[HttpGet("/settings/v1/{accountId:long}")]
public async Task>> Get(long accountId)
{