mirror of
https://github.com/DorkSquadRR/DorkNet.git
synced 2026-10-25 15:08:47 -07:00
fix: second pass over the 2023-client wire defects, plus a coverage harness
Fourteen agents worked one server file each over the audit's open findings,
re-verifying every claim against the client binary before changing anything.
Route+verb coverage against the recovered client inventory is now 474/522
(90.8%), up from 86.2% when the harness was first wired up.
Adds ClientRouteCoverageTests, which turns "are we compliant?" into a number
instead of an opinion. It reads data/client-routes-2023.tsv — every request the
2023-03-21 client can issue, recovered from the binary — and asserts each one
has a handler under the verb the client actually uses, separating "no handler"
from "handler exists, wrong verb" since those have different fixes. Remaining
gaps live in KnownGaps, which is expected to shrink and never grow.
Also fixes a real blind spot in EndpointContractDiscovery: it only collected
HttpMethodAttribute, so an action carrying a bare [Route] with no verb attribute
was invisible to it. The CDN's unity_assets handler is exactly that shape, which
had been showing up as a phantom gap.
Notable findings from this pass:
- The club permissions envelope key is literally "Type", not "MembershipType"
as the audit's suggested fix guessed. Read off the generated reader, then
cross-checked against the 2020 decompile — the int bitmask we were emitting
was wrong for BOTH client generations, not just 2023.
- Room-consumable purchase responses carry OperationResult as an OBJECT
{ Status, InventoryItem }; the scalar operation enum belongs in
BalanceUpdateResult. The base type OKHNOPLBOFP holds a single BGHBAILNNJJ,
which exposes exactly those two properties. A test asserting the old scalar
shape was corrected rather than the code.
- buyProgressionEventXpBoost blind-inserted an ObjectiveProgress row whose
(PlayerId, Key) is unique, so a second purchase of the same boost threw
"UNIQUE constraint failed" and 500'd AFTER taking the player's currency.
Now an upsert.
Agents were forbidden from faking fixes they could not complete in their own
file, and several correctly refused: club role permissions, club additional
image slots, announcement meta, MinLevel/ClubChatEnabled, currency purchase
offer ordering, and room-key image/currency columns all need new entities or
columns. Those are deliberately left for a single coherent schema change rather
than stubbed out here.
This commit is contained in:
@@ -0,0 +1,110 @@
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace DorkNet.Server.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Asserts that every HTTP request the Rec Room 2023-03-21 client can issue has
|
||||
/// a handler on this server, under the verb the client actually uses.
|
||||
///
|
||||
/// Rec Room is shut down, so the client can't be observed against a live server.
|
||||
/// <c>data/client-routes-2023.tsv</c> is the recovered inventory of what it
|
||||
/// asks for — see <c>docs/recroom-2023-client-api-complete.md</c> for how it was
|
||||
/// extracted. This test is the regression guard on that inventory: adding a
|
||||
/// client route without a handler, or moving a handler to a different verb,
|
||||
/// fails here rather than silently 404/405-ing a real player.
|
||||
///
|
||||
/// Routes not yet implemented live in <see cref="KnownGaps"/>. That list is
|
||||
/// expected to shrink and never grow. Deleting an entry as you implement it is
|
||||
/// the point — it is the project's running score against the client.
|
||||
/// </summary>
|
||||
public sealed class ClientRouteCoverageTests
|
||||
{
|
||||
/// <summary>Client routes with no handler yet. Each entry is
|
||||
/// <c>VERB route</c> exactly as it appears in the inventory file.</summary>
|
||||
private static readonly HashSet<string> KnownGaps = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
// (populated from the current gap report; remove as they are implemented)
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void Every_client_route_has_a_handler()
|
||||
{
|
||||
var client = LoadClientRoutes();
|
||||
Assert.NotEmpty(client);
|
||||
|
||||
var discovered = EndpointContractDiscovery.Discover()
|
||||
.Select(r => (Verb: r.Method.ToUpperInvariant(), Pattern: Normalise(r.Template)))
|
||||
.ToList();
|
||||
|
||||
var server = discovered.ToHashSet();
|
||||
|
||||
// A bare [Route] with no verb attribute serves every verb.
|
||||
var verbless = discovered
|
||||
.Where(d => d.Verb == "ANY")
|
||||
.Select(d => d.Pattern)
|
||||
.ToHashSet(StringComparer.Ordinal);
|
||||
|
||||
var serverAnyVerb = discovered.Select(s => s.Pattern).ToHashSet(StringComparer.Ordinal);
|
||||
|
||||
var missing = new List<string>();
|
||||
var wrongVerb = new List<string>();
|
||||
|
||||
foreach (var (verb, route, _) in client)
|
||||
{
|
||||
var pattern = Normalise(route);
|
||||
if (server.Contains((verb, pattern)) || verbless.Contains(pattern)) continue;
|
||||
|
||||
// Distinguish "no handler at all" from "handler exists, wrong verb" —
|
||||
// they have different causes and different fixes.
|
||||
if (serverAnyVerb.Contains(pattern)) wrongVerb.Add($"{verb} {route}");
|
||||
else missing.Add($"{verb} {route}");
|
||||
}
|
||||
|
||||
var gaps = missing.Concat(wrongVerb)
|
||||
.Where(g => !KnownGaps.Contains(g))
|
||||
.OrderBy(g => g, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
var covered = client.Count - missing.Count - wrongVerb.Count;
|
||||
var pct = 100.0 * covered / client.Count;
|
||||
|
||||
Assert.True(gaps.Count == 0,
|
||||
$"""
|
||||
Client route coverage: {covered}/{client.Count} ({pct:F1}%).
|
||||
{missing.Count} missing, {wrongVerb.Count} wrong-verb, {KnownGaps.Count} known gaps.
|
||||
|
||||
Unlisted gaps ({gaps.Count}) — implement them, or add to KnownGaps with a reason:
|
||||
{string.Join(Environment.NewLine, gaps)}
|
||||
""");
|
||||
}
|
||||
|
||||
/// <summary>Collapse a route to a comparable shape: no host, no leading or
|
||||
/// trailing slash, no query, and every parameter — the client's <c>{0}</c>
|
||||
/// and the server's <c>{id:long}</c> alike — reduced to <c>*</c>.</summary>
|
||||
private static string Normalise(string route)
|
||||
{
|
||||
var r = route.Trim();
|
||||
var q = r.IndexOf('?');
|
||||
if (q >= 0) r = r[..q];
|
||||
r = r.Trim('/', '~');
|
||||
r = Regex.Replace(r, @"\{[^}]*\}", "*");
|
||||
return r.ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static List<(string Verb, string Route, string Subsystem)> LoadClientRoutes()
|
||||
{
|
||||
var path = Path.Combine(AppContext.BaseDirectory, "data", "client-routes-2023.tsv");
|
||||
Assert.True(File.Exists(path), $"missing route inventory at {path}");
|
||||
|
||||
var rows = new List<(string, string, string)>();
|
||||
foreach (var line in File.ReadLines(path))
|
||||
{
|
||||
if (line.Length == 0 || line[0] == '#') continue;
|
||||
var parts = line.Split('\t');
|
||||
if (parts.Length < 2) continue;
|
||||
rows.Add((parts[0].Trim().ToUpperInvariant(), parts[1].Trim(),
|
||||
parts.Length > 2 ? parts[2].Trim() : string.Empty));
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
}
|
||||
@@ -24,4 +24,9 @@
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- Recovered client route inventory read by ClientRouteCoverageTests. -->
|
||||
<Content Include="data\**" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -36,7 +36,20 @@ public static partial class EndpointContractDiscovery
|
||||
{
|
||||
var httpAttrs = method.GetCustomAttributes(inherit: true)
|
||||
.OfType<HttpMethodAttribute>()
|
||||
.Cast<IRouteTemplateProvider>()
|
||||
.ToArray();
|
||||
|
||||
// An action can carry a bare [Route("...")] with no [HttpGet]/[HttpPost],
|
||||
// which routes ALL verbs — the CDN's unity_assets handler is the live
|
||||
// example. Skipping those made the discovered table under-report the
|
||||
// server, so route-coverage checks saw phantom gaps.
|
||||
if (httpAttrs.Length == 0)
|
||||
{
|
||||
httpAttrs = method.GetCustomAttributes(inherit: true)
|
||||
.OfType<IRouteTemplateProvider>()
|
||||
.Where(a => !string.IsNullOrWhiteSpace(a.Template))
|
||||
.ToArray();
|
||||
}
|
||||
if (httpAttrs.Length == 0)
|
||||
{
|
||||
continue;
|
||||
@@ -44,8 +57,8 @@ public static partial class EndpointContractDiscovery
|
||||
|
||||
foreach (var attr in httpAttrs)
|
||||
{
|
||||
var attrMethods = attr.HttpMethods.Any()
|
||||
? attr.HttpMethods
|
||||
var attrMethods = attr is HttpMethodAttribute { HttpMethods: var verbs } && verbs.Any()
|
||||
? verbs
|
||||
: new[] { "ANY" };
|
||||
|
||||
foreach (var httpMethod in attrMethods)
|
||||
|
||||
@@ -95,7 +95,12 @@ public sealed class RoomConsumablesShopTests : IClassFixture<DorkNetServerFactor
|
||||
"ExpectedPriceAndCurrency": { "Price": 5, "CurrencyId": null }
|
||||
}
|
||||
""");
|
||||
Assert.Equal(6, badPrice.GetProperty("OperationResult").GetInt32()); // RequestedPriceDoesNotMatch
|
||||
// OperationResult is an OBJECT { Status, InventoryItem } — the base type
|
||||
// OKHNOPLBOFP holds a single BGHBAILNNJJ, which itself exposes the status
|
||||
// enum plus the inventory row. The scalar token-operation enum lives in
|
||||
// BalanceUpdateResult (Nullable<CABBDKFODEC>) on MJKFEHPIDME.
|
||||
Assert.Equal(6, badPrice.GetProperty("BalanceUpdateResult").GetInt32()); // RequestedPriceDoesNotMatch
|
||||
Assert.Equal(38, badPrice.GetProperty("OperationResult").GetProperty("Status").GetInt32());
|
||||
var balanceBefore = badPrice.GetProperty("TokenBalanceResponse").GetProperty("Balance").GetInt64();
|
||||
|
||||
// ── Purchase (client stores NewConcurrencyCode locally) ──────────
|
||||
@@ -106,7 +111,7 @@ public sealed class RoomConsumablesShopTests : IClassFixture<DorkNetServerFactor
|
||||
"ExpectedPriceAndCurrency": { "Price": 10, "CurrencyId": null }
|
||||
}
|
||||
""");
|
||||
Assert.Equal(0, purchase.GetProperty("OperationResult").GetInt32());
|
||||
Assert.Equal(0, purchase.GetProperty("OperationResult").GetProperty("Status").GetInt32());
|
||||
var tokenBalance = purchase.GetProperty("TokenBalanceResponse");
|
||||
Assert.Equal(2, tokenBalance.GetProperty("CurrencyType").GetInt32());
|
||||
Assert.Equal(balanceBefore - 10, tokenBalance.GetProperty("Balance").GetInt64());
|
||||
|
||||
@@ -0,0 +1,525 @@
|
||||
# Every HTTP request the Rec Room 2023-03-21 client can issue.
|
||||
# Recovered from the client binary; see docs/recroom-2023-client-api-complete.md.
|
||||
# verb<TAB>route<TAB>subsystem
|
||||
GET account/bulk identity-account
|
||||
POST account/bulk identity-account
|
||||
GET account/isactivecreator/me identity-account
|
||||
DELETE account/me identity-account
|
||||
GET account/me identity-account
|
||||
POST account/me/changepassword identity-account
|
||||
POST account/me/createlogintoken identity-account
|
||||
GET account/me/haspassword identity-account
|
||||
POST account/me/remoteauth identity-account
|
||||
POST account/recoverpassword identity-account
|
||||
GET account/search identity-account
|
||||
GET account/{0}/bio identity-account
|
||||
GET account/{0}/clubs identity-account
|
||||
PUT accountprivacysettings/recenthistoryvisibility identity-account
|
||||
GET accountprivacysettings/{0} identity-account
|
||||
GET accounts/{0}/receives/{1} identity-account
|
||||
POST actionlink config-telemetry
|
||||
GET actionlink/{code} config-telemetry
|
||||
POST actionlink/{code}/consume config-telemetry
|
||||
GET announcements/club/{clubId} config-telemetry
|
||||
POST announcements/club/{clubId} config-telemetry
|
||||
DELETE announcements/club/{clubId}/{announcementId} config-telemetry
|
||||
PUT announcements/club/{clubId}/{announcementId} config-telemetry
|
||||
POST announcements/club/{clubId}/{announcementId}/read config-telemetry
|
||||
GET announcements/mine config-telemetry
|
||||
GET announcements/subscription/mine config-telemetry
|
||||
GET announcements/v2/mine/unread config-telemetry
|
||||
GET announcements/v2/mine/unread?sendAnnouncements=true config-telemetry
|
||||
POST api/AppIntegrity/v1/iospaymentqueuefailed commerce-subscription
|
||||
POST api/AppIntegrity/v1/iosproducts commerce-subscription
|
||||
GET api/CampusCard/PS5RecRoomPlusEnabledForAllPlayers commerce-subscription
|
||||
POST api/CampusCard/v1/UpdateAndGetSubscription commerce-subscription
|
||||
POST api/PlayerCheer/v1/SetSelectedCheer progression-misc
|
||||
POST api/PlayerCheer/v1/create progression-misc
|
||||
POST api/PlayerReporting/v1/deviceId moderation-safety
|
||||
POST api/PlayerReporting/v1/hile moderation-safety
|
||||
POST api/PlayerReporting/v1/instantKick moderation-safety
|
||||
GET api/PlayerReporting/v1/moderationBlockDetails moderation-safety
|
||||
GET api/PlayerReporting/v1/voteToKickReasons moderation-safety
|
||||
POST api/PlayerReporting/v3/create moderation-safety
|
||||
POST api/PlayerReporting/v3/voteToKick moderation-safety
|
||||
GET api/activities/charades/v1/words/{0} progression-misc
|
||||
POST api/ageverification/generateCode moderation-safety
|
||||
GET api/announcement/v1/get config-telemetry
|
||||
GET api/apple/musicpromotion/active commerce-subscription
|
||||
GET api/apple/musicpromotion/code commerce-subscription
|
||||
GET api/avatar/v1/defaultbaseavataritems avatar-inventory
|
||||
GET api/avatar/v1/defaultunlocked avatar-inventory
|
||||
GET api/avatar/v1/lockeditems avatar-inventory
|
||||
GET api/avatar/v2 avatar-inventory
|
||||
GET api/avatar/v2/gifts avatar-inventory
|
||||
POST api/avatar/v2/gifts/generate avatar-inventory
|
||||
POST api/avatar/v2/set avatar-inventory
|
||||
GET api/avatar/v2/{playerId} avatar-inventory
|
||||
POST api/avatar/v3/gifts/generate avatar-inventory
|
||||
GET api/avatar/v3/saved avatar-inventory
|
||||
POST api/avatar/v3/saved/set avatar-inventory
|
||||
GET api/avatar/v4/items avatar-inventory
|
||||
GET api/banappeal/generateCode moderation-safety
|
||||
GET api/catalog/v1/all?onlyAvailableSkus=true avatar-inventory
|
||||
GET api/challenge/v2/getCurrent progression-misc
|
||||
POST api/challenge/v2/updateProgress progression-misc
|
||||
POST api/checklist/v1/complete progression-misc
|
||||
GET api/checklist/v1/current progression-misc
|
||||
POST api/clubreporting/v1/report moderation-safety
|
||||
GET api/communityboard/v2/current progression-misc
|
||||
GET api/config/v1/amplitude config-telemetry
|
||||
GET api/config/v1/azurespeech config-telemetry
|
||||
GET api/config/v1/backtrace config-telemetry
|
||||
GET api/config/v1/cohortnux/{cohortId} config-telemetry
|
||||
GET api/config/v1/freegiftbutton config-telemetry
|
||||
GET api/config/v2 config-telemetry
|
||||
POST api/consumables/v1/consume avatar-inventory
|
||||
GET api/consumables/v1/getTransferable/{playerId} avatar-inventory
|
||||
POST api/consumables/v1/transfer avatar-inventory
|
||||
POST api/consumables/v1/updateActive avatar-inventory
|
||||
GET api/consumables/v2/getUnlocked avatar-inventory
|
||||
POST api/customAvatarItems/v1 avatar-inventory
|
||||
POST api/customAvatarItems/v1/bulk avatar-inventory
|
||||
DELETE api/customAvatarItems/v1/design avatar-inventory
|
||||
GET api/customAvatarItems/v1/design avatar-inventory
|
||||
PUT api/customAvatarItems/v1/design avatar-inventory
|
||||
GET api/customAvatarItems/v1/featured avatar-inventory
|
||||
GET api/customAvatarItems/v1/hot avatar-inventory
|
||||
GET api/customAvatarItems/v1/isCreationAllowedForAccount avatar-inventory
|
||||
GET api/customAvatarItems/v1/isCreationEnabled avatar-inventory
|
||||
GET api/customAvatarItems/v1/isRenderingEnabled avatar-inventory
|
||||
GET api/customAvatarItems/v1/me avatar-inventory
|
||||
GET api/customAvatarItems/v1/minPriceForPublicItem avatar-inventory
|
||||
DELETE api/customAvatarItems/v1/{id} avatar-inventory
|
||||
PUT api/customAvatarItems/v1/{id} avatar-inventory
|
||||
POST api/customAvatarItems/v1/{id}/report avatar-inventory
|
||||
GET api/customAvatarItems/v2/fromCreator/{creatorId} avatar-inventory
|
||||
POST api/equipment/v1/update avatar-inventory
|
||||
GET api/equipment/v2/getUnlocked avatar-inventory
|
||||
POST api/externalfriendinvite/v1/createplatforminvite relationships-messaging
|
||||
POST api/externalfriendinvite/v1/getplatformreferrers relationships-messaging
|
||||
POST api/externalfriendinvite/v1/gettextmessagereferrers relationships-messaging
|
||||
POST api/externalfriendinvite/v1/sendtextmessageinvite relationships-messaging
|
||||
POST api/freegifts/v1/sendmultiple progression-misc
|
||||
GET api/gameconfigs/v1/all config-telemetry
|
||||
GET api/gamerewards/v1/pending progression-misc
|
||||
POST api/gamerewards/v1/request progression-misc
|
||||
POST api/gamerewards/v1/select progression-misc
|
||||
POST api/gamesight/event events-playlists
|
||||
POST api/images/v1/cheer inventions-media
|
||||
POST api/images/v1/deletesaved inventions-media
|
||||
GET api/images/v1/listsaved inventions-media
|
||||
POST api/images/v1/modifyaccessibility inventions-media
|
||||
POST api/images/v1/sendlink inventions-media
|
||||
GET api/images/v1/slideshow inventions-media
|
||||
POST api/images/v1/{id}/report inventions-media
|
||||
GET api/images/v2/named inventions-media
|
||||
GET api/images/v4/room/{roomId}?sort&filter&take&skip inventions-media
|
||||
POST api/images/v4/uploadsaved inventions-media
|
||||
GET api/images/v5/bulk inventions-media
|
||||
GET api/images/v5/cheered/bulk inventions-media
|
||||
GET api/images/v5/player/{playerId}?sort={int} inventions-media
|
||||
GET api/images/v6/{id} inventions-media
|
||||
GET api/images/v6?name={imageName} inventions-media
|
||||
POST api/incentivizedreferrals/claim progression-misc
|
||||
GET api/incentivizedreferrals/progress progression-misc
|
||||
GET api/influencerpartnerprogram/influencer progression-misc
|
||||
GET api/influencerpartnerprogram/influencers progression-misc
|
||||
GET api/influencerpartnerprogram/myinfluencer progression-misc
|
||||
POST api/influencerpartnerprogram/remove progression-misc
|
||||
POST api/influencerpartnerprogram/support progression-misc
|
||||
POST api/inventions/v1/cheer inventions-media
|
||||
GET api/inventions/v1/delete?inventionId={id} inventions-media
|
||||
GET api/inventions/v1/details?inventionId={id} inventions-media
|
||||
GET api/inventions/v1/dormskinsfromids inventions-media
|
||||
GET api/inventions/v1/featured inventions-media
|
||||
GET api/inventions/v1/featureddormskins inventions-media
|
||||
GET api/inventions/v1/fromcreators inventions-media
|
||||
GET api/inventions/v1/fulllineageowner inventions-media
|
||||
GET api/inventions/v1/personaldetails/{id} inventions-media
|
||||
POST api/inventions/v1/report inventions-media
|
||||
GET api/inventions/v1/room?id={roomId} inventions-media
|
||||
POST api/inventions/v1/settags inventions-media
|
||||
GET api/inventions/v1/tagfilters inventions-media
|
||||
GET api/inventions/v1/toptoday inventions-media
|
||||
GET api/inventions/v1/unpublish?inventionId={id} inventions-media
|
||||
GET api/inventions/v1/update inventions-media
|
||||
POST api/inventions/v1/updateprice inventions-media
|
||||
GET api/inventions/v1/version?inventionId={id}&version={n} inventions-media
|
||||
GET api/inventions/v1/versions?inventionId={id} inventions-media
|
||||
GET api/inventions/v1?inventionId={id} inventions-media
|
||||
GET api/inventions/v2/batch inventions-media
|
||||
GET api/inventions/v2/mine inventions-media
|
||||
GET api/inventions/v2/search?value&skip&take inventions-media
|
||||
GET api/inventions/v3/publish?inventionId&permissionLevel&accessibility&price inventions-media
|
||||
POST api/inventions/v4/addversion inventions-media
|
||||
POST api/inventions/v6/save inventions-media
|
||||
POST api/keepsakes events-playlists
|
||||
GET api/keepsakes/categories events-playlists
|
||||
GET api/keepsakes/events/{progressionEventId} events-playlists
|
||||
GET api/keepsakes/globalconfig events-playlists
|
||||
GET api/keepsakes/rooms/{roomId} events-playlists
|
||||
DELETE api/keepsakes/{keepsakeInstanceId:guid} events-playlists
|
||||
POST api/keepsakes/{keepsakeInstanceId:guid}/collect events-playlists
|
||||
GET api/messages/v1/favoriteFriendOnlineStatus relationships-messaging
|
||||
POST api/messages/v1/sendMultiple relationships-messaging
|
||||
GET api/messages/v2/get relationships-messaging
|
||||
POST api/messages/v2/send relationships-messaging
|
||||
POST api/messages/v3/delete relationships-messaging
|
||||
POST api/objectives/v1/cleargroup progression-misc
|
||||
POST api/objectives/v1/completegroup progression-misc
|
||||
GET api/objectives/v1/myprogress progression-misc
|
||||
POST api/objectives/v1/updateobjective progression-misc
|
||||
POST api/offlineinvite/v1/send relationships-messaging
|
||||
GET api/playerevents/v1 events-playlists
|
||||
GET api/playerevents/v1/all events-playlists
|
||||
GET api/playerevents/v1/all/{0} events-playlists
|
||||
POST api/playerevents/v1/broadcast events-playlists
|
||||
POST api/playerevents/v1/bulk events-playlists
|
||||
POST api/playerevents/v1/bulkInvite events-playlists
|
||||
GET api/playerevents/v1/club/{0} events-playlists
|
||||
GET api/playerevents/v1/clubs events-playlists
|
||||
POST api/playerevents/v1/deleteResponse events-playlists
|
||||
POST api/playerevents/v1/report events-playlists
|
||||
POST api/playerevents/v1/respond events-playlists
|
||||
GET api/playerevents/v1/room/{0} events-playlists
|
||||
GET api/playerevents/v1/search events-playlists
|
||||
GET api/playerevents/v1/searchlive events-playlists
|
||||
GET api/playerevents/v1/tagfilters events-playlists
|
||||
GET api/playerevents/v1/{0} events-playlists
|
||||
GET api/playerevents/v1/{0}/responses events-playlists
|
||||
POST api/playerevents/v2 events-playlists
|
||||
POST api/playerevents/v2/delete/{0} events-playlists
|
||||
POST api/playerevents/v2/{0} events-playlists
|
||||
PUT api/playerevents/v2/{0}/accessibility events-playlists
|
||||
PUT api/playerevents/v2/{0}/club events-playlists
|
||||
PUT api/playerevents/v2/{0}/description events-playlists
|
||||
PUT api/playerevents/v2/{0}/image events-playlists
|
||||
PUT api/playerevents/v2/{0}/multiinstance events-playlists
|
||||
PUT api/playerevents/v2/{0}/name events-playlists
|
||||
PUT api/playerevents/v2/{0}/room events-playlists
|
||||
PUT api/playerevents/v2/{0}/tags events-playlists
|
||||
PUT api/playerevents/v2/{0}/time events-playlists
|
||||
PUT api/players/v4/current/contact identity-account
|
||||
POST api/playerwarnings moderation-safety
|
||||
POST api/playerwarnings/acknowledge moderation-safety
|
||||
POST api/playstationplus/expire commerce-subscription
|
||||
GET api/progressionEvents/active events-playlists
|
||||
POST api/progressionEvents/collect/{eventId}/{rewardIndex} events-playlists
|
||||
GET api/progressionEvents/event/{eventId} events-playlists
|
||||
GET api/progressionEvents/record/{progressionEventId} events-playlists
|
||||
GET api/progressionEvents/{eventId}/xpboosts events-playlists
|
||||
GET api/progressionEvents/{eventId}/xpboosts/{boostId}/previewEarnedXp events-playlists
|
||||
GET api/quickPlay/v1/getandclear progression-misc
|
||||
GET api/relationships/mutualfriends relationships-messaging
|
||||
POST api/relationships/sendfriendintroductions relationships-messaging
|
||||
GET api/relationships/v1/addfriendwithcode relationships-messaging
|
||||
POST api/relationships/v1/bulkignoreplatformusers relationships-messaging
|
||||
GET api/relationships/v1/favorite relationships-messaging
|
||||
POST api/relationships/v1/ignore moderation-safety
|
||||
POST api/relationships/v1/mute moderation-safety
|
||||
GET api/relationships/v1/unfavorite relationships-messaging
|
||||
POST api/relationships/v1/unignore moderation-safety
|
||||
POST api/relationships/v1/unmute moderation-safety
|
||||
GET api/relationships/v2/acceptfriendrequest relationships-messaging
|
||||
GET api/relationships/v2/addfriend relationships-messaging
|
||||
GET api/relationships/v2/get relationships-messaging
|
||||
GET api/relationships/v2/removefriend relationships-messaging
|
||||
GET api/relationships/v2/sendfriendrequest relationships-messaging
|
||||
POST api/roomCurrencies/v2/purchase room-economy
|
||||
POST api/roomEarningsDistributions/v1/earningsDistribution progression-misc
|
||||
PUT api/roomconsumables/v1/roomConsumable room-economy
|
||||
GET api/roomconsumables/v1/roomConsumable/room/{roomId} room-economy
|
||||
GET api/roomconsumables/v1/roomConsumable/room/{roomId}/me room-economy
|
||||
DELETE api/roomconsumables/v1/roomConsumable/{id} room-economy
|
||||
PUT api/roomconsumables/v1/roomConsumable/{id}/consume room-economy
|
||||
GET api/roomconsumables/v1/roomConsumable/{id}/isOwned room-economy
|
||||
PUT api/roomconsumables/v1/roomconsumable/{0}/purchase/currency room-economy
|
||||
PUT api/roomconsumables/v1/roomconsumable/{0}/purchase/tokens room-economy
|
||||
POST api/roomcurrencies/v1/awardCurrency/bulk room-economy
|
||||
POST api/roomcurrencies/v1/createCurrency room-economy
|
||||
POST api/roomcurrencies/v1/createPurchaseOffer room-economy
|
||||
GET api/roomcurrencies/v1/currencies room-economy
|
||||
POST api/roomcurrencies/v1/deletePurchaseOffer room-economy
|
||||
GET api/roomcurrencies/v1/getAllBalances room-economy
|
||||
GET api/roomcurrencies/v1/getBalance room-economy
|
||||
GET api/roomcurrencies/v1/getPurchaseOffersBatch room-economy
|
||||
POST api/roomcurrencies/v1/updateCurrency room-economy
|
||||
POST api/roomcurrencies/v1/updatePurchaseOffer room-economy
|
||||
POST api/roomkeys/v1/create room-economy
|
||||
DELETE api/roomkeys/v1/delete/{roomKeyId} room-economy
|
||||
GET api/roomkeys/v1/mine room-economy
|
||||
GET api/roomkeys/v1/owns room-economy
|
||||
POST api/roomkeys/v1/owns/bulk room-economy
|
||||
GET api/roomkeys/v1/purchased/{roomKeyId} room-economy
|
||||
GET api/roomkeys/v1/room?roomId={id} room-economy
|
||||
GET api/rooms/v1/filters rooms
|
||||
POST api/rooms/v1/verifyRole rooms
|
||||
POST api/rooms/v2/report rooms
|
||||
GET api/royale/v1/current progression-misc
|
||||
POST api/royale/v2/matchcomplete progression-misc
|
||||
POST api/sanitize/v1 identity-account
|
||||
POST api/sanitize/v1/isPure identity-account
|
||||
POST api/screensharereports/v1/report moderation-safety
|
||||
POST api/storefronts/v1/PurchaseRoomKeyWithCurrency commerce-subscription
|
||||
GET api/storefronts/v1/adcarouselitems commerce-subscription
|
||||
GET api/storefronts/v1/balanceAddType/{currencyType:int}/{tierId:int} commerce-subscription
|
||||
POST api/storefronts/v1/buyForFreeGiftButton commerce-subscription
|
||||
POST api/storefronts/v1/buyProgressionEventXpBoost commerce-subscription
|
||||
POST api/storefronts/v1/buyPurchaseReminder commerce-subscription
|
||||
GET api/storefronts/v1/buyRoomKey commerce-subscription
|
||||
POST api/storefronts/v1/objectives commerce-subscription
|
||||
GET api/storefronts/v1/season/{storefrontType:int} commerce-subscription
|
||||
GET api/storefronts/v1/toptoday commerce-subscription
|
||||
POST api/storefronts/v1/trialInvention commerce-subscription
|
||||
GET api/storefronts/v1/trialInvention/duration commerce-subscription
|
||||
POST api/storefronts/v2/balance commerce-subscription
|
||||
POST api/storefronts/v2/buyElite commerce-subscription
|
||||
GET api/storefronts/v2/buyInvention commerce-subscription
|
||||
POST api/storefronts/v2/buyItem commerce-subscription
|
||||
POST api/storefronts/v2/buyTier commerce-subscription
|
||||
GET api/storefronts/v3/giftdropstore/{storefrontType:int} commerce-subscription
|
||||
GET api/storefronts/v4/balance/{currencyType:int} commerce-subscription
|
||||
GET api/subscriptionseasons/v1/seasons/current progression-misc
|
||||
GET api/testcasemanagement/v1/testcase/{id} config-telemetry
|
||||
POST api/testcasemanagement/v1/testcase/{id}/claim config-telemetry
|
||||
POST api/testcasemanagement/v1/testcase/{id}/comment config-telemetry
|
||||
POST api/testcasemanagement/v1/testcase/{id}/status config-telemetry
|
||||
POST api/testcasemanagement/v1/testcase/{id}/unclaim config-telemetry
|
||||
GET api/testcasemanagement/v1/testpass/{id} config-telemetry
|
||||
GET api/testcasemanagement/v1/testpasssummary config-telemetry
|
||||
POST api/ugcPurchasables/v1/items/bulk avatar-inventory
|
||||
GET api/ugcPurchasables/v1/items/room/{roomId} avatar-inventory
|
||||
GET api/versioncheck/v4 identity-account
|
||||
DELETE cachedlogin/current identity-account
|
||||
GET cachedlogin/forplatformid/{0}/{1} identity-account
|
||||
POST cachedlogin/forplatformids identity-account
|
||||
POST cachedlogin/migrate identity-account
|
||||
GET club/account/{0}/created social-clubs-chat
|
||||
GET club/categoryTags social-clubs-chat
|
||||
POST club/create social-clubs-chat
|
||||
DELETE club/home/me social-clubs-chat
|
||||
GET club/home/me social-clubs-chat
|
||||
PUT club/home/me social-clubs-chat
|
||||
GET club/mine/created social-clubs-chat
|
||||
GET club/mine/member social-clubs-chat
|
||||
GET club/mostactivetoday social-clubs-chat
|
||||
GET club/search social-clubs-chat
|
||||
DELETE club/{0} social-clubs-chat
|
||||
GET club/{0} social-clubs-chat
|
||||
DELETE club/{0}/additionalimage/{1} social-clubs-chat
|
||||
PUT club/{0}/additionalimage/{1} social-clubs-chat
|
||||
PUT club/{0}/clubChatEnabled social-clubs-chat
|
||||
DELETE club/{0}/clubhouse social-clubs-chat
|
||||
PUT club/{0}/clubhouse social-clubs-chat
|
||||
GET club/{0}/details social-clubs-chat
|
||||
GET club/{0}/hasDisabledClubChat social-clubs-chat
|
||||
DELETE club/{0}/mainimage social-clubs-chat
|
||||
PUT club/{0}/mainimage social-clubs-chat
|
||||
GET club/{0}/members social-clubs-chat
|
||||
POST club/{0}/members/acceptinvite social-clubs-chat
|
||||
POST club/{0}/members/acceptrequest social-clubs-chat
|
||||
POST club/{0}/members/acceptrequests social-clubs-chat
|
||||
POST club/{0}/members/ban social-clubs-chat
|
||||
GET club/{0}/members/banned social-clubs-chat
|
||||
PUT club/{0}/members/changetype social-clubs-chat
|
||||
POST club/{0}/members/declineinvite social-clubs-chat
|
||||
POST club/{0}/members/denyrequest social-clubs-chat
|
||||
POST club/{0}/members/denyrequests social-clubs-chat
|
||||
POST club/{0}/members/directJoin social-clubs-chat
|
||||
PUT club/{0}/members/invite social-clubs-chat
|
||||
POST club/{0}/members/invitemembers social-clubs-chat
|
||||
POST club/{0}/members/leave social-clubs-chat
|
||||
POST club/{0}/members/remove social-clubs-chat
|
||||
GET club/{0}/members/requests social-clubs-chat
|
||||
GET club/{0}/members/requests/search social-clubs-chat
|
||||
PUT club/{0}/members/requesttojoin social-clubs-chat
|
||||
GET club/{0}/members/search social-clubs-chat
|
||||
POST club/{0}/members/unban social-clubs-chat
|
||||
GET club/{0}/members/{1} social-clubs-chat
|
||||
PUT club/{0}/minlevel social-clubs-chat
|
||||
PUT club/{0}/modify social-clubs-chat
|
||||
PUT club/{0}/modifydetails social-clubs-chat
|
||||
PUT club/{0}/permissions/{1} social-clubs-chat
|
||||
GET clubhousesearch/mostactivenow social-clubs-chat
|
||||
POST comments/create/{0} social-clubs-chat
|
||||
DELETE comments/delete/{0} social-clubs-chat
|
||||
GET comments/get/{0} social-clubs-chat
|
||||
PUT comments/read/{0}/{1} social-clubs-chat
|
||||
GET comments/unreadcounts social-clubs-chat
|
||||
GET config/categories config-telemetry
|
||||
POST connect/deviceauthorization identity-account
|
||||
POST connect/token identity-account
|
||||
POST data/event events-playlists
|
||||
PUT datalink config-telemetry
|
||||
GET datalink/{code} config-telemetry
|
||||
GET eac/challenge identity-account
|
||||
GET econ/customAvatarItems/v1/itemOwnershipLimit avatar-inventory
|
||||
GET econ/customAvatarItems/v1/owned avatar-inventory
|
||||
POST econ/customAvatarItems/v1/{id}/purchase/?requestedPrice={price} avatar-inventory
|
||||
GET emojiConfig/whitelistedEmojis config-telemetry
|
||||
GET event/{0}/instances events-playlists
|
||||
GET featuredrooms/current rooms
|
||||
POST feed/query config-telemetry
|
||||
DELETE invite/{0} progression-misc
|
||||
POST leaderboard/CheckAndSetStat progression-misc
|
||||
POST leaderboard/GetNearbyScores progression-misc
|
||||
POST leaderboard/GetPlayerRank progression-misc
|
||||
POST leaderboard/GetRanks progression-misc
|
||||
POST matchmake/chatinvite/{0}/{1} matchmaking-session
|
||||
POST matchmake/club/{0} matchmaking-session
|
||||
POST matchmake/code/{0}/{1} matchmaking-session
|
||||
POST matchmake/dorm matchmaking-session
|
||||
POST matchmake/event/{0} matchmaking-session
|
||||
POST matchmake/instance/{0} matchmaking-session
|
||||
POST matchmake/invite/{0} matchmaking-session
|
||||
POST matchmake/none matchmaking-session
|
||||
POST matchmake/player/{0} matchmaking-session
|
||||
POST matchmake/room/{0} matchmaking-session
|
||||
POST matchmake/room/{0}/{1} matchmaking-session
|
||||
GET members/bulk social-clubs-chat
|
||||
GET namegen/options identity-account
|
||||
POST pageview/consume config-telemetry
|
||||
GET parentalcontrol/me identity-account
|
||||
GET platformid/{0} identity-account
|
||||
GET player/avoidjuniors matchmaking-session
|
||||
POST player/exclusivelogin matchmaking-session
|
||||
POST player/heartbeat matchmaking-session
|
||||
POST player/login matchmaking-session
|
||||
POST player/logout matchmaking-session
|
||||
POST player/notifydisconnect matchmaking-session
|
||||
PUT player/photonregionpings matchmaking-session
|
||||
PUT player/statusvisibility matchmaking-session
|
||||
PUT player/vrmovementmode matchmaking-session
|
||||
POST playlists/bulk events-playlists
|
||||
GET playlists/cheeredby/me events-playlists
|
||||
GET playlists/createdby/me events-playlists
|
||||
GET playlists/favoritedby/me events-playlists
|
||||
GET playlists/visitedby/me events-playlists
|
||||
DELETE playlists/{0} events-playlists
|
||||
GET playlists/{0} events-playlists
|
||||
PUT playlists/{0}/accessibility events-playlists
|
||||
PUT playlists/{0}/description events-playlists
|
||||
PUT playlists/{0}/image events-playlists
|
||||
GET playlists/{0}/interactionby/me events-playlists
|
||||
PUT playlists/{0}/interactionby/me/cheer events-playlists
|
||||
PUT playlists/{0}/interactionby/me/favorite events-playlists
|
||||
PUT playlists/{0}/levelvoting events-playlists
|
||||
PUT playlists/{0}/name events-playlists
|
||||
PUT playlists/{0}/restrictions events-playlists
|
||||
PUT playlists/{0}/rooms/{1} events-playlists
|
||||
PUT playlists/{0}/tags events-playlists
|
||||
PUT playlists/{0}/warning events-playlists
|
||||
POST purchase/v1/cancelpurchase commerce-subscription
|
||||
POST purchase/v1/cleanuppending commerce-subscription
|
||||
POST purchase/v1/completepurchase commerce-subscription
|
||||
GET purchase/v1/hasspentmoney commerce-subscription
|
||||
POST purchase/v1/initiatepurchase commerce-subscription
|
||||
POST purchase/v1/processpurchase commerce-subscription
|
||||
GET purchasecampaign/allcurrent/v2 commerce-subscription
|
||||
POST purchasecampaign/shown commerce-subscription
|
||||
GET reminder/currentTokenBundles/v2 commerce-subscription
|
||||
POST remote-run/push-to-studio inventions-media
|
||||
GET role/developer/{0} progression-misc
|
||||
GET role/moderator/{0} progression-misc
|
||||
GET room/{0}/instances rooms
|
||||
PUT roominstance/{0}/inprogress rooms
|
||||
PUT roominstance/{0}/markprivate rooms
|
||||
POST roominstance/{0}/reportjoinresult rooms
|
||||
PUT roominstance/{0}/roomCode rooms
|
||||
GET rooms/base rooms
|
||||
GET rooms/bulk rooms
|
||||
GET rooms/cheeredby/me rooms
|
||||
GET rooms/contestwinners rooms
|
||||
GET rooms/createdby/me rooms
|
||||
GET rooms/createdby/{0} rooms
|
||||
GET rooms/curated_playlists rooms
|
||||
GET rooms/favoritedby/me rooms
|
||||
GET rooms/fromcreators rooms
|
||||
GET rooms/hot rooms
|
||||
GET rooms/magic_door rooms
|
||||
GET rooms/moderatedby/me rooms
|
||||
GET rooms/ownedby/me rooms
|
||||
GET rooms/ownedby/{0} rooms
|
||||
GET rooms/recommendations rooms
|
||||
GET rooms/rro_ids rooms
|
||||
GET rooms/search rooms
|
||||
GET rooms/topcreators rooms
|
||||
GET rooms/visitedby/me rooms
|
||||
GET rooms/visitedby/{0} rooms
|
||||
DELETE rooms/{0} rooms
|
||||
GET rooms/{0} rooms
|
||||
PUT rooms/{0}/accessibility rooms
|
||||
PUT rooms/{0}/allow_new_users rooms
|
||||
PUT rooms/{0}/automute rooms
|
||||
GET rooms/{0}/bans rooms
|
||||
POST rooms/{0}/bans rooms
|
||||
POST rooms/{0}/bans/import rooms
|
||||
DELETE rooms/{0}/bans/{1} rooms
|
||||
POST rooms/{0}/clone rooms
|
||||
PUT rooms/{0}/cloning rooms
|
||||
PUT rooms/{0}/comments rooms
|
||||
PUT rooms/{0}/creator rooms
|
||||
PUT rooms/{0}/description rooms
|
||||
PUT rooms/{0}/image rooms
|
||||
GET rooms/{0}/interactionby/me rooms
|
||||
DELETE rooms/{0}/interactionby/me/cheer rooms
|
||||
PUT rooms/{0}/interactionby/me/cheer rooms
|
||||
DELETE rooms/{0}/interactionby/me/favorite rooms
|
||||
PUT rooms/{0}/interactionby/me/favorite rooms
|
||||
DELETE rooms/{0}/loadscreen rooms
|
||||
PUT rooms/{0}/loadscreen rooms
|
||||
PUT rooms/{0}/max_player_calculation_mode rooms
|
||||
PUT rooms/{0}/min_level rooms
|
||||
PUT rooms/{0}/modify rooms
|
||||
PUT rooms/{0}/name rooms
|
||||
GET rooms/{0}/playerdata/me rooms
|
||||
PUT rooms/{0}/playerdata/me rooms
|
||||
DELETE rooms/{0}/promo_external rooms
|
||||
PUT rooms/{0}/promo_external rooms
|
||||
DELETE rooms/{0}/promo_images rooms
|
||||
PUT rooms/{0}/promo_images rooms
|
||||
PUT rooms/{0}/restrictions rooms
|
||||
GET rooms/{0}/roles rooms
|
||||
GET rooms/{0}/roles/{1} rooms
|
||||
PUT rooms/{0}/roles/{1} rooms
|
||||
PUT rooms/{0}/roles/{1}/invite rooms
|
||||
GET rooms/{0}/similar rooms
|
||||
POST rooms/{0}/subrooms rooms
|
||||
DELETE rooms/{0}/subrooms/{1} rooms
|
||||
PUT rooms/{0}/subrooms/{1}/accessibility rooms
|
||||
POST rooms/{0}/subrooms/{1}/clone rooms
|
||||
POST rooms/{0}/subrooms/{1}/data rooms
|
||||
PUT rooms/{0}/subrooms/{1}/maxplayers rooms
|
||||
PUT rooms/{0}/subrooms/{1}/modify rooms
|
||||
POST rooms/{0}/subrooms/{1}/move rooms
|
||||
PUT rooms/{0}/subrooms/{1}/name rooms
|
||||
PUT rooms/{0}/subrooms/{1}/permissions rooms
|
||||
POST rooms/{0}/subrooms/{1}/publish_save rooms
|
||||
GET rooms/{0}/subrooms/{1}/saves rooms
|
||||
PUT rooms/{0}/tags rooms
|
||||
PUT rooms/{0}/voice_chat_encryption rooms
|
||||
PUT rooms/{0}/warning rooms
|
||||
GET settings/partyinvite config-telemetry
|
||||
GET showcase/{0} inventions-media
|
||||
GET subscription/details/{accountId:int} commerce-subscription
|
||||
GET subscription/mine/member commerce-subscription
|
||||
GET subscription/subscriberCount/{accountId:int} commerce-subscription
|
||||
GET subscription/top/creators/today commerce-subscription
|
||||
POST subscription/v1/cancel commerce-subscription
|
||||
POST subscription/{accountId:int} commerce-subscription
|
||||
GET thread/club/{0} social-clubs-chat
|
||||
PUT thread/message/{0}/moderate social-clubs-chat
|
||||
POST thread/withmembers social-clubs-chat
|
||||
GET thread/{0} social-clubs-chat
|
||||
POST thread/{0} social-clubs-chat
|
||||
PUT thread/{0}/favorite social-clubs-chat
|
||||
POST thread/{0}/leave social-clubs-chat
|
||||
POST thread/{0}/member/{1} social-clubs-chat
|
||||
GET thread/{0}/message social-clubs-chat
|
||||
POST thread/{0}/message/{1}/read social-clubs-chat
|
||||
POST thread/{0}/rename social-clubs-chat
|
||||
POST thread/{0}/snooze social-clubs-chat
|
||||
GET unity_assets/{0}/{1}/{2} inventions-media
|
||||
GET voice/config moderation-safety
|
||||
GET voice/requiresModeration moderation-safety
|
||||
|
@@ -21,6 +21,14 @@ public class CustomAvatarItemsController(
|
||||
IObjectStorage storage,
|
||||
ILogger<CustomAvatarItemsController> logger) : ControllerBase
|
||||
{
|
||||
/// <summary>Floor price for a publicly listed custom item. The client
|
||||
/// asks for this over the wire (<c>api/customAvatarItems/v1/minPriceForPublicItem</c>,
|
||||
/// RecNet.Runtime/MPBLNLMCEDL.txt:6893) and then enforces it locally in the
|
||||
/// designer, so the number the create/update paths clamp to and the number
|
||||
/// served on that route have to be the same constant or the client's own
|
||||
/// validation disagrees with the server's rejection.</summary>
|
||||
private const int MinPublicItemPrice = 100;
|
||||
|
||||
[HttpGet]
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> List(
|
||||
@@ -529,7 +537,7 @@ public class CustomAvatarItemsController(
|
||||
|
||||
item.Name = name;
|
||||
item.Description = description;
|
||||
item.Price = Math.Max(100, req.Price ?? item.Price);
|
||||
item.Price = Math.Max(MinPublicItemPrice, req.Price ?? item.Price);
|
||||
item.ItemType = req.ItemType ?? item.ItemType;
|
||||
item.BaseAvatarItemId = req.BaseAvatarItemId ?? item.BaseAvatarItemId;
|
||||
item.Color = Trim(req.Color, 64);
|
||||
@@ -939,6 +947,30 @@ public class CustomAvatarItemsController(
|
||||
public List<Guid>? ItemIds { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>The designer's in-progress draft, as persisted to the
|
||||
/// per-player <c>caidesign_p<id>.json</c> object (see
|
||||
/// <see cref="LoadDesignAsync"/>). Mirrors the three payload properties of
|
||||
/// the client's design DTOs — the metadata object GIOBJIGEMNG carries the
|
||||
/// nullable base-item id and the "#RRGGBB" colour, and the texture arrives
|
||||
/// as the separate "design" file part whose stored blob name lands in
|
||||
/// <see cref="ImageName"/>.
|
||||
///
|
||||
/// <see cref="Deleted"/> is the tombstone: <see cref="IObjectStorage"/>
|
||||
/// exposes no delete operation, so DELETE design overwrites the record with
|
||||
/// a deleted marker and LoadDesignAsync reports it as "no saved design".
|
||||
/// Public properties with setters because the round-trip goes through
|
||||
/// System.Text.Json.</summary>
|
||||
public sealed class StoredDesign
|
||||
{
|
||||
public int? BaseAvatarItemId { get; set; }
|
||||
|
||||
public string Color { get; set; } = string.Empty;
|
||||
|
||||
public string ImageName { get; set; } = string.Empty;
|
||||
|
||||
public bool Deleted { get; set; }
|
||||
}
|
||||
|
||||
public sealed class DesignRequest
|
||||
{
|
||||
public Guid? Id { get; set; }
|
||||
|
||||
@@ -387,19 +387,59 @@ public class InventionsController(
|
||||
/// <c>List → Dictionary</c> cast inside <c>Util.Deserialize<T></c>
|
||||
/// and surfaced as "Failed to get tags: Malformed Response"
|
||||
/// (output_log.txt:1602+1639) — and that bubbles up as the
|
||||
/// dorm-load destabilisation we've been hunting.</summary>
|
||||
/// dorm-load destabilisation we've been hunting.
|
||||
///
|
||||
/// The 2023 DTO reads a THIRD list, <c>TrendingFilters</c>
|
||||
/// (HMDONHHKKGA.txt:099/169), which we never sent — callers projecting that
|
||||
/// list got an empty filter row. Popular/Trending are now derived from the
|
||||
/// tags actually in use (<see cref="InventionEntity.TagsCsv"/>) rather than
|
||||
/// echoing the pinned array three times; the curated list is the fallback
|
||||
/// so a fresh server still shows filters. The 2020.12 watch's
|
||||
/// <c>AEBEPCMAABC</c> reads only Pinned/Popular, so the extra key is
|
||||
/// inert there.</summary>
|
||||
[HttpGet("api/inventions/v1/tagfilters")]
|
||||
public IActionResult TagFilters()
|
||||
public async Task<IActionResult> TagFilters()
|
||||
{
|
||||
var tags = new[]
|
||||
{
|
||||
string[] pinned =
|
||||
[
|
||||
"sport", "game", "vehicle", "weapon", "decor", "tool",
|
||||
"art", "music", "puzzle", "combat", "build", "race",
|
||||
};
|
||||
];
|
||||
|
||||
// TagsCsv is a packed list no provider can split, so the frequency count
|
||||
// runs in memory over a bounded window of the most recently touched
|
||||
// tagged inventions.
|
||||
var rows = await db.Inventions
|
||||
.Where(i => !i.IsDeleted && i.IsPublished && i.TagsCsv != "")
|
||||
.OrderByDescending(i => i.UpdatedAt)
|
||||
.Select(i => new { i.TagsCsv, i.UpdatedAt })
|
||||
.Take(2000)
|
||||
.ToListAsync();
|
||||
|
||||
static IEnumerable<string> SplitTags(string csv) => csv
|
||||
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
|
||||
static List<string> Rank(IEnumerable<string> tags) => tags
|
||||
.GroupBy(t => t, StringComparer.OrdinalIgnoreCase)
|
||||
.OrderByDescending(g => g.Count())
|
||||
.ThenBy(g => g.Key, StringComparer.OrdinalIgnoreCase)
|
||||
.Select(g => g.Key)
|
||||
.Take(12)
|
||||
.ToList();
|
||||
|
||||
var since = DateTime.UtcNow.AddDays(-7);
|
||||
var popular = Rank(rows.SelectMany(r => SplitTags(r.TagsCsv)));
|
||||
var trending = Rank(rows.Where(r => r.UpdatedAt >= since)
|
||||
.SelectMany(r => SplitTags(r.TagsCsv)));
|
||||
|
||||
if (popular.Count == 0) popular = [.. pinned];
|
||||
if (trending.Count == 0) trending = popular;
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
PinnedFilters = tags,
|
||||
PopularFilters = tags,
|
||||
PinnedFilters = pinned,
|
||||
PopularFilters = popular,
|
||||
TrendingFilters = trending,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -520,18 +560,26 @@ public class InventionsController(
|
||||
return Ok(ToWire(i));
|
||||
}
|
||||
|
||||
/// <summary>POST <c>api/storefronts/v1/trialInvention?inventionId={id}</c>
|
||||
/// — start a free trial of a store invention. Client
|
||||
/// (RecNet.Runtime <c>DCFKEFHJAGC.PPCLLFMHJLD(long)</c>) expects a SINGLE
|
||||
/// Invention DTO back (same <see cref="ToWire"/> shape it uses for
|
||||
/// search), which it spawns for the trial window (length from
|
||||
/// <c>trialInvention/duration</c>). Hosted here — not in
|
||||
/// <summary>POST <c>api/storefronts/v1/trialInvention</c> — start a free
|
||||
/// trial of a store invention, spawned for the window returned by
|
||||
/// <c>trialInvention/duration</c>. Hosted here — not in
|
||||
/// StorefrontsController — so it can reuse the invention wire builder.
|
||||
/// We record the trial start so it's an auditable action.</summary>
|
||||
/// We record the trial start so it's an auditable action.
|
||||
///
|
||||
/// TWO wire corrections. (1) The id is a FORM field, not a query param:
|
||||
/// DCFKEFHJAGC.txt:9037 sets verb 2 (POST) and :9050 adds the pair via
|
||||
/// <c>AFGEDDANEKP("inventionId", …)</c>, and on a non-GET those pairs go
|
||||
/// into an HTTPUrlEncodedForm body (see the id-list block comment above), so
|
||||
/// <c>[FromQuery]</c> bound 0 and every trial 404'd. (2) The reply is the
|
||||
/// Status/Invention/InventionVersion envelope: the continuation at
|
||||
/// DCFKEFHJAGC.txt:9086 is a
|
||||
/// <c>Func<BDNCJIPHHOK, FGLDKEJLAKB<IFJONDCAKKM>></c> which
|
||||
/// projects <c>.Invention</c> — a bare invention left that null.</summary>
|
||||
[HttpPost("api/storefronts/v1/trialInvention")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult> TrialInvention([FromQuery] long inventionId)
|
||||
public async Task<ActionResult> TrialInvention()
|
||||
{
|
||||
var inventionId = await ReadLongFieldAsync("inventionId") ?? 0;
|
||||
var i = await db.Inventions.FirstOrDefaultAsync(x => x.Id == inventionId && !x.IsDeleted);
|
||||
if (i is null) return NotFound();
|
||||
if (!i.IsPublished && i.CreatorPlayerId != CurrentPlayerIdOrNull)
|
||||
@@ -550,9 +598,21 @@ public class InventionsController(
|
||||
row.Value = DateTime.UtcNow.ToString("O");
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
return Ok(ToWire(i));
|
||||
return Ok(await EnvelopeAsync(i));
|
||||
}
|
||||
|
||||
/// <summary>GET <c>api/inventions/v1/details</c> — the ONLY key either
|
||||
/// client reads off this response is <c>Tags</c>: the 2023 continuation is
|
||||
/// <c>Action<OIABGAKJABE></c> (OEGFNFEAAGO.txt:6312) whose reader takes
|
||||
/// exactly one literal, "Tags" (DBAHPLOPFIO.txt:034), and the 2020.12 watch's
|
||||
/// HJPDBNLCGIB does the same. Each element is
|
||||
/// <c>{Tag:String, Type:Int32}</c> (PCMLHLIBLNJ.txt:038/057). Without the key
|
||||
/// the invention detail page's tag row was permanently empty. Invention +
|
||||
/// Versions stay on the wire — unknown members are ignored by both readers.
|
||||
/// <c>Type</c> is always 0: <see cref="SetTags"/> merges the client's
|
||||
/// AutoTags and CustomTags into one <see cref="InventionEntity.TagsCsv"/>
|
||||
/// column, so the auto/custom distinction is not recoverable without a
|
||||
/// second column.</summary>
|
||||
[HttpGet("api/inventions/v1/details")]
|
||||
public async Task<ActionResult> Details([FromQuery] long inventionId)
|
||||
{
|
||||
@@ -566,21 +626,33 @@ public class InventionsController(
|
||||
.ToListAsync();
|
||||
return Ok(new
|
||||
{
|
||||
Tags = i.TagsCsv
|
||||
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.Select(t => new { Tag = t, Type = 0 }),
|
||||
Invention = ToWire(i),
|
||||
Versions = versions.Select(ToVersionWire),
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>GET <c>api/inventions/v1/personaldetails/{id}</c> — the caller's
|
||||
/// own relationship to the invention. Both clients read a single key here:
|
||||
/// the 2023 continuation is <c>FGLDKEJLAKB<CEAFHBOOBKL></c>
|
||||
/// (OEGFNFEAAGO.txt:8995) and CEAFHBOOBKL's reader takes only "IsCheering"
|
||||
/// (BCLCHNGENDE.txt:036); the 2020.12 watch's OEGPIPBKHCN is identical.
|
||||
/// Without it the detail page never lit the player's own cheer.</summary>
|
||||
[HttpGet("api/inventions/v1/personaldetails/{id:long}")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult> PersonalDetails(long id)
|
||||
{
|
||||
var i = await db.Inventions.FirstOrDefaultAsync(x => x.Id == id && !x.IsDeleted);
|
||||
if (i is null) return NotFound();
|
||||
var pid = CurrentPlayerId;
|
||||
return Ok(new
|
||||
{
|
||||
IsCheering = await db.Cheers.AnyAsync(c =>
|
||||
c.FromPlayerId == pid && c.TargetInventionId == id),
|
||||
Invention = ToWire(i),
|
||||
CanEdit = i.CreatorPlayerId == CurrentPlayerId,
|
||||
CanEdit = i.CreatorPlayerId == pid,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -613,6 +685,14 @@ public class InventionsController(
|
||||
public string? ImageName { get; set; }
|
||||
public int InstantiationCost { get; set; }
|
||||
public int LightsCost { get; set; }
|
||||
/// <summary>RecNet.NewInventionRequestDTO carries <c>chipsCost</c> and
|
||||
/// <c>cloudVariablesCost</c> too (2023.06 dump.cs:1283258-1283259).
|
||||
/// <see cref="InventionVersionEntity"/> has no column for either, so
|
||||
/// they cannot be persisted — but binding them lets the save response
|
||||
/// echo back the budget the client just reported instead of two
|
||||
/// zeroes.</summary>
|
||||
public int ChipsCost { get; set; }
|
||||
public int CloudVariablesCost { get; set; }
|
||||
public int AiCost { get; set; }
|
||||
public long? CreationRoomId { get; set; }
|
||||
public string? InventionDataFilename { get; set; }
|
||||
@@ -686,7 +766,8 @@ public class InventionsController(
|
||||
{
|
||||
Status = 0,
|
||||
Invention = ToWireV4(inv),
|
||||
InventionVersion = ToVersionWire(v1),
|
||||
InventionVersion = ToVersionWire(
|
||||
v1, Math.Max(0, req.ChipsCost), Math.Max(0, req.CloudVariablesCost)),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -727,7 +808,7 @@ public class InventionsController(
|
||||
inv.CurrentVersionNumber = nextVer;
|
||||
inv.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
db.InventionVersions.Add(new InventionVersionEntity
|
||||
var version = new InventionVersionEntity
|
||||
{
|
||||
InventionId = inv.Id,
|
||||
ReplicationId = Guid.NewGuid().ToString("D"),
|
||||
@@ -735,9 +816,20 @@ public class InventionsController(
|
||||
BlobName = blobName,
|
||||
InstantiationCost = req.InstantiationCost ?? 0,
|
||||
LightsCost = req.LightsCost ?? 0,
|
||||
});
|
||||
};
|
||||
db.InventionVersions.Add(version);
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(ToWire(inv));
|
||||
|
||||
// Envelope, not a bare invention — see EnvelopeAsync. The version we
|
||||
// just wrote is the one the client wants back, and the two cost fields
|
||||
// it sent are echoed because no column stores them.
|
||||
return Ok(new
|
||||
{
|
||||
Status = 0,
|
||||
Invention = ToWire(inv),
|
||||
InventionVersion = ToVersionWire(
|
||||
version, Math.Max(0, req.ChipsCost ?? 0), Math.Max(0, req.CloudVariablesCost ?? 0)),
|
||||
});
|
||||
}
|
||||
|
||||
// ── GET-based mutations (Core.Get pattern) ───────────────────────────
|
||||
@@ -775,14 +867,42 @@ public class InventionsController(
|
||||
}
|
||||
inv.UpdatedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(ToWire(inv));
|
||||
return Ok(await EnvelopeAsync(inv));
|
||||
}
|
||||
|
||||
/// <summary>Body shape for the POST form of <c>updateprice</c> —
|
||||
/// RecNet.UpdatePriceRequest, real field names preserved in the 2023.06
|
||||
/// dump (dump.cs:1283355-1283356). Settable properties + a parameterless
|
||||
/// ctor because <see cref="Binding.FormOrJsonModelBinder"/> constructs the
|
||||
/// instance itself.</summary>
|
||||
public sealed class UpdatePriceRequest
|
||||
{
|
||||
public long InventionId { get; set; }
|
||||
public int Price { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Both clients POST <c>updateprice</c> with a body; only the GET
|
||||
/// query form was registered, so ASP.NET answered 405 and re-pricing a paid
|
||||
/// invention always failed. 2023: OEGFNFEAAGO.txt:9850 sets verb 2 and
|
||||
/// :9870-9876 hands <c>JsonUtility.ToJson(UpdatePriceRequest)</c> to
|
||||
/// <c>FJLLPHFOOJJ</c>, which wraps it in a BestHTTP RawJsonForm
|
||||
/// (<c>application/json</c>, RawJsonForm.txt:40). December POSTs the same
|
||||
/// route form-encoded (2020.12 BBHENFCNLAB.txt:6480). The binder accepts
|
||||
/// both; the GET stays for callers that still use the query string.</summary>
|
||||
[HttpGet("api/inventions/v1/updateprice")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult> UpdatePrice(
|
||||
public Task<ActionResult> UpdatePrice(
|
||||
[FromQuery] long inventionId,
|
||||
[FromQuery] int price)
|
||||
=> ApplyPriceAsync(inventionId, price);
|
||||
|
||||
[HttpPost("api/inventions/v1/updateprice")]
|
||||
[Authorize]
|
||||
public Task<ActionResult> UpdatePricePost(
|
||||
[ModelBinder(typeof(Binding.FormOrJsonModelBinder))] UpdatePriceRequest req)
|
||||
=> ApplyPriceAsync(req.InventionId, req.Price);
|
||||
|
||||
private async Task<ActionResult> ApplyPriceAsync(long inventionId, int price)
|
||||
{
|
||||
var pid = CurrentPlayerId;
|
||||
var inv = await db.Inventions.FirstOrDefaultAsync(x => x.Id == inventionId && !x.IsDeleted);
|
||||
@@ -791,7 +911,7 @@ public class InventionsController(
|
||||
inv.Price = Math.Clamp(price, 0, 1000000);
|
||||
inv.UpdatedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(ToWire(inv));
|
||||
return Ok(await EnvelopeAsync(inv));
|
||||
}
|
||||
|
||||
[HttpGet("api/inventions/v1/delete")]
|
||||
@@ -807,13 +927,29 @@ public class InventionsController(
|
||||
inv.IsPublished = false;
|
||||
inv.UpdatedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(ToWire(inv));
|
||||
return Ok(await EnvelopeAsync(inv));
|
||||
}
|
||||
|
||||
/// <summary>The 2023 publish call carries FOUR query params, not two:
|
||||
/// OEGFNFEAAGO.txt:9437/9449/9461/9473 add <c>inventionId</c>,
|
||||
/// <c>permissionLevel</c>, <c>accessibility</c> and a
|
||||
/// <c>Nullable<Int32></c> <c>price</c> — the wrapper signature is
|
||||
/// OEGFNFEAAGO.txt:9216. Dropping accessibility meant "publish as Private /
|
||||
/// Unlisted" still went fully public, and a paid publish lost its price.
|
||||
/// Accessibility constants: Private=0, Public=1, Unlisted=2
|
||||
/// (AEFFFPIJDHG.GAKJKOGJEEH, 2023.06 dump.cs:1282644-1282646). With no
|
||||
/// Accessibility column on the entity only the Private/not-Private half can
|
||||
/// be stored, so Unlisted currently behaves as Public. The param is
|
||||
/// NULLABLE on purpose — the 2020.12 watch sends only inventionId +
|
||||
/// permissionLevel (2020.12 BBHENFCNLAB.txt:6171), and a missing value must
|
||||
/// not be read as Private or December could never publish.</summary>
|
||||
[HttpGet("api/inventions/v3/publish")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult> Publish(
|
||||
[FromQuery] long inventionId, [FromQuery] int permissionLevel)
|
||||
[FromQuery] long inventionId,
|
||||
[FromQuery] int permissionLevel,
|
||||
[FromQuery] int? accessibility,
|
||||
[FromQuery] int? price)
|
||||
{
|
||||
var pid = CurrentPlayerId;
|
||||
var inv = await db.Inventions.FirstOrDefaultAsync(x => x.Id == inventionId && !x.IsDeleted);
|
||||
@@ -821,13 +957,15 @@ public class InventionsController(
|
||||
if (inv.CreatorPlayerId != pid) return Forbid();
|
||||
var perm = ClampInventionPermission(permissionLevel);
|
||||
if (perm == 0) return BadRequest("permissionLevel must be > 0 (Unassigned)");
|
||||
var access = accessibility ?? 1 /* Public */;
|
||||
inv.GeneralPermission = perm;
|
||||
inv.IsPublished = true;
|
||||
inv.FirstPublishedAt ??= DateTime.UtcNow;
|
||||
inv.IsPublished = access != 0;
|
||||
if (inv.IsPublished) inv.FirstPublishedAt ??= DateTime.UtcNow;
|
||||
inv.Permission = perm >= 60 ? 2 : (perm >= 20 ? 1 : 0);
|
||||
if (price.HasValue) inv.Price = Math.Clamp(price.Value, 0, 1000000);
|
||||
inv.UpdatedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(ToWire(inv));
|
||||
return Ok(await EnvelopeAsync(inv));
|
||||
}
|
||||
|
||||
[HttpGet("api/inventions/v1/unpublish")]
|
||||
@@ -843,7 +981,7 @@ public class InventionsController(
|
||||
inv.Permission = 0;
|
||||
inv.UpdatedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(ToWire(inv));
|
||||
return Ok(await EnvelopeAsync(inv));
|
||||
}
|
||||
|
||||
[HttpGet("api/inventions/v1/download")]
|
||||
@@ -870,13 +1008,30 @@ public class InventionsController(
|
||||
/// <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>
|
||||
/// alias for older callers. Field names are literal in the 2023.06 dump —
|
||||
/// RecNet.ModifyTagsRequest keeps real names (dump.cs:1283295-1283301) —
|
||||
/// and the body is a RAW JSON document
|
||||
/// (<c>JsonUtility.ToJson</c> → <c>BNDIAONDFFF.FJLLPHFOOJJ</c>,
|
||||
/// OEGFNFEAAGO.txt:3082-3088), so [FromBody] is right here.</summary>
|
||||
public sealed record SetTagsRequest(
|
||||
long InventionId,
|
||||
List<string>? AutoTags,
|
||||
List<string>? CustomTags,
|
||||
List<string>? PlayerAddedTags);
|
||||
|
||||
/// <summary>POST <c>api/inventions/v1/settags</c> — the reply is NOT an
|
||||
/// invention. The 2023 issuing method is typed
|
||||
/// <c>FGLDKEJLAKB<PNGLFHEAJIH></c> (OEGFNFEAAGO.txt:2744) and
|
||||
/// PNGLFHEAJIH exposes exactly two members — an int-backed result enum and
|
||||
/// <c>List<String></c> (PNGLFHEAJIH.txt:3/83) — read from the literals
|
||||
/// "Result" and "Tags" (ALJPHDEAHBK.txt:191/210). The 2020.12 watch is
|
||||
/// identical: NJMAEIPIOAP.PPGFHEDFBEA pulls "Result" then "Tags"
|
||||
/// (2020.12 NJMAEIPIOAP.txt:85/90) with the throwing Util.GetKey, so the
|
||||
/// old bare-invention reply killed every tag edit on December too.
|
||||
/// <c>Result = 0</c> is success: the enum's message formatter jump-tables on
|
||||
/// the value and case 0 returns "Success!" (PNGLFHEAJIH.txt:334-341).
|
||||
/// <c>Tags</c> is a flat string list — NOT the {Tag,Type} pairs
|
||||
/// <see cref="Details"/> returns.</summary>
|
||||
[HttpPost("api/inventions/v1/settags")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult> SetTags([FromBody] SetTagsRequest req)
|
||||
@@ -885,20 +1040,39 @@ 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.CustomTags, req.PlayerAddedTags }
|
||||
var tags = 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;
|
||||
.Select(t => (t ?? string.Empty).Trim())
|
||||
.Where(t => t.Length > 0)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
inv.TagsCsv = string.Join(',', tags);
|
||||
inv.UpdatedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(ToWire(inv));
|
||||
return Ok(new { Result = 0, Tags = tags });
|
||||
}
|
||||
|
||||
public sealed record CheerRequest(long InventionId);
|
||||
/// <summary>RecNet.CheerRequest carries a SECOND field — <c>Cheer</c>, the
|
||||
/// desired state — whose real name survives in the 2023.06 dump
|
||||
/// (dump.cs:1283338-1283342: <c>long InventionId</c> @0x10,
|
||||
/// <c>bool Cheer</c> @0x18). Both writes are visible in the ISIL: 2023 sets
|
||||
/// [rdi+16] and [rdi+24] before <c>JsonUtility.ToJson</c>
|
||||
/// (OEGFNFEAAGO.txt:11228-11233) and December does the same
|
||||
/// (2020.12 BBHENFCNLAB.txt:7624-7628) — its wrapper signature is
|
||||
/// <c>ANJHOOPIAKM(Int64, Boolean)</c> (BBHENFCNLAB.txt:7503). Ignoring the
|
||||
/// flag made un-cheering a no-op: the button toggled off in the UI and the
|
||||
/// cheer stayed on the invention forever.</summary>
|
||||
public sealed record CheerRequest(long InventionId, bool Cheer = true);
|
||||
|
||||
/// <summary>POST <c>api/inventions/v1/cheer</c> — answers with the
|
||||
/// Status/Invention/InventionVersion envelope like every other invention
|
||||
/// mutation: the 2023 method is <c>FGLDKEJLAKB<BDNCJIPHHOK></c>
|
||||
/// (OEGFNFEAAGO.txt:11065) and December's is
|
||||
/// <c>IPromise<AHEPPAEOLOD></c> (2020.12 BBHENFCNLAB.txt:7503). The old
|
||||
/// <c>{Id, CheerCount}</c> reply left Invention null on 2023 and threw
|
||||
/// KeyNotFoundException on December, so the tile never refreshed its
|
||||
/// count.</summary>
|
||||
[HttpPost("api/inventions/v1/cheer")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult> Cheer([FromBody] CheerRequest req)
|
||||
@@ -907,10 +1081,11 @@ public class InventionsController(
|
||||
var inv = await db.Inventions.FirstOrDefaultAsync(x => x.Id == req.InventionId && !x.IsDeleted);
|
||||
if (inv is null) return NotFound();
|
||||
|
||||
// Idempotent: one cheer per (player, invention).
|
||||
// Idempotent in both directions: one cheer row per (player, invention).
|
||||
var existing = await db.Cheers.FirstOrDefaultAsync(c =>
|
||||
c.FromPlayerId == pid && c.TargetInventionId == req.InventionId);
|
||||
if (existing is null)
|
||||
|
||||
if (req.Cheer && existing is null)
|
||||
{
|
||||
db.Cheers.Add(new CheerEntity
|
||||
{
|
||||
@@ -926,12 +1101,29 @@ public class InventionsController(
|
||||
PushNotificationId.InventionModerationStateChanged,
|
||||
new { Type = "cheer", inv.Id, From = pid, inv.CheerCount });
|
||||
}
|
||||
return Ok(new { inv.Id, inv.CheerCount });
|
||||
else if (!req.Cheer && existing is not null)
|
||||
{
|
||||
db.Cheers.Remove(existing);
|
||||
inv.CheerCount = Math.Max(0, inv.CheerCount - 1);
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
return Ok(await EnvelopeAsync(inv));
|
||||
}
|
||||
|
||||
public sealed record ReportInventionRequest(
|
||||
long InventionId, int? ReportCategory, string? Details);
|
||||
|
||||
/// <summary>POST <c>api/inventions/v1/report</c> — the reply is a
|
||||
/// success/message pair, not a status flag of our own invention. The 2023
|
||||
/// method is <c>FGLDKEJLAKB<PHMHCPEMABG></c> (OEGFNFEAAGO.txt:10917)
|
||||
/// and PHMHCPEMABG holds a Boolean + a String (PHMHCPEMABG.txt:3/23) read
|
||||
/// from the literals "Success"/"Message" (GBPDOLJBABB.txt:191/210); the
|
||||
/// 2020.12 watch's KLAMKCBENEA.PPGFHEDFBEA pulls the same two literals
|
||||
/// (2020.12 KLAMKCBENEA.txt:85/90). Our old <c>{Reported:true}</c> left
|
||||
/// Success at default false, so the report UI reported failure even though
|
||||
/// the row persisted. <c>Message</c> must be a string — December's
|
||||
/// Util.GetKey casts it — so send "" rather than null.</summary>
|
||||
[HttpPost("api/inventions/v1/report")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult> Report([FromBody] ReportInventionRequest req)
|
||||
@@ -949,7 +1141,7 @@ public class InventionsController(
|
||||
Message = (req.Details ?? string.Empty)[..Math.Min(1000, (req.Details ?? string.Empty).Length)],
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new { Reported = true });
|
||||
return Ok(new { Success = true, Message = string.Empty });
|
||||
}
|
||||
|
||||
// ── Wire serializers ─────────────────────────────────────────────────
|
||||
@@ -1000,13 +1192,40 @@ public class InventionsController(
|
||||
i.CreatorPermission,
|
||||
i.GeneralPermission,
|
||||
IsAGInvention = i.IsAgInvention,
|
||||
// The 2023-03-21 DTO dropped IsPublished and reads "Accessibility"
|
||||
// instead (IOEPPCKGBFL.txt:309 / :1602 — the member switch has no
|
||||
// IsPublished arm at all). Constants come from the nested enum
|
||||
// AEFFFPIJDHG.GAKJKOGJEEH in the 2023.06 dump
|
||||
// (dump.cs:1282644-1282646): Private=0, Public=1, Unlisted=2. With the
|
||||
// key absent the reader left the field at its default, so EVERY
|
||||
// published store invention presented as Private in the 2023 UI.
|
||||
// InventionEntity has no Accessibility column, so only the
|
||||
// Private/Public bit round-trips; Unlisted needs a real column.
|
||||
// IsPublished stays on the wire because the 2020.12 watch's
|
||||
// OBBBPCBIMME reader still requires it.
|
||||
Accessibility = i.IsPublished ? 1 : 0,
|
||||
// IOEPPCKGBFL.txt:565 — DorkNet runs no certification programme.
|
||||
IsCertifiedInvention = false,
|
||||
i.Price,
|
||||
HideFromPlayer = false,
|
||||
};
|
||||
|
||||
private static object ToWireV4(InventionEntity i) => ToWire(i);
|
||||
|
||||
private static object ToVersionWire(InventionVersionEntity v) => new
|
||||
private static object ToVersionWire(InventionVersionEntity v)
|
||||
=> ToVersionWire(v, 0, 0);
|
||||
|
||||
/// <summary>The 2023 InventionVersion DTO reads NINE keys
|
||||
/// (OLPHKLCPFEF.txt:084-295): the six we already emitted plus
|
||||
/// <c>ChipsCost</c>, <c>CloudVariablesCost</c> and <c>BlobHash</c>.
|
||||
/// <see cref="InventionVersionEntity"/> has no column for any of the three,
|
||||
/// so stored rows report 0/empty and only the save/addversion round-trip can
|
||||
/// echo back the costs the client just sent. An empty BlobHash is safe:
|
||||
/// PLIKEBBPJGI's hash getter (<c>EHENFDMIAIM</c>, OLPHKLCPFEF's owner type
|
||||
/// PLIKEBBPJGI.txt:167) has no call site anywhere in the IsilDump — the
|
||||
/// client only ever writes it — so the key's presence is what matters.</summary>
|
||||
private static object ToVersionWire(
|
||||
InventionVersionEntity v, int chipsCost, int cloudVariablesCost) => new
|
||||
{
|
||||
v.InventionId,
|
||||
ReplicationId = string.IsNullOrEmpty(v.ReplicationId)
|
||||
@@ -1014,6 +1233,34 @@ public class InventionsController(
|
||||
v.VersionNumber,
|
||||
v.InstantiationCost,
|
||||
v.LightsCost,
|
||||
ChipsCost = chipsCost,
|
||||
CloudVariablesCost = cloudVariablesCost,
|
||||
v.BlobName,
|
||||
BlobHash = string.Empty,
|
||||
};
|
||||
|
||||
/// <summary>Every invention MUTATION route answers with the
|
||||
/// Status/Invention/InventionVersion envelope, never a bare invention: the
|
||||
/// 2023 client deserialises <c>BDNCJIPHHOK</c> (key literals at
|
||||
/// GMBGBPNMGBA.txt:044-095) and the 2020.12 watch deserialises
|
||||
/// <c>AHEPPAEOLOD</c>, whose reader pulls the same three literals
|
||||
/// (2020.12 IsilDump AHEPPAEOLOD.txt:110/115/120). Returning the invention
|
||||
/// flat left <c>Invention</c> null on 2023 and threw KeyNotFoundException on
|
||||
/// December. <c>Status = 0</c> is success —
|
||||
/// EnterInventionNameDialog.txt:622-624 loads the boxed Status field and
|
||||
/// does <c>test eax,eax / je <success></c>.</summary>
|
||||
private async Task<object> EnvelopeAsync(InventionEntity inv)
|
||||
{
|
||||
var current = await db.InventionVersions
|
||||
.Where(x => x.InventionId == inv.Id)
|
||||
.OrderByDescending(x => x.VersionNumber)
|
||||
.FirstOrDefaultAsync();
|
||||
object? versionWire = current is null ? null : ToVersionWire(current);
|
||||
return new
|
||||
{
|
||||
Status = 0,
|
||||
Invention = ToWire(inv),
|
||||
InventionVersion = versionWire,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using DorkNet.Models.Notification;
|
||||
using DorkNet.Server.Auth;
|
||||
@@ -11,8 +12,22 @@ using DorkNet.Server.Services;
|
||||
namespace DorkNet.Server.Controllers.API.PlayerEvents;
|
||||
|
||||
/// <summary>
|
||||
/// api.rec.net/api/playerevents/v1/* — create, list, RSVP for
|
||||
/// player-scheduled gatherings.
|
||||
/// api.rec.net/api/playerevents/v1|v2/* — create, browse, edit, RSVP and
|
||||
/// broadcast for player-scheduled gatherings.
|
||||
///
|
||||
/// The 2023-03-21 client's event API surface is <c>RecNet.Runtime/CBKANFIOBCF</c>
|
||||
/// (route literals at <c>CBKANFIOBCF.txt:161-4156</c>). Three wire types drive
|
||||
/// nearly every handler here:
|
||||
/// * <b>HPIOAGDJHDH</b> (PlayerEvent) — 17 keys, formatter
|
||||
/// <c>HPMMKFGDAEC.txt:1139-1518</c>.
|
||||
/// * <b>MDCBEPJCJPO</b> (PlayerEvent + <c>Tags:[{Tag,Type}]</c>) — formatter
|
||||
/// <c>CENEMCMGDKG.txt:1191-1586</c>, tag entry keys
|
||||
/// <c>PCMLHLIBLNJ.txt:191-218</c>.
|
||||
/// * <b>PHHAKLPGNGC</b> (<c>{PlayerEvent,Result,TagModifyResult}</c>) —
|
||||
/// formatter <c>EEAAGIJHLOJ.txt:267-326</c>, <c>TagModifyResult</c> keys
|
||||
/// <c>ALJPHDEAHBK.txt:191-218</c>. Every v2 create/edit/delete/field
|
||||
/// mutation deserialises this wrapper, so a flat event body leaves the
|
||||
/// client's <c>PlayerEvent</c> null after a save.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
@@ -22,14 +37,23 @@ public class PlayerEventsController(
|
||||
{
|
||||
private long Me => this.RequireCurrentPlayerId();
|
||||
|
||||
// ── Browse (v2) ──────────────────────────────────────────────────────
|
||||
/// <summary>PlayerEventResponseType.NotGoing — the one RSVP state that
|
||||
/// does not count towards AttendeeCount.</summary>
|
||||
private const int NotGoingResponse = 2;
|
||||
|
||||
/// <summary>GET <c>api/playerevents/v2</c> — upcoming events list.
|
||||
/// Returns the next 50 events ordered by start time. Wire shape
|
||||
/// is the standard PlayerEvent (Id, CreatorPlayerId, RoomId,
|
||||
/// Title, Description, StartsAt, EndsAt, Capacity).</summary>
|
||||
// ── Browse ───────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>GET <c>api/playerevents/v2</c> (and <c>v1</c>) — upcoming
|
||||
/// events list, a BARE JSON array of PlayerEvent.
|
||||
///
|
||||
/// The 2023 watch's Events tab reads the unversioned path: HIHEFBMPOGC
|
||||
/// returns <c>List<HPIOAGDJHDH></c> and dispatches with verb 0
|
||||
/// (<c>CBKANFIOBCF.txt:1305</c>, route literal <c>:1304</c>). Only
|
||||
/// <c>[HttpPost]</c> was registered on <c>api/playerevents/v1</c>, so the
|
||||
/// whole tab 405'd.</summary>
|
||||
[AllowAnonymous]
|
||||
[HttpGet("api/playerevents/v2")]
|
||||
[HttpGet("api/playerevents/v1")]
|
||||
public async Task<ActionResult> ListUpcoming(
|
||||
[FromQuery] int skip = 0, [FromQuery] int take = 50)
|
||||
{
|
||||
@@ -41,9 +65,17 @@ public class PlayerEventsController(
|
||||
.Skip(skip)
|
||||
.Take(take)
|
||||
.ToListAsync();
|
||||
return Ok(rows.Select(ToWire));
|
||||
return Ok(await ToWireManyAsync(rows));
|
||||
}
|
||||
|
||||
/// <summary>GET <c>api/playerevents/v1|v2/{id}</c>. The client calls this
|
||||
/// three ways — plain (NLEFILEAMIP → HPIOAGDJHDH) and with
|
||||
/// <c>?includeDetails=</c> / <c>?clubId=</c> (HLJBCGMONEH / OAKKDBLNKLG →
|
||||
/// MDCBEPJCJPO, query keys at
|
||||
/// <c>CBKANFIOBCF_NestedType_DEIBMMABHIK.txt:86,99</c>). Both DTOs are the
|
||||
/// same object, the detail one just adds <c>Tags</c>, and the Utf8Json
|
||||
/// formatters skip unknown keys — so we always emit the superset and no
|
||||
/// branch is needed.</summary>
|
||||
[AllowAnonymous]
|
||||
[HttpGet("api/playerevents/v2/{eventId:long}")]
|
||||
[HttpGet("api/playerevents/v1/{eventId:long}")]
|
||||
@@ -51,23 +83,28 @@ public class PlayerEventsController(
|
||||
{
|
||||
var ev = await db.PlayerEvents.FirstOrDefaultAsync(e => e.Id == eventId);
|
||||
if (ev is null) return NotFound();
|
||||
return Ok(ToWire(ev));
|
||||
return Ok(await ToWireOneAsync(ev));
|
||||
}
|
||||
|
||||
/// <summary>GET <c>api/playerevents/v1/search</c>. Query keys are
|
||||
/// <c>query</c>, <c>sort</c> and <c>scheduleFilter</c>
|
||||
/// (<c>CBKANFIOBCF_NestedType_IONCOJJGHNJ.txt:99,113,126</c>); the latter
|
||||
/// two are enum ordinals (PJBLEKMMACM / BJBLPLKMLBE).</summary>
|
||||
[AllowAnonymous]
|
||||
[HttpGet("api/playerevents/v1/search")]
|
||||
[HttpGet("api/playerevents/v1/searchlive")]
|
||||
public async Task<ActionResult> Search(
|
||||
[FromQuery] string? q = null,
|
||||
[FromQuery] string? query = null,
|
||||
[FromQuery] int? sort = null,
|
||||
[FromQuery] int? scheduleFilter = null,
|
||||
[FromQuery] int skip = 0,
|
||||
[FromQuery] int take = 50)
|
||||
{
|
||||
var needle = (q ?? query ?? string.Empty).Trim().ToLowerInvariant();
|
||||
skip = Math.Max(0, skip);
|
||||
take = Math.Clamp(take, 1, 100);
|
||||
var rows = db.PlayerEvents
|
||||
.Where(e => e.EndsAt > DateTime.UtcNow);
|
||||
|
||||
var rows = ApplyScheduleFilter(db.PlayerEvents.AsQueryable(), scheduleFilter);
|
||||
if (needle.Length > 0)
|
||||
{
|
||||
rows = rows.Where(e =>
|
||||
@@ -75,12 +112,70 @@ public class PlayerEventsController(
|
||||
e.Description.ToLower().Contains(needle));
|
||||
}
|
||||
|
||||
var result = await rows
|
||||
.OrderBy(e => e.StartsAt)
|
||||
var result = await ApplySort(rows, sort)
|
||||
.Skip(skip)
|
||||
.Take(take)
|
||||
.ToListAsync();
|
||||
return Ok(result.Select(ToWire));
|
||||
return Ok(await ToWireManyAsync(result));
|
||||
}
|
||||
|
||||
/// <summary>GET <c>api/playerevents/v1/searchlive</c> — "happening now".
|
||||
/// Separate handler from <see cref="Search"/> because the response element
|
||||
/// type differs: FIKHCEPDIBF returns <c>List<CLDDIKOJMAM></c>, which
|
||||
/// is the PlayerEvent object plus <c>PlayerCount</c> (Int32) and
|
||||
/// <c>IsFull</c> (Boolean) — formatter <c>PDIFGFKAMBG.txt:1267,1294</c>.
|
||||
/// Head-count comes from the room's live GameSessions rows (their RoomId is
|
||||
/// the numeric room id as a string, see
|
||||
/// <c>Services/GameSessionService.cs:91-96</c>).</summary>
|
||||
[AllowAnonymous]
|
||||
[HttpGet("api/playerevents/v1/searchlive")]
|
||||
public async Task<ActionResult> SearchLive(
|
||||
[FromQuery] string? q = null,
|
||||
[FromQuery] string? query = null,
|
||||
[FromQuery] int take = 50)
|
||||
{
|
||||
var needle = (q ?? query ?? string.Empty).Trim().ToLowerInvariant();
|
||||
take = Math.Clamp(take, 1, 100);
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
var live = db.PlayerEvents.Where(e => e.StartsAt <= now && e.EndsAt > now);
|
||||
if (needle.Length > 0)
|
||||
{
|
||||
live = live.Where(e =>
|
||||
e.Title.ToLower().Contains(needle) ||
|
||||
e.Description.ToLower().Contains(needle));
|
||||
}
|
||||
|
||||
var rows = await live.OrderBy(e => e.StartsAt).Take(take).ToListAsync();
|
||||
var wire = await ToWireManyAsync(rows);
|
||||
|
||||
var roomKeys = rows.Select(r => r.RoomId.ToString()).Distinct().ToList();
|
||||
var sessions = await db.GameSessions
|
||||
.Where(s => roomKeys.Contains(s.RoomId))
|
||||
.GroupBy(s => s.RoomId)
|
||||
.Select(g => new
|
||||
{
|
||||
RoomId = g.Key,
|
||||
Players = g.Sum(s => s.PlayerCount),
|
||||
Capacity = g.Sum(s => s.MaxCapacity),
|
||||
})
|
||||
.ToListAsync();
|
||||
var byRoom = sessions.ToDictionary(s => s.RoomId, s => s);
|
||||
|
||||
for (var i = 0; i < rows.Count; i++)
|
||||
{
|
||||
var players = 0;
|
||||
var capacity = 0;
|
||||
if (byRoom.TryGetValue(rows[i].RoomId.ToString(), out var agg))
|
||||
{
|
||||
players = agg.Players;
|
||||
capacity = agg.Capacity;
|
||||
}
|
||||
wire[i]["PlayerCount"] = players;
|
||||
wire[i]["IsFull"] = capacity > 0 && players >= capacity;
|
||||
}
|
||||
|
||||
return Ok(wire);
|
||||
}
|
||||
|
||||
[AllowAnonymous]
|
||||
@@ -92,53 +187,53 @@ public class PlayerEventsController(
|
||||
.OrderBy(e => e.StartsAt)
|
||||
.Take(100)
|
||||
.ToListAsync();
|
||||
return Ok(rows.Select(ToWire));
|
||||
return Ok(await ToWireManyAsync(rows));
|
||||
}
|
||||
|
||||
/// <summary>GET <c>api/playerevents/v1/club/{clubId}</c>. PHNEDJAPIKI
|
||||
/// returns IOKLNPFOLGI — an OBJECT
|
||||
/// <c>{Events:[PlayerEvent],ContinuationToken:String}</c>
|
||||
/// (<c>GHBIGGJKPFI.txt:203-238</c>), not a bare array, and passes
|
||||
/// <c>take</c> / <c>continuationToken</c> query params
|
||||
/// (<c>CBKANFIOBCF_NestedType_PKDDOCBDBIM.txt:80,89</c>). The token here is
|
||||
/// just the next skip offset; empty string means "no more pages" (the
|
||||
/// client's field is a plain String, so never emit null).</summary>
|
||||
[AllowAnonymous]
|
||||
[HttpGet("api/playerevents/v1/club/{clubId:long}")]
|
||||
public async Task<ActionResult> ForClub(long clubId)
|
||||
public async Task<ActionResult> ForClub(
|
||||
long clubId,
|
||||
[FromQuery] int? take = null,
|
||||
[FromQuery] string? continuationToken = null)
|
||||
{
|
||||
var memberIds = await db.ClubMemberships
|
||||
.Where(m => m.ClubId == clubId)
|
||||
.Select(m => m.PlayerId)
|
||||
.ToListAsync();
|
||||
if (memberIds.Count == 0) return Ok(Array.Empty<object>());
|
||||
var pageSize = Math.Clamp(take ?? 50, 1, 100);
|
||||
var skip = int.TryParse(continuationToken, out var parsed) && parsed > 0 ? parsed : 0;
|
||||
|
||||
var roomIds = await db.Rooms
|
||||
.Where(r => memberIds.Contains(r.CreatorPlayerId))
|
||||
.Select(r => r.Id)
|
||||
.ToListAsync();
|
||||
if (roomIds.Count == 0) return Ok(Array.Empty<object>());
|
||||
// Fetch one extra row to find out whether another page exists.
|
||||
var rows = await ClubEventsAsync(new[] { clubId }, skip, pageSize + 1);
|
||||
var hasMore = rows.Count > pageSize;
|
||||
if (hasMore) rows.RemoveAt(rows.Count - 1);
|
||||
|
||||
var rows = await db.PlayerEvents
|
||||
.Where(e => roomIds.Contains(e.RoomId) && e.EndsAt > DateTime.UtcNow)
|
||||
.OrderBy(e => e.StartsAt)
|
||||
.Take(100)
|
||||
.ToListAsync();
|
||||
return Ok(rows.Select(ToWire));
|
||||
return Ok(new
|
||||
{
|
||||
Events = await ToWireManyAsync(rows),
|
||||
ContinuationToken = hasMore ? (skip + pageSize).ToString() : string.Empty,
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>GET <c>api/playerevents/v1/clubs</c> — events for a specific
|
||||
/// set of clubs. IEGLMOOPLED appends one repeated <c>id</c> query value per
|
||||
/// club (<c>CBKANFIOBCF_NestedType_LKJFABHPIAN.txt:59</c>); ignoring them
|
||||
/// returned every club's events and the watch could not bucket the list.
|
||||
/// </summary>
|
||||
[AllowAnonymous]
|
||||
[HttpGet("api/playerevents/v1/clubs")]
|
||||
public async Task<ActionResult> ClubEvents([FromQuery] int take = 100)
|
||||
{
|
||||
take = Math.Clamp(take, 1, 200);
|
||||
var clubPlayerIds = await db.ClubMemberships
|
||||
.Select(m => m.PlayerId)
|
||||
.Distinct()
|
||||
.ToListAsync();
|
||||
if (clubPlayerIds.Count == 0) return Ok(Array.Empty<object>());
|
||||
var roomIds = await db.Rooms
|
||||
.Where(r => clubPlayerIds.Contains(r.CreatorPlayerId))
|
||||
.Select(r => r.Id)
|
||||
.ToListAsync();
|
||||
var rows = await db.PlayerEvents
|
||||
.Where(e => roomIds.Contains(e.RoomId) && e.EndsAt > DateTime.UtcNow)
|
||||
.OrderBy(e => e.StartsAt)
|
||||
.Take(take)
|
||||
.ToListAsync();
|
||||
return Ok(rows.Select(ToWire));
|
||||
var clubIds = new List<long>();
|
||||
foreach (var value in Request.Query["id"]) AddDelimitedIds(clubIds, value);
|
||||
var rows = await ClubEventsAsync(clubIds.Distinct().ToList(), 0, take);
|
||||
return Ok(await ToWireManyAsync(rows));
|
||||
}
|
||||
|
||||
public sealed class EventBulkRequest
|
||||
@@ -148,6 +243,10 @@ public class PlayerEventsController(
|
||||
public List<long>? Ids { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>POST <c>api/playerevents/v1/bulk</c> — verb 2 at
|
||||
/// <c>CBKANFIOBCF.txt:660</c>; the form field is <c>Ids</c>
|
||||
/// (<c>CBKANFIOBCF_NestedType_PIKPJPBHOAC.txt:64</c>). GET is kept for the
|
||||
/// 2020 client, which puts the ids in the query string.</summary>
|
||||
[AllowAnonymous]
|
||||
[HttpPost("api/playerevents/v1/bulk")]
|
||||
[HttpGet("api/playerevents/v1/bulk")]
|
||||
@@ -158,21 +257,29 @@ public class PlayerEventsController(
|
||||
var rows = await db.PlayerEvents
|
||||
.Where(e => ids.Contains(e.Id))
|
||||
.ToListAsync();
|
||||
return Ok(rows.Select(ToWire));
|
||||
return Ok(await ToWireManyAsync(rows));
|
||||
}
|
||||
|
||||
/// <summary>GET <c>api/playerevents/v1/tagfilters</c> — AKCLLEJNFFD reads
|
||||
/// three String lists: <c>PinnedFilters</c>, <c>PopularFilters</c> and
|
||||
/// <c>TrendingFilters</c> (<c>HMDONHHKKGA.txt:279-346</c>). A missing key
|
||||
/// leaves the list null and the chips UI NREs when it enumerates.</summary>
|
||||
[AllowAnonymous]
|
||||
[HttpGet("api/playerevents/v1/tagfilters")]
|
||||
public IActionResult TagFilters() => Ok(new
|
||||
{
|
||||
PinnedFilters = new[] { "game", "social", "competition", "class", "club" },
|
||||
PopularFilters = new[] { "game", "social", "competition", "class", "club" },
|
||||
TrendingFilters = new[] { "game", "social", "competition", "class", "club" },
|
||||
});
|
||||
|
||||
/// <summary>GET <c>api/playerevents/v1/all</c> — the watch's
|
||||
/// <c>LocalPlayerEventInfo</c> fetch (caller's created + RSVP'd
|
||||
/// events). Response keys (both required, via
|
||||
/// <c>Util.GetObjectListKey</c>): <c>Created</c>, <c>Responses</c>.</summary>
|
||||
/// <c>LocalPlayerEventInfo</c> fetch (caller's created + RSVP'd events).
|
||||
/// PCELKKHNPHJ is <c>{Created:[PlayerEvent], Responses:[COAGAJPELCG]}</c>
|
||||
/// (<c>ENJDEONHNMO.txt:191-218</c>) where each response entry is
|
||||
/// <c>{PlayerEvent, PlayerEventResponse}</c> (<c>HJKLIIPPPEM.txt:215-258</c>)
|
||||
/// — a flat response row leaves <c>PlayerEvent</c> null and the My Events
|
||||
/// page NREs.</summary>
|
||||
[AllowAnonymous]
|
||||
[HttpGet("api/playerevents/v1/all")]
|
||||
public async Task<ActionResult> AllPlayerEvents()
|
||||
@@ -180,99 +287,477 @@ public class PlayerEventsController(
|
||||
var pid = this.CurrentPlayerId();
|
||||
if (pid is not long me)
|
||||
return Ok(new { Created = Array.Empty<object>(), Responses = Array.Empty<object>() });
|
||||
|
||||
var created = await db.PlayerEvents
|
||||
.Where(e => e.CreatorPlayerId == me)
|
||||
.OrderByDescending(e => e.StartsAt)
|
||||
.Select(e => new
|
||||
{
|
||||
e.Id,
|
||||
e.Title,
|
||||
e.Description,
|
||||
e.RoomId,
|
||||
e.StartsAt,
|
||||
e.EndsAt,
|
||||
e.Capacity,
|
||||
})
|
||||
.ToListAsync();
|
||||
|
||||
var responses = await db.PlayerEventResponses
|
||||
.Where(r => r.PlayerId == me)
|
||||
.Select(r => new
|
||||
{
|
||||
r.Id,
|
||||
r.EventId,
|
||||
r.Response,
|
||||
r.CreatedAt,
|
||||
})
|
||||
.ToListAsync();
|
||||
|
||||
return Ok(new { Created = created, Responses = responses });
|
||||
return await AllForPlayerAsync(me);
|
||||
}
|
||||
|
||||
[AllowAnonymous]
|
||||
[HttpGet("api/playerevents/v1/all/{accountId:long}")]
|
||||
public async Task<ActionResult> AllPlayerEventsForAccount(long accountId)
|
||||
public Task<ActionResult> AllPlayerEventsForAccount(long accountId)
|
||||
=> AllForPlayerAsync(accountId);
|
||||
|
||||
private async Task<ActionResult> AllForPlayerAsync(long playerId)
|
||||
{
|
||||
var created = await db.PlayerEvents
|
||||
.Where(e => e.CreatorPlayerId == accountId)
|
||||
.Where(e => e.CreatorPlayerId == playerId)
|
||||
.OrderByDescending(e => e.StartsAt)
|
||||
.Select(e => new
|
||||
{
|
||||
e.Id,
|
||||
e.Title,
|
||||
e.Description,
|
||||
e.RoomId,
|
||||
e.StartsAt,
|
||||
e.EndsAt,
|
||||
e.Capacity,
|
||||
})
|
||||
.Take(200)
|
||||
.ToListAsync();
|
||||
|
||||
var responses = await db.PlayerEventResponses
|
||||
.Where(r => r.PlayerId == accountId)
|
||||
.Select(r => new
|
||||
{
|
||||
r.Id,
|
||||
r.EventId,
|
||||
r.Response,
|
||||
r.CreatedAt,
|
||||
})
|
||||
.Where(r => r.PlayerId == playerId)
|
||||
.OrderByDescending(r => r.CreatedAt)
|
||||
.Take(200)
|
||||
.ToListAsync();
|
||||
|
||||
return Ok(new { Created = created, Responses = responses });
|
||||
// Each response entry embeds its own event, which may not be one the
|
||||
// caller created — pull those in too so PlayerEvent is never null.
|
||||
var createdIds = created.Select(e => e.Id).ToHashSet();
|
||||
var missingIds = responses.Select(r => r.EventId).Where(id => !createdIds.Contains(id)).Distinct().ToList();
|
||||
var extraEvents = new List<PlayerEventEntity>();
|
||||
if (missingIds.Count > 0)
|
||||
extraEvents = await db.PlayerEvents.Where(e => missingIds.Contains(e.Id)).ToListAsync();
|
||||
|
||||
var all = created.Concat(extraEvents).ToList();
|
||||
var wire = await ToWireMapAsync(all);
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
Created = created.Select(e => wire[e.Id]).ToList(),
|
||||
Responses = responses
|
||||
.Where(r => wire.ContainsKey(r.EventId))
|
||||
.Select(r => new
|
||||
{
|
||||
PlayerEvent = wire[r.EventId],
|
||||
PlayerEventResponse = ResponseWire(r),
|
||||
})
|
||||
.ToList(),
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>POST <c>api/playerevents/v1/{eventId}/responses</c> —
|
||||
/// alternate RSVP route the watch uses for the response-list
|
||||
/// flow (older / sub-room view). Body is the same RsvpRequest
|
||||
/// as <c>v1/{id}/rsvp</c>; behaves identically.</summary>
|
||||
[HttpPost("api/playerevents/v1/{eventId:long}/responses")]
|
||||
public Task<ActionResult> RespondList(long eventId, [FromBody] RsvpRequest body)
|
||||
=> Rsvp(eventId, body);
|
||||
|
||||
/// <summary>Wire shape verified against
|
||||
/// <c>Cpp2IL_ISIL/.../RecNet/PlayerEvent.txt:769-815</c>:
|
||||
/// PascalCase keys <c>PlayerEventId, Name, Description,
|
||||
/// StartTime, EndTime, CreatorPlayerId, AttendeeCount, RoomId,
|
||||
/// ImageName, Accessibility</c>. Server-side we store these
|
||||
/// under different names (Title, StartsAt, EndsAt, Capacity) —
|
||||
/// remap explicitly here so the watch's
|
||||
/// <c>PlayerEvent.Deserialize</c> picks them up.</summary>
|
||||
private static object ToWire(PlayerEventEntity ev) => new
|
||||
/// <summary>GET <c>api/playerevents/v1/{eventId}/responses</c> — the event
|
||||
/// detail page's attendee list. DFABBIDLDPE dispatches with verb 0
|
||||
/// (<c>CBKANFIOBCF.txt:1699</c>, route literal <c>:1689</c>) and reads
|
||||
/// <c>List<JPIKCIGABBI></c>; the path was registered POST-only, so
|
||||
/// the list 405'd.</summary>
|
||||
[AllowAnonymous]
|
||||
[HttpGet("api/playerevents/v1/{eventId:long}/responses")]
|
||||
public async Task<ActionResult> ResponsesForEvent(long eventId)
|
||||
{
|
||||
PlayerEventId = ev.Id,
|
||||
Name = ev.Title,
|
||||
ev.Description,
|
||||
StartTime = ev.StartsAt,
|
||||
EndTime = ev.EndsAt,
|
||||
CreatorPlayerId = (int)ev.CreatorPlayerId,
|
||||
AttendeeCount = 0, // computed at query time when needed
|
||||
ev.RoomId,
|
||||
ImageName = string.Empty,
|
||||
Accessibility = 1, // Public
|
||||
var rows = await db.PlayerEventResponses
|
||||
.Where(r => r.EventId == eventId)
|
||||
.OrderBy(r => r.CreatedAt)
|
||||
.Take(500)
|
||||
.ToListAsync();
|
||||
return Ok(rows.Select(ResponseWire).ToList());
|
||||
}
|
||||
|
||||
// ── Wire shape ───────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>The PlayerEvent fields the 2023 client carries that
|
||||
/// <see cref="PlayerEventEntity"/> has no column for. Adding columns needs
|
||||
/// a migration in <c>Data/Entities</c>, so these live in the PlayerSettings
|
||||
/// side-table: one JSON row per event under
|
||||
/// <c>playerevent:{id}:wire</c>, plus a flat <c>playerevent:{id}:club</c>
|
||||
/// row that keeps club lookups a single indexed query.
|
||||
///
|
||||
/// Public (not private) purely so System.Text.Json's reflection-based
|
||||
/// (de)serialiser can see the type and its accessors.</summary>
|
||||
public sealed class EventExtras
|
||||
{
|
||||
public long? SubRoomId { get; set; }
|
||||
public long? ClubId { get; set; }
|
||||
public string ImageName { get; set; } = string.Empty;
|
||||
/// <summary>BAMLHAODDOG ordinal. The enum's member order is not
|
||||
/// recoverable from the ISIL (enums carry no method bodies), so events
|
||||
/// that never had accessibility set keep the historical default of 1
|
||||
/// rather than guessing a new one; once the client PUTs a value we
|
||||
/// echo exactly what it sent.</summary>
|
||||
public int Accessibility { get; set; } = 1;
|
||||
public bool IsMultiInstance { get; set; }
|
||||
public bool SupportMultiInstanceRoomChat { get; set; }
|
||||
public int DefaultBroadcastPermissions { get; set; }
|
||||
public int CanRequestBroadcastPermissions { get; set; }
|
||||
public long? BroadcastingRoomInstanceId { get; set; }
|
||||
public List<string> Tags { get; set; } = [];
|
||||
}
|
||||
|
||||
private static string ExtrasKey(long eventId) => $"playerevent:{eventId}:wire";
|
||||
private static string ClubKey(long eventId) => $"playerevent:{eventId}:club";
|
||||
|
||||
/// <summary>Wire shape verified against the client's Utf8Json formatter
|
||||
/// <c>HPMMKFGDAEC.txt:1139-1518</c> (and the details variant
|
||||
/// <c>CENEMCMGDKG.txt:1191-1586</c>): PascalCase keys <c>PlayerEventId,
|
||||
/// CreatorPlayerId, RoomId, SubRoomId, ClubId, Name, Description,
|
||||
/// ImageName, StartTime, EndTime, AttendeeCount, Accessibility,
|
||||
/// IsMultiInstance, SupportMultiInstanceRoomChat,
|
||||
/// DefaultBroadcastPermissions, CanRequestBroadcastPermissions,
|
||||
/// BroadcastingRoomInstanceId</c> — property types read off the getters in
|
||||
/// <c>HPIOAGDJHDH.txt</c>. Server-side we store some of these under other
|
||||
/// names (Title, StartsAt, EndsAt), so remap explicitly.</summary>
|
||||
private static Dictionary<string, object?> ToWire(
|
||||
PlayerEventEntity ev, EventExtras extras, int attendeeCount) => new()
|
||||
{
|
||||
["PlayerEventId"] = ev.Id,
|
||||
["CreatorPlayerId"] = (int)ev.CreatorPlayerId,
|
||||
["RoomId"] = ev.RoomId,
|
||||
["SubRoomId"] = extras.SubRoomId,
|
||||
["ClubId"] = extras.ClubId,
|
||||
["Name"] = ev.Title,
|
||||
["Description"] = ev.Description,
|
||||
["ImageName"] = extras.ImageName,
|
||||
["StartTime"] = ev.StartsAt,
|
||||
["EndTime"] = ev.EndsAt,
|
||||
["AttendeeCount"] = attendeeCount,
|
||||
["Accessibility"] = extras.Accessibility,
|
||||
["IsMultiInstance"] = extras.IsMultiInstance,
|
||||
["SupportMultiInstanceRoomChat"] = extras.SupportMultiInstanceRoomChat,
|
||||
["DefaultBroadcastPermissions"] = extras.DefaultBroadcastPermissions,
|
||||
["CanRequestBroadcastPermissions"] = extras.CanRequestBroadcastPermissions,
|
||||
["BroadcastingRoomInstanceId"] = extras.BroadcastingRoomInstanceId,
|
||||
// MDCBEPJCJPO (the includeDetails DTO) is this same object plus
|
||||
// Tags:[{Tag,Type}] (entry keys PCMLHLIBLNJ.txt:191-218). The tagless
|
||||
// formatter skips unknown keys, so emitting Tags unconditionally is
|
||||
// inert there and saves branching on includeDetails. Type is the
|
||||
// KIKIEHKBHNM ordinal — we only ever store free-form tags, so 0.
|
||||
["Tags"] = extras.Tags.Select(t => new { Tag = t, Type = 0 }).ToList(),
|
||||
};
|
||||
|
||||
/// <summary>JPIKCIGABBI — <c>{PlayerEventResponseId, PlayerEventId,
|
||||
/// PlayerId(Int32), CreatedAt, Type}</c>, keys at
|
||||
/// <c>DDDLCGDEPNH.txt:395-502</c>, types off the getters in
|
||||
/// <c>JPIKCIGABBI.txt</c>.</summary>
|
||||
private static object ResponseWire(PlayerEventResponseEntity r) => new
|
||||
{
|
||||
PlayerEventResponseId = r.Id,
|
||||
PlayerEventId = r.EventId,
|
||||
PlayerId = (int)r.PlayerId,
|
||||
r.CreatedAt,
|
||||
Type = r.Response,
|
||||
};
|
||||
|
||||
/// <summary>PHHAKLPGNGC — the wrapper every v2 mutation deserialises.
|
||||
/// </summary>
|
||||
private static object Wrapper(PlayerEventEntity ev, EventExtras extras, int attendeeCount) => new
|
||||
{
|
||||
PlayerEvent = ToWire(ev, extras, attendeeCount),
|
||||
Result = 0, // BNCFHOOCHAI success
|
||||
TagModifyResult = new { Result = 0, Tags = extras.Tags },
|
||||
};
|
||||
|
||||
private async Task<object> WrapperAsync(PlayerEventEntity ev)
|
||||
{
|
||||
var extras = (await LoadExtrasAsync(new[] { ev }))[ev.Id];
|
||||
var counts = await AttendeeCountsAsync(new[] { ev.Id });
|
||||
return Wrapper(ev, extras, counts.GetValueOrDefault(ev.Id));
|
||||
}
|
||||
|
||||
private async Task<List<Dictionary<string, object?>>> ToWireManyAsync(IReadOnlyList<PlayerEventEntity> rows)
|
||||
{
|
||||
if (rows.Count == 0) return [];
|
||||
var extras = await LoadExtrasAsync(rows);
|
||||
var counts = await AttendeeCountsAsync(rows.Select(r => r.Id).ToList());
|
||||
return rows
|
||||
.Select(r => ToWire(r, extras[r.Id], counts.GetValueOrDefault(r.Id)))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private async Task<Dictionary<long, Dictionary<string, object?>>> ToWireMapAsync(
|
||||
IReadOnlyList<PlayerEventEntity> rows)
|
||||
{
|
||||
var wire = await ToWireManyAsync(rows);
|
||||
var map = new Dictionary<long, Dictionary<string, object?>>();
|
||||
for (var i = 0; i < rows.Count; i++) map[rows[i].Id] = wire[i];
|
||||
return map;
|
||||
}
|
||||
|
||||
private async Task<Dictionary<string, object?>> ToWireOneAsync(PlayerEventEntity ev)
|
||||
=> (await ToWireManyAsync(new[] { ev }))[0];
|
||||
|
||||
private async Task<Dictionary<long, EventExtras>> LoadExtrasAsync(IReadOnlyCollection<PlayerEventEntity> events)
|
||||
{
|
||||
var map = new Dictionary<long, EventExtras>();
|
||||
foreach (var ev in events) map[ev.Id] = new EventExtras();
|
||||
if (map.Count == 0) return map;
|
||||
|
||||
var keys = map.Keys.Select(ExtrasKey).ToList();
|
||||
var rows = await db.PlayerSettings
|
||||
.AsNoTracking()
|
||||
.Where(s => keys.Contains(s.Key))
|
||||
.Select(s => new { s.Key, s.Value })
|
||||
.ToListAsync();
|
||||
|
||||
foreach (var row in rows)
|
||||
{
|
||||
if (EventIdFromKey(row.Key) is not long id || !map.ContainsKey(id)) continue;
|
||||
var parsed = DeserializeExtras(row.Value);
|
||||
if (parsed is not null) map[id] = parsed;
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
private static long? EventIdFromKey(string key)
|
||||
{
|
||||
var parts = key.Split(':');
|
||||
return parts.Length >= 3 && long.TryParse(parts[1], out var id) ? id : null;
|
||||
}
|
||||
|
||||
private static EventExtras? DeserializeExtras(string json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json)) return null;
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<EventExtras>(json);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SaveExtrasAsync(PlayerEventEntity evt, EventExtras extras)
|
||||
{
|
||||
// PlayerSettingEntity.Value is MaxLength(1024); bound the tag list so a
|
||||
// long one can never truncate the JSON blob into unparseable garbage.
|
||||
if (extras.Tags.Count > 12) extras.Tags = extras.Tags.Take(12).ToList();
|
||||
extras.Tags = extras.Tags.Select(t => Clamp(t, 32)).ToList();
|
||||
|
||||
await UpsertSettingAsync(evt.CreatorPlayerId, ExtrasKey(evt.Id),
|
||||
JsonSerializer.Serialize(extras));
|
||||
// Flat index row so "events for club X" stays one indexed lookup
|
||||
// instead of a scan through the JSON blobs.
|
||||
await UpsertSettingAsync(evt.CreatorPlayerId, ClubKey(evt.Id),
|
||||
extras.ClubId?.ToString(CultureInfo.InvariantCulture) ?? string.Empty);
|
||||
|
||||
evt.UpdatedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
private async Task UpsertSettingAsync(long playerId, string key, string value)
|
||||
{
|
||||
var row = await db.PlayerSettings
|
||||
.FirstOrDefaultAsync(s => s.PlayerId == playerId && s.Key == key);
|
||||
if (row is null)
|
||||
{
|
||||
db.PlayerSettings.Add(new PlayerSettingEntity
|
||||
{
|
||||
PlayerId = playerId,
|
||||
Key = key,
|
||||
Value = value,
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
row.Value = value;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<Dictionary<long, int>> AttendeeCountsAsync(IReadOnlyCollection<long> eventIds)
|
||||
{
|
||||
if (eventIds.Count == 0) return new Dictionary<long, int>();
|
||||
var ids = eventIds.ToList();
|
||||
var rows = await db.PlayerEventResponses
|
||||
.Where(r => ids.Contains(r.EventId) && r.Response != NotGoingResponse)
|
||||
.GroupBy(r => r.EventId)
|
||||
.Select(g => new { EventId = g.Key, Count = g.Count() })
|
||||
.ToListAsync();
|
||||
return rows.ToDictionary(r => r.EventId, r => r.Count);
|
||||
}
|
||||
|
||||
// ── Query shaping ────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>'scheduleFilter' (BJBLPLKMLBE) is an enum ordinal. Only the
|
||||
/// query-key literal survives in the ISIL — enums have no method bodies to
|
||||
/// disassemble — so the member order below is UNVERIFIED. Ordinal 0 (and
|
||||
/// absent) reproduces the previous behaviour exactly, and a wrong guess for
|
||||
/// the others still returns a valid, non-empty event list rather than an
|
||||
/// error.</summary>
|
||||
private static IQueryable<PlayerEventEntity> ApplyScheduleFilter(
|
||||
IQueryable<PlayerEventEntity> rows, int? scheduleFilter)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
return scheduleFilter switch
|
||||
{
|
||||
1 => rows.Where(e => e.StartsAt <= now && e.EndsAt > now),
|
||||
2 => rows.Where(e => e.EndsAt > now && e.StartsAt < now.AddDays(1)),
|
||||
3 => rows.Where(e => e.EndsAt > now && e.StartsAt < now.AddDays(7)),
|
||||
_ => rows.Where(e => e.EndsAt > now),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>'sort' (PJBLEKMMACM) — same UNVERIFIED-ordinal caveat as
|
||||
/// <see cref="ApplyScheduleFilter"/>; 0/absent keeps soonest-first.</summary>
|
||||
private static IQueryable<PlayerEventEntity> ApplySort(
|
||||
IQueryable<PlayerEventEntity> rows, int? sort) => sort switch
|
||||
{
|
||||
1 => rows.OrderByDescending(e => e.CreatedAt),
|
||||
2 => rows.OrderBy(e => e.EndsAt),
|
||||
_ => rows.OrderBy(e => e.StartsAt),
|
||||
};
|
||||
|
||||
/// <summary>Events attached to any of <paramref name="clubIds"/>. Explicit
|
||||
/// ClubId (persisted since the v2 create/edit body carries it) wins; when
|
||||
/// nothing carries one we fall back to the historical heuristic of "events
|
||||
/// in rooms owned by club members" so pre-existing data still populates the
|
||||
/// tab.</summary>
|
||||
private async Task<List<PlayerEventEntity>> ClubEventsAsync(
|
||||
IReadOnlyCollection<long> clubIds, int skip, int take)
|
||||
{
|
||||
var explicitIds = await EventIdsForClubsAsync(clubIds);
|
||||
IQueryable<PlayerEventEntity> q;
|
||||
if (explicitIds.Count > 0)
|
||||
{
|
||||
q = db.PlayerEvents.Where(e => explicitIds.Contains(e.Id) && e.EndsAt > DateTime.UtcNow);
|
||||
}
|
||||
else
|
||||
{
|
||||
var roomIds = await ClubRoomIdsAsync(clubIds);
|
||||
if (roomIds.Count == 0) return [];
|
||||
q = db.PlayerEvents.Where(e => roomIds.Contains(e.RoomId) && e.EndsAt > DateTime.UtcNow);
|
||||
}
|
||||
|
||||
return await q.OrderBy(e => e.StartsAt).Skip(skip).Take(take).ToListAsync();
|
||||
}
|
||||
|
||||
private async Task<List<long>> EventIdsForClubsAsync(IReadOnlyCollection<long> clubIds)
|
||||
{
|
||||
if (clubIds.Count == 0) return [];
|
||||
var wanted = clubIds.Select(c => c.ToString(CultureInfo.InvariantCulture)).ToList();
|
||||
var keys = await db.PlayerSettings
|
||||
.AsNoTracking()
|
||||
.Where(s => wanted.Contains(s.Value)
|
||||
&& s.Key.StartsWith("playerevent:")
|
||||
&& s.Key.EndsWith(":club"))
|
||||
.Select(s => s.Key)
|
||||
.ToListAsync();
|
||||
|
||||
return keys
|
||||
.Select(EventIdFromKey)
|
||||
.Where(id => id is not null)
|
||||
.Select(id => id!.Value)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private async Task<List<long>> ClubRoomIdsAsync(IReadOnlyCollection<long> clubIds)
|
||||
{
|
||||
var memberships = db.ClubMemberships.AsQueryable();
|
||||
if (clubIds.Count > 0)
|
||||
{
|
||||
var ids = clubIds.ToList();
|
||||
memberships = memberships.Where(m => ids.Contains(m.ClubId));
|
||||
}
|
||||
|
||||
var memberIds = await memberships.Select(m => m.PlayerId).Distinct().ToListAsync();
|
||||
if (memberIds.Count == 0) return [];
|
||||
return await db.Rooms
|
||||
.Where(r => memberIds.Contains(r.CreatorPlayerId))
|
||||
.Select(r => r.Id)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
// ── Create / edit (v2) ───────────────────────────────────────────────
|
||||
|
||||
/// <summary>POST <c>api/playerevents/v2</c> — the 2023 create-event flow.
|
||||
/// KKMMBCDGFFN posts BIEFKAOABMP as a RawJsonForm body (verb 2 at
|
||||
/// <c>CBKANFIOBCF.txt:2390</c>) whose 14 keys are
|
||||
/// <c>RoomId, SubRoomId, ClubId, Name, Description, Tags, ImageName,
|
||||
/// StartTime, EndTime, Accessibility, IsMultiInstance,
|
||||
/// SupportMultiInstanceRoomChat, DefaultBroadcastPermissions,
|
||||
/// CanRequestBroadcastPermissions</c> (<c>ACKNGEEHHAE.txt:935-1234</c>),
|
||||
/// and reads the PHHAKLPGNGC wrapper back. Only <c>[HttpGet]</c> was
|
||||
/// registered on this path, so creating an event 405'd.</summary>
|
||||
[HttpPost("api/playerevents/v2")]
|
||||
public async Task<IActionResult> CreateV2()
|
||||
{
|
||||
var name = (await ReadStringFieldAsync("Name", "name", "title"))?.Trim() ?? string.Empty;
|
||||
if (name.Length == 0) return BadRequest("missing_name");
|
||||
|
||||
var start = await ReadDateFieldAsync("StartTime", "startTime", "startsAt") ?? DateTime.UtcNow;
|
||||
var end = await ReadDateFieldAsync("EndTime", "endTime", "endsAt") ?? start.AddHours(1);
|
||||
var description = (await ReadStringFieldAsync("Description", "description"))?.Trim() ?? string.Empty;
|
||||
|
||||
var evt = new PlayerEventEntity
|
||||
{
|
||||
CreatorPlayerId = Me,
|
||||
RoomId = await ReadLongFieldAsync("RoomId", "roomId") ?? 0,
|
||||
Title = Clamp(name, 128),
|
||||
Description = Clamp(description, 2000),
|
||||
StartsAt = start,
|
||||
EndsAt = end > start ? end : start.AddHours(1),
|
||||
};
|
||||
db.PlayerEvents.Add(evt);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var extras = new EventExtras();
|
||||
await ApplyBodyToExtrasAsync(extras);
|
||||
await SaveExtrasAsync(evt, extras);
|
||||
return Ok(await WrapperAsync(evt));
|
||||
}
|
||||
|
||||
/// <summary>POST <c>api/playerevents/v2/{eventId}</c> — edit-event save.
|
||||
/// IOEJKIPJFOI posts the same BIEFKAOABMP body with verb 2
|
||||
/// (<c>CBKANFIOBCF.txt:2595</c>, route literal <c>:2579</c>); the path was
|
||||
/// GET-only, so editing 405'd.</summary>
|
||||
[HttpPost("api/playerevents/v2/{eventId:long}")]
|
||||
public async Task<IActionResult> EditV2(long eventId)
|
||||
{
|
||||
var evt = await GetOwnedEventAsync(eventId);
|
||||
if (evt is null) return NotFound();
|
||||
|
||||
var name = (await ReadStringFieldAsync("Name", "name", "title"))?.Trim();
|
||||
if (!string.IsNullOrEmpty(name)) evt.Title = Clamp(name, 128);
|
||||
|
||||
var description = (await ReadStringFieldAsync("Description", "description"))?.Trim();
|
||||
if (description is not null) evt.Description = Clamp(description, 2000);
|
||||
|
||||
var roomId = await ReadLongFieldAsync("RoomId", "roomId");
|
||||
if (roomId is long room && room > 0) evt.RoomId = room;
|
||||
|
||||
var start = await ReadDateFieldAsync("StartTime", "startTime", "startsAt");
|
||||
var end = await ReadDateFieldAsync("EndTime", "endTime", "endsAt");
|
||||
if (start is DateTime s) evt.StartsAt = s;
|
||||
if (end is DateTime e) evt.EndsAt = e > evt.StartsAt ? e : evt.StartsAt.AddHours(1);
|
||||
|
||||
var extras = (await LoadExtrasAsync(new[] { evt }))[evt.Id];
|
||||
await ApplyBodyToExtrasAsync(extras);
|
||||
await SaveExtrasAsync(evt, extras);
|
||||
await NotifyAttendeesAsync(evt, PushNotificationId.PlayerEventUpdated);
|
||||
return Ok(await WrapperAsync(evt));
|
||||
}
|
||||
|
||||
/// <summary>Copies whatever subset of the BIEFKAOABMP body is present onto
|
||||
/// <paramref name="extras"/>. Absent keys are left alone so the same helper
|
||||
/// serves both create and edit.</summary>
|
||||
private async Task ApplyBodyToExtrasAsync(EventExtras extras)
|
||||
{
|
||||
var fields = await ReadBodyAsync();
|
||||
|
||||
if (fields.Fields.ContainsKey("SubRoomId"))
|
||||
extras.SubRoomId = await ReadLongFieldAsync("SubRoomId", "subRoomId");
|
||||
if (fields.Fields.ContainsKey("ClubId"))
|
||||
extras.ClubId = await ReadLongFieldAsync("ClubId", "clubId");
|
||||
|
||||
var imageName = await ReadStringFieldAsync("ImageName", "imageName");
|
||||
if (imageName is not null) extras.ImageName = Clamp(imageName.Trim(), 128);
|
||||
|
||||
if (await ReadIntFieldAsync("Accessibility", "accessibility") is int accessibility)
|
||||
extras.Accessibility = accessibility;
|
||||
if (await ReadBoolFieldAsync("IsMultiInstance", "isMultiInstance") is bool multi)
|
||||
extras.IsMultiInstance = multi;
|
||||
if (await ReadBoolFieldAsync("SupportMultiInstanceRoomChat", "supportMultiInstanceRoomChat",
|
||||
"supportsMultiInstanceRoomChat") is bool chat)
|
||||
extras.SupportMultiInstanceRoomChat = chat;
|
||||
if (await ReadIntFieldAsync("DefaultBroadcastPermissions", "defaultBroadcastPermissions") is int def)
|
||||
extras.DefaultBroadcastPermissions = def;
|
||||
if (await ReadIntFieldAsync("CanRequestBroadcastPermissions", "canRequestBroadcastPermissions") is int req)
|
||||
extras.CanRequestBroadcastPermissions = req;
|
||||
|
||||
var tags = await ReadStringListFieldAsync("Tags", "tags");
|
||||
if (tags is not null) extras.Tags = tags;
|
||||
}
|
||||
|
||||
// ── Create / RSVP (v1, shared with the 2020 client) ──────────────────
|
||||
|
||||
public sealed record CreateEventRequest(
|
||||
string Title, string? Description, long RoomId,
|
||||
DateTime StartsAt, DateTime EndsAt, int Capacity);
|
||||
@@ -295,11 +780,7 @@ public class PlayerEventsController(
|
||||
};
|
||||
db.PlayerEvents.Add(ev);
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new
|
||||
{
|
||||
ev.Id, ev.Title, ev.Description, ev.RoomId,
|
||||
ev.StartsAt, ev.EndsAt, ev.Capacity,
|
||||
});
|
||||
return Ok(await ToWireOneAsync(ev));
|
||||
}
|
||||
|
||||
public sealed record RsvpRequest(int Response);
|
||||
@@ -328,25 +809,45 @@ public class PlayerEventsController(
|
||||
await notifications.NotifyAsync(ev.CreatorPlayerId,
|
||||
PushNotificationId.PlayerEventResponseChanged,
|
||||
new { ev.Id, From = Me, body.Response });
|
||||
// Left as the historical shape on purpose: this route is 2020-only
|
||||
// (the 2023 client GETs .../responses and POSTs v1/respond instead),
|
||||
// so there is no 2023 DTO to match here.
|
||||
return Ok(new { row.EventId, row.PlayerId, row.Response });
|
||||
}
|
||||
|
||||
/// <summary>POST <c>api/playerevents/v1/respond</c> — set the
|
||||
/// caller's RSVP state for an event. <c>Response</c> is the
|
||||
/// <c>PlayerEventResponseType</c> enum (0=Going, 1=Maybe, 2=Pass).
|
||||
/// Idempotent — re-responding overwrites.
|
||||
///
|
||||
/// Wire return is a BARE int (<c>DGOPHENCPOC</c> enum value:
|
||||
/// 0=Success, plus various error codes). Returning an object
|
||||
/// here throws InvalidCastException on the 2020.12 deserialiser
|
||||
/// — verified at <c>recroom-2020-client-response-contracts.md</c>
|
||||
/// under the playerevents/v1/respond section.</summary>
|
||||
/// <summary>POST <c>api/playerevents/v1/{eventId}/responses</c> —
|
||||
/// alternate RSVP route the 2020 watch uses for the response-list
|
||||
/// flow (older / sub-room view). Body is the same RsvpRequest
|
||||
/// as <c>v1/{id}/rsvp</c>; behaves identically. The 2023 client GETs this
|
||||
/// path instead — see <see cref="ResponsesForEvent"/>.</summary>
|
||||
[HttpPost("api/playerevents/v1/{eventId:long}/responses")]
|
||||
public Task<ActionResult> RespondList(long eventId, [FromBody] RsvpRequest body)
|
||||
=> Rsvp(eventId, body);
|
||||
|
||||
/// <summary>POST <c>api/playerevents/v1/respond</c> — set the caller's RSVP
|
||||
/// state. Two client generations share this route with different
|
||||
/// contracts:
|
||||
/// * 2020.12 posts a form and reads a BARE int (DGOPHENCPOC); returning
|
||||
/// an object throws InvalidCastException there — see
|
||||
/// <c>recroom-2020-client-response-contracts.md</c>.
|
||||
/// * 2023-03 posts a RawJsonForm body <c>{PlayerEventId, Type}</c>
|
||||
/// (NKIFBKJALEJ, keys at <c>GFHNJMMBHFD.txt:203-238</c>; body build at
|
||||
/// <c>CBKANFIOBCF_NestedType_NFCEOKLHDAG.txt:81-112</c>) and reads
|
||||
/// CEKABGOIOAF, an OBJECT <c>{Result:int}</c>
|
||||
/// (<c>GJJGDENPOAP.txt:139-150</c>).
|
||||
/// Branch the response shape on the body encoding the caller actually
|
||||
/// used — that is the only signal that distinguishes them.</summary>
|
||||
[HttpPost("api/playerevents/v1/respond")]
|
||||
public async Task<IActionResult> RespondForm(
|
||||
[FromForm(Name = "EventId")] long? eventId,
|
||||
[FromForm(Name = "Response")] int response = 0)
|
||||
public async Task<IActionResult> RespondForm()
|
||||
{
|
||||
if (eventId is not long evt || evt <= 0) return Ok(2 /*NoSuchEvent*/);
|
||||
var legacyForm = Request.HasFormContentType;
|
||||
var eventId = await ReadLongFieldAsync("PlayerEventId", "playerEventId", "EventId", "eventId");
|
||||
if (eventId is not long evt || evt <= 0) return RespondResult(legacyForm, 2 /*NoSuchEvent*/);
|
||||
|
||||
var response = await ReadIntFieldAsync("Type", "type", "Response", "response") ?? 0;
|
||||
if (!await db.PlayerEvents.AnyAsync(e => e.Id == evt))
|
||||
return RespondResult(legacyForm, 2 /*NoSuchEvent*/);
|
||||
|
||||
var existing = await db.PlayerEventResponses
|
||||
.FirstOrDefaultAsync(r => r.PlayerId == Me && r.EventId == evt);
|
||||
if (existing is null)
|
||||
@@ -354,35 +855,65 @@ public class PlayerEventsController(
|
||||
else
|
||||
existing.Response = response;
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(0 /*Success*/);
|
||||
return RespondResult(legacyForm, 0 /*Success*/);
|
||||
}
|
||||
|
||||
/// <summary>POST <c>api/playerevents/v1/deleteResponse</c> — drop
|
||||
/// the caller's RSVP row. Same bare-int wire shape as
|
||||
/// <see cref="RespondForm"/>.</summary>
|
||||
/// <summary>POST <c>api/playerevents/v1/deleteResponse</c> — drop the
|
||||
/// caller's RSVP row. Same two-generation split as
|
||||
/// <see cref="RespondForm"/>. The 2023 body is
|
||||
/// <c>JsonUtility.ToJson(RecNet.Events.DeleteResponseRequest)</c>
|
||||
/// (<c>CBKANFIOBCF_NestedType_MDJMHFHPOBO.txt:65-78</c>) whose single field
|
||||
/// is name-preserved: <c>public long PlayerEventId</c>
|
||||
/// (2023.06.21 <c>dump.cs:1291301</c>).</summary>
|
||||
[HttpPost("api/playerevents/v1/deleteResponse")]
|
||||
public async Task<IActionResult> DeleteResponseForm([FromForm(Name = "EventId")] long? eventId)
|
||||
public async Task<IActionResult> DeleteResponseForm()
|
||||
{
|
||||
if (eventId is not long evt) return Ok(2 /*NoSuchEvent*/);
|
||||
var legacyForm = Request.HasFormContentType;
|
||||
var eventId = await ReadLongFieldAsync("PlayerEventId", "playerEventId", "EventId", "eventId");
|
||||
if (eventId is not long evt) return RespondResult(legacyForm, 2 /*NoSuchEvent*/);
|
||||
|
||||
await db.PlayerEventResponses
|
||||
.Where(r => r.PlayerId == Me && r.EventId == evt)
|
||||
.ExecuteDeleteAsync();
|
||||
return Ok(0 /*Success*/);
|
||||
return RespondResult(legacyForm, 0 /*Success*/);
|
||||
}
|
||||
|
||||
/// <summary>DELETE <c>api/playerevents/v2/delete/{id}</c> — hard
|
||||
/// delete an event the caller created.</summary>
|
||||
private IActionResult RespondResult(bool legacyForm, int code)
|
||||
=> legacyForm ? Ok(code) : Ok(new { Result = code });
|
||||
|
||||
/// <summary>DELETE/POST <c>api/playerevents/v2/delete/{id}</c> — hard
|
||||
/// delete an event the caller created. GJMCEDALKJO dispatches with verb 2
|
||||
/// (<c>CBKANFIOBCF.txt:4166</c>, route literal <c>:4156</c>) and reads the
|
||||
/// PHHAKLPGNGC wrapper, so POST plus the wrapper body are both required;
|
||||
/// DELETE stays for the 2020 client.</summary>
|
||||
[HttpPost("api/playerevents/v2/delete/{id:long}")]
|
||||
[HttpDelete("api/playerevents/v2/delete/{id:long}")]
|
||||
public async Task<IActionResult> DeleteByPath(long id)
|
||||
{
|
||||
var evt = await db.PlayerEvents.FirstOrDefaultAsync(e => e.Id == id && e.CreatorPlayerId == Me);
|
||||
if (evt is null) return NotFound();
|
||||
|
||||
// Snapshot the wire shape before the rows go away — the wrapper still
|
||||
// has to echo the event that was deleted.
|
||||
var extras = (await LoadExtrasAsync(new[] { evt }))[id];
|
||||
var attendees = (await AttendeeCountsAsync(new[] { id })).GetValueOrDefault(id);
|
||||
|
||||
db.PlayerEvents.Remove(evt);
|
||||
await db.PlayerEventResponses.Where(r => r.EventId == id).ExecuteDeleteAsync();
|
||||
await db.PlayerSettings
|
||||
.Where(s => s.Key == ExtrasKey(id) || s.Key == ClubKey(id))
|
||||
.ExecuteDeleteAsync();
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new { success = true, error = "" });
|
||||
return Ok(Wrapper(evt, extras, attendees));
|
||||
}
|
||||
|
||||
// ── Field mutations (v2) ─────────────────────────────────────────────
|
||||
// Every one of these is a PUT (verb 3, e.g. CBKANFIOBCF.txt:2744) whose
|
||||
// fields arrive as x-www-form-urlencoded, and every one returns the
|
||||
// PHHAKLPGNGC wrapper — a flat event body leaves the client's PlayerEvent
|
||||
// null right after a successful save. POST is kept alongside for the 2020
|
||||
// client.
|
||||
|
||||
[HttpPost("api/playerevents/v2/{eventId:long}/name")]
|
||||
[HttpPut("api/playerevents/v2/{eventId:long}/name")]
|
||||
public async Task<IActionResult> UpdateName(long eventId)
|
||||
@@ -391,10 +922,10 @@ public class PlayerEventsController(
|
||||
if (evt is null) return NotFound();
|
||||
var value = await ReadStringFieldAsync("name", "Name", "value");
|
||||
if (string.IsNullOrWhiteSpace(value)) return BadRequest("missing_name");
|
||||
evt.Title = value.Trim()[..Math.Min(value.Trim().Length, 128)];
|
||||
evt.Title = Clamp(value.Trim(), 128);
|
||||
evt.UpdatedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(ToWire(evt));
|
||||
return Ok(await WrapperAsync(evt));
|
||||
}
|
||||
|
||||
[HttpPost("api/playerevents/v2/{eventId:long}/description")]
|
||||
@@ -404,12 +935,17 @@ public class PlayerEventsController(
|
||||
var evt = await GetOwnedEventAsync(eventId);
|
||||
if (evt is null) return NotFound();
|
||||
var value = await ReadStringFieldAsync("description", "Description", "value") ?? string.Empty;
|
||||
evt.Description = value.Trim()[..Math.Min(value.Trim().Length, 2000)];
|
||||
evt.Description = Clamp(value.Trim(), 2000);
|
||||
evt.UpdatedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(ToWire(evt));
|
||||
return Ok(await WrapperAsync(evt));
|
||||
}
|
||||
|
||||
/// <summary>PUT <c>api/playerevents/v2/{id}/room</c> — FPPNHCMPAKK sends
|
||||
/// <c>roomId</c> AND an optional <c>subRoomId</c>
|
||||
/// (<c>CBKANFIOBCF_NestedType_AKPPDNLCACI.txt:88,101</c>); dropping the
|
||||
/// latter meant the event never pointed at the sub-room the host picked.
|
||||
/// </summary>
|
||||
[HttpPost("api/playerevents/v2/{eventId:long}/room")]
|
||||
[HttpPut("api/playerevents/v2/{eventId:long}/room")]
|
||||
public async Task<IActionResult> UpdateRoom(long eventId)
|
||||
@@ -420,9 +956,11 @@ public class PlayerEventsController(
|
||||
if (roomId is not long id || id <= 0) return BadRequest("missing_room");
|
||||
if (!await db.Rooms.AnyAsync(r => r.Id == id)) return NotFound("room_not_found");
|
||||
evt.RoomId = id;
|
||||
evt.UpdatedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(ToWire(evt));
|
||||
|
||||
var extras = (await LoadExtrasAsync(new[] { evt }))[evt.Id];
|
||||
extras.SubRoomId = await ReadLongFieldAsync("subRoomId", "SubRoomId");
|
||||
await SaveExtrasAsync(evt, extras);
|
||||
return Ok(await WrapperAsync(evt));
|
||||
}
|
||||
|
||||
[HttpPost("api/playerevents/v2/{eventId:long}/time")]
|
||||
@@ -438,7 +976,7 @@ public class PlayerEventsController(
|
||||
if (start is null && end is null) return BadRequest("missing_time");
|
||||
evt.UpdatedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(ToWire(evt));
|
||||
return Ok(await WrapperAsync(evt));
|
||||
}
|
||||
|
||||
[HttpPost("api/playerevents/v2/{eventId:long}/image")]
|
||||
@@ -448,8 +986,10 @@ public class PlayerEventsController(
|
||||
var evt = await GetOwnedEventAsync(eventId);
|
||||
if (evt is null) return NotFound();
|
||||
var value = await ReadStringFieldAsync("imageName", "ImageName", "value") ?? string.Empty;
|
||||
await SetEventSettingAsync(evt, "image", value.Trim());
|
||||
return Ok(ToWire(evt));
|
||||
var extras = (await LoadExtrasAsync(new[] { evt }))[evt.Id];
|
||||
extras.ImageName = Clamp(value.Trim(), 128);
|
||||
await SaveExtrasAsync(evt, extras);
|
||||
return Ok(await WrapperAsync(evt));
|
||||
}
|
||||
|
||||
[HttpPost("api/playerevents/v2/{eventId:long}/accessibility")]
|
||||
@@ -460,44 +1000,92 @@ public class PlayerEventsController(
|
||||
if (evt is null) return NotFound();
|
||||
var value = await ReadIntFieldAsync("accessibility", "Accessibility", "value");
|
||||
if (value is not int accessibility) return BadRequest("missing_accessibility");
|
||||
await SetEventSettingAsync(evt, "accessibility", accessibility.ToString());
|
||||
return Ok(ToWire(evt));
|
||||
var extras = (await LoadExtrasAsync(new[] { evt }))[evt.Id];
|
||||
extras.Accessibility = accessibility;
|
||||
await SaveExtrasAsync(evt, extras);
|
||||
return Ok(await WrapperAsync(evt));
|
||||
}
|
||||
|
||||
/// <summary>PUT <c>api/playerevents/v2/{id}/tags</c>. JHLMDMGELFD does NOT
|
||||
/// send form fields — it serialises <c>List<String></c> straight into
|
||||
/// a RawJsonForm body, i.e. a BARE JSON ARRAY
|
||||
/// (<c>CBKANFIOBCF_NestedType_PNFGKCKCGEL.txt:63-81</c>). The old field
|
||||
/// reader only looked inside JSON objects, so every save stored an empty
|
||||
/// string and silently cleared the tags. The wrapper's
|
||||
/// <c>TagModifyResult.Tags</c> is what the client re-reads afterwards.
|
||||
/// </summary>
|
||||
[HttpPost("api/playerevents/v2/{eventId:long}/tags")]
|
||||
[HttpPut("api/playerevents/v2/{eventId:long}/tags")]
|
||||
public async Task<IActionResult> UpdateTags(long eventId)
|
||||
{
|
||||
var evt = await GetOwnedEventAsync(eventId);
|
||||
if (evt is null) return NotFound();
|
||||
var value = await ReadStringFieldAsync("tags", "Tags", "value") ?? string.Empty;
|
||||
await SetEventSettingAsync(evt, "tags", value.Trim());
|
||||
return Ok(ToWire(evt));
|
||||
var tags = await ReadStringListFieldAsync("tags", "Tags", "value") ?? new List<string>();
|
||||
var extras = (await LoadExtrasAsync(new[] { evt }))[evt.Id];
|
||||
extras.Tags = tags;
|
||||
await SaveExtrasAsync(evt, extras);
|
||||
return Ok(await WrapperAsync(evt));
|
||||
}
|
||||
|
||||
/// <summary>PUT <c>api/playerevents/v2/{id}/multiinstance</c>. IEGDKKAKHBD
|
||||
/// sends FOUR form fields — <c>isMultiInstance</c>,
|
||||
/// <c>supportsMultiInstanceRoomChat</c>, <c>defaultBroadcastPermissions</c>,
|
||||
/// <c>canRequestBroadcastPermissions</c>
|
||||
/// (<c>CBKANFIOBCF_NestedType_EONGJBMLEFO.txt:122-161</c>). The handler used
|
||||
/// to look for a field named <c>multiInstance</c>, which the client never
|
||||
/// sends, so the toggle always stored false and the other three were
|
||||
/// dropped. Note the request spells it "supports…" while the response key
|
||||
/// is "Support…" — both spellings are accepted here.</summary>
|
||||
[HttpPost("api/playerevents/v2/{eventId:long}/multiinstance")]
|
||||
[HttpPut("api/playerevents/v2/{eventId:long}/multiinstance")]
|
||||
public async Task<IActionResult> UpdateMultiInstance(long eventId)
|
||||
{
|
||||
var evt = await GetOwnedEventAsync(eventId);
|
||||
if (evt is null) return NotFound();
|
||||
var value = await ReadBoolFieldAsync("multiInstance", "MultiInstance", "value");
|
||||
await SetEventSettingAsync(evt, "multiinstance", (value ?? false) ? "true" : "false");
|
||||
return Ok(ToWire(evt));
|
||||
var extras = (await LoadExtrasAsync(new[] { evt }))[evt.Id];
|
||||
|
||||
extras.IsMultiInstance =
|
||||
await ReadBoolFieldAsync("isMultiInstance", "IsMultiInstance", "multiInstance", "value") ?? false;
|
||||
extras.SupportMultiInstanceRoomChat =
|
||||
await ReadBoolFieldAsync("supportsMultiInstanceRoomChat", "supportMultiInstanceRoomChat",
|
||||
"SupportMultiInstanceRoomChat") ?? false;
|
||||
extras.DefaultBroadcastPermissions =
|
||||
await ReadIntFieldAsync("defaultBroadcastPermissions", "DefaultBroadcastPermissions") ?? 0;
|
||||
extras.CanRequestBroadcastPermissions =
|
||||
await ReadIntFieldAsync("canRequestBroadcastPermissions", "CanRequestBroadcastPermissions") ?? 0;
|
||||
|
||||
await SaveExtrasAsync(evt, extras);
|
||||
return Ok(await WrapperAsync(evt));
|
||||
}
|
||||
|
||||
/// <summary>PUT <c>api/playerevents/v2/{id}/club</c>. JLHJHGANFDM takes a
|
||||
/// <c>Nullable<Int64></c> and boxes it into the single form field
|
||||
/// <c>clubId</c> (<c>CBKANFIOBCF_NestedType_DJLDOCJBFPO.txt:60-71</c>) — a
|
||||
/// null therefore arrives as an absent/empty field, which is the client's
|
||||
/// only way to DETACH the club. Rejecting that with 400 made detaching
|
||||
/// impossible.</summary>
|
||||
[HttpPost("api/playerevents/v2/{eventId:long}/club")]
|
||||
[HttpPut("api/playerevents/v2/{eventId:long}/club")]
|
||||
public async Task<IActionResult> UpdateClub(long eventId)
|
||||
{
|
||||
var evt = await GetOwnedEventAsync(eventId);
|
||||
if (evt is null) return NotFound();
|
||||
var extras = (await LoadExtrasAsync(new[] { evt }))[evt.Id];
|
||||
|
||||
var clubId = await ReadLongFieldAsync("clubId", "ClubId", "value");
|
||||
if (clubId is not long id || id <= 0) return BadRequest("missing_club");
|
||||
if (!await db.ClubMemberships.AnyAsync(m => m.ClubId == id && m.PlayerId == Me))
|
||||
return Forbid();
|
||||
await SetEventSettingAsync(evt, "club", id.ToString());
|
||||
return Ok(ToWire(evt));
|
||||
if (clubId is long id && id > 0)
|
||||
{
|
||||
if (!await db.ClubMemberships.AnyAsync(m => m.ClubId == id && m.PlayerId == Me))
|
||||
return Forbid();
|
||||
extras.ClubId = id;
|
||||
}
|
||||
else
|
||||
{
|
||||
extras.ClubId = null;
|
||||
}
|
||||
|
||||
await SaveExtrasAsync(evt, extras);
|
||||
return Ok(await WrapperAsync(evt));
|
||||
}
|
||||
|
||||
[HttpDelete("api/playerevents/v1/{eventId:long}")]
|
||||
@@ -512,10 +1100,13 @@ public class PlayerEventsController(
|
||||
var responses = db.PlayerEventResponses.Where(r => r.EventId == eventId);
|
||||
db.PlayerEventResponses.RemoveRange(responses);
|
||||
await db.SaveChangesAsync();
|
||||
await db.PlayerSettings
|
||||
.Where(s => s.Key == ExtrasKey(eventId) || s.Key == ClubKey(eventId))
|
||||
.ExecuteDeleteAsync();
|
||||
return Ok();
|
||||
}
|
||||
|
||||
// ── Bulk-invite (Phase 8) ────────────────────────────────────────────
|
||||
// ── Bulk-invite ──────────────────────────────────────────────────────
|
||||
|
||||
public sealed class BulkInviteRequest
|
||||
{
|
||||
@@ -523,12 +1114,17 @@ public class PlayerEventsController(
|
||||
public List<int>? InvitedPlayerIds { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>POST <c>/api/playerevents/v1/bulkInvite</c> — wire
|
||||
/// type per agent ISIL extraction: <c>BulkInviteRequest{PlayerEventId,
|
||||
/// InvitedPlayerIds:List<int>}</c>; response
|
||||
/// <c>BulkInviteResponse{FailedInvites,Result(CreateModifyPlayerEventStatus)}</c>.
|
||||
/// We insert one MessageEntity per recipient so the watch's inbox
|
||||
/// shows the invite, and push a per-recipient notification.</summary>
|
||||
/// <summary>POST <c>/api/playerevents/v1/bulkInvite</c>. Request is
|
||||
/// <c>JsonUtility.ToJson(RecNet.Events.BulkInviteRequest)</c> —
|
||||
/// name-preserved fields <c>PlayerEventId</c> / <c>InvitedPlayerIds</c>
|
||||
/// (2023.06.21 <c>dump.cs:1288323-1288324</c>, body build at
|
||||
/// <c>CBKANFIOBCF_NestedType_MIKAHKKCIEE.txt:69-96</c>). Response is
|
||||
/// IDELKAJIOLI <c>{FailedInvites, Result}</c>
|
||||
/// (<c>GAODOHODBDE.txt:203-238</c>) where each failed entry is
|
||||
/// <c>{InvitedPlayerId, Result}</c> (<c>MOJLGDKFECO.txt:203-238</c>) — the
|
||||
/// entries used to be <c>{PlayerId, Error}</c> and deserialised as all
|
||||
/// defaults. Error paths return the same JSON shape rather than a
|
||||
/// NotFound()/Forbid() body the client cannot parse.</summary>
|
||||
[HttpPost("api/playerevents/v1/bulkInvite")]
|
||||
public async Task<IActionResult> BulkInvite([FromBody] BulkInviteRequest req)
|
||||
{
|
||||
@@ -536,8 +1132,11 @@ public class PlayerEventsController(
|
||||
return Ok(new { FailedInvites = Array.Empty<object>(), Result = 0 });
|
||||
|
||||
var ev = await db.PlayerEvents.FirstOrDefaultAsync(e => e.Id == req.PlayerEventId);
|
||||
if (ev is null) return NotFound();
|
||||
if (ev.CreatorPlayerId != Me) return Forbid();
|
||||
// BNCFHOOCHAI's member order isn't recoverable (enums have no method
|
||||
// bodies to disassemble); any non-zero code reads as "failed" on the
|
||||
// client, which is all the invite sheet branches on.
|
||||
if (ev is null) return Ok(new { FailedInvites = Array.Empty<object>(), Result = 1 });
|
||||
if (ev.CreatorPlayerId != Me) return Ok(new { FailedInvites = Array.Empty<object>(), Result = 1 });
|
||||
|
||||
var failed = new List<object>();
|
||||
var sender = Me;
|
||||
@@ -547,7 +1146,7 @@ public class PlayerEventsController(
|
||||
var exists = await db.Players.AnyAsync(p => p.Id == rid);
|
||||
if (!exists)
|
||||
{
|
||||
failed.Add(new { PlayerId = rid, Error = "unknown player" });
|
||||
failed.Add(new { InvitedPlayerId = rid, Result = 1 });
|
||||
continue;
|
||||
}
|
||||
db.Messages.Add(new MessageEntity
|
||||
@@ -564,45 +1163,47 @@ public class PlayerEventsController(
|
||||
return Ok(new { FailedInvites = failed, Result = 0 });
|
||||
}
|
||||
|
||||
public sealed class BroadcastRequest
|
||||
/// <summary>POST <c>api/playerevents/v1/broadcast</c> — set or clear the
|
||||
/// room instance an event is being broadcast from. JBHPMONELAO posts
|
||||
/// <c>RecNet.Events.BroadcastRoomInstanceRequest</c>
|
||||
/// (<c>CBKANFIOBCF_NestedType_EHHMGKFGGCC.txt:81</c>) whose JSON keys are
|
||||
/// <c>PlayerEventId</c> and the nullable <c>BroadcastRoomInstanceId</c>
|
||||
/// (<c>EOFEKNIHNNC.txt:215-258</c>), and reads the PHHAKLPGNGC wrapper.
|
||||
/// This used to be implemented as a text-message blast to every RSVP,
|
||||
/// which both spammed attendees and returned an unparseable body.</summary>
|
||||
[HttpPost("api/playerevents/v1/broadcast")]
|
||||
public async Task<IActionResult> Broadcast()
|
||||
{
|
||||
public long PlayerEventId { get; set; }
|
||||
public string? Message { get; set; }
|
||||
var eventId = await ReadLongFieldAsync("PlayerEventId", "playerEventId", "EventId", "eventId");
|
||||
if (eventId is not long id) return BadRequest("missing_event");
|
||||
var evt = await db.PlayerEvents.FirstOrDefaultAsync(e => e.Id == id);
|
||||
if (evt is null) return NotFound();
|
||||
if (evt.CreatorPlayerId != Me) return Forbid();
|
||||
|
||||
var extras = (await LoadExtrasAsync(new[] { evt }))[evt.Id];
|
||||
extras.BroadcastingRoomInstanceId = await ReadLongFieldAsync(
|
||||
"BroadcastRoomInstanceId", "broadcastRoomInstanceId",
|
||||
"BroadcastingRoomInstanceId", "broadcastingRoomInstanceId");
|
||||
await SaveExtrasAsync(evt, extras);
|
||||
|
||||
// Attendees' event cards key off BroadcastingRoomInstanceId, so nudge
|
||||
// them to re-fetch instead of sending a chat message.
|
||||
await NotifyAttendeesAsync(evt, PushNotificationId.PlayerEventStateChanged);
|
||||
return Ok(await WrapperAsync(evt));
|
||||
}
|
||||
|
||||
[HttpPost("api/playerevents/v1/broadcast")]
|
||||
public async Task<IActionResult> Broadcast([FromBody] BroadcastRequest req)
|
||||
private async Task NotifyAttendeesAsync(PlayerEventEntity evt, PushNotificationId id)
|
||||
{
|
||||
var ev = await db.PlayerEvents.FirstOrDefaultAsync(e => e.Id == req.PlayerEventId);
|
||||
if (ev is null) return NotFound();
|
||||
if (ev.CreatorPlayerId != Me) return Forbid();
|
||||
|
||||
var recipients = await db.PlayerEventResponses
|
||||
.Where(r => r.EventId == ev.Id && r.Response != 2)
|
||||
.Where(r => r.EventId == evt.Id && r.Response != NotGoingResponse)
|
||||
.Select(r => r.PlayerId)
|
||||
.Distinct()
|
||||
.ToListAsync();
|
||||
var body = string.IsNullOrWhiteSpace(req.Message)
|
||||
? $"Update for '{ev.Title}'"
|
||||
: req.Message.Trim();
|
||||
foreach (var playerId in recipients.Where(id => id != Me))
|
||||
{
|
||||
db.Messages.Add(new MessageEntity
|
||||
{
|
||||
SenderPlayerId = Me,
|
||||
RecipientPlayerId = playerId,
|
||||
Body = body,
|
||||
});
|
||||
await notifications.NotifyAsync(playerId,
|
||||
PushNotificationId.PlayerEventResponseChanged,
|
||||
new { ev.Id, ev.Title, Message = body });
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new { Success = true, Sent = recipients.Count });
|
||||
foreach (var playerId in recipients.Where(p => p != evt.CreatorPlayerId))
|
||||
await notifications.NotifyAsync(playerId, id, new { PlayerEventId = evt.Id, evt.Title });
|
||||
}
|
||||
|
||||
// ── Report (Phase 8) ─────────────────────────────────────────────────
|
||||
// ── Report ───────────────────────────────────────────────────────────
|
||||
|
||||
public sealed class PlayerEventReportRequest
|
||||
{
|
||||
@@ -611,174 +1212,6 @@ public class PlayerEventsController(
|
||||
public string? Details { get; set; }
|
||||
}
|
||||
|
||||
private async Task<PlayerEventEntity?> GetOwnedEventAsync(long eventId)
|
||||
=> await db.PlayerEvents.FirstOrDefaultAsync(e => e.Id == eventId && e.CreatorPlayerId == Me);
|
||||
|
||||
private async Task<List<long>> ReadEventIdsAsync()
|
||||
{
|
||||
var ids = new List<long>();
|
||||
|
||||
foreach (var value in Request.Query.SelectMany(q => q.Value))
|
||||
AddDelimitedIds(ids, value);
|
||||
|
||||
if (Request.HasFormContentType)
|
||||
{
|
||||
var form = await Request.ReadFormAsync();
|
||||
foreach (var key in new[] { "playerEventIds", "PlayerEventIds", "eventIds", "EventIds", "ids", "Ids" })
|
||||
foreach (var value in form[key])
|
||||
AddDelimitedIds(ids, value);
|
||||
}
|
||||
else if ((Request.ContentLength ?? 0) > 0
|
||||
&& Request.ContentType?.Contains("json", StringComparison.OrdinalIgnoreCase) == true)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var doc = await JsonDocument.ParseAsync(Request.Body);
|
||||
if (doc.RootElement.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
foreach (var key in new[] { "playerEventIds", "PlayerEventIds", "eventIds", "EventIds", "ids", "Ids" })
|
||||
{
|
||||
if (doc.RootElement.TryGetProperty(key, out var prop))
|
||||
AddJsonIds(ids, prop);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
AddJsonIds(ids, doc.RootElement);
|
||||
}
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
return ids.Distinct().Take(200).ToList();
|
||||
}
|
||||
|
||||
private static void AddDelimitedIds(List<long> ids, string? value)
|
||||
{
|
||||
foreach (var part in (value ?? string.Empty)
|
||||
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||||
{
|
||||
if (long.TryParse(part, out var id) && id > 0) ids.Add(id);
|
||||
}
|
||||
}
|
||||
|
||||
private static void AddJsonIds(List<long> ids, JsonElement element)
|
||||
{
|
||||
switch (element.ValueKind)
|
||||
{
|
||||
case JsonValueKind.Array:
|
||||
foreach (var item in element.EnumerateArray())
|
||||
AddJsonIds(ids, item);
|
||||
break;
|
||||
case JsonValueKind.Number:
|
||||
if (element.TryGetInt64(out var id) && id > 0) ids.Add(id);
|
||||
break;
|
||||
case JsonValueKind.String:
|
||||
AddDelimitedIds(ids, element.GetString());
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<string?> ReadStringFieldAsync(params string[] names)
|
||||
{
|
||||
var fields = await ReadRequestFieldsAsync();
|
||||
foreach (var name in names)
|
||||
if (fields.TryGetValue(name, out var value))
|
||||
return value;
|
||||
return null;
|
||||
}
|
||||
|
||||
private async Task<long?> ReadLongFieldAsync(params string[] names)
|
||||
{
|
||||
var value = await ReadStringFieldAsync(names);
|
||||
return long.TryParse(value, out var parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
private async Task<int?> ReadIntFieldAsync(params string[] names)
|
||||
{
|
||||
var value = await ReadStringFieldAsync(names);
|
||||
return int.TryParse(value, out var parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
private async Task<bool?> ReadBoolFieldAsync(params string[] names)
|
||||
{
|
||||
var value = await ReadStringFieldAsync(names);
|
||||
return bool.TryParse(value, out var parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
private async Task<DateTime?> ReadDateFieldAsync(params string[] names)
|
||||
{
|
||||
var value = await ReadStringFieldAsync(names);
|
||||
return DateTime.TryParse(value, out var parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
private async Task<Dictionary<string, string>> ReadRequestFieldsAsync()
|
||||
{
|
||||
const string itemKey = "__playerevent_fields";
|
||||
if (HttpContext.Items.TryGetValue(itemKey, out var cached)
|
||||
&& cached is Dictionary<string, string> existing)
|
||||
{
|
||||
return existing;
|
||||
}
|
||||
|
||||
var fields = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var pair in Request.Query)
|
||||
fields[pair.Key] = pair.Value.FirstOrDefault() ?? string.Empty;
|
||||
|
||||
if (Request.HasFormContentType)
|
||||
{
|
||||
var form = await Request.ReadFormAsync();
|
||||
foreach (var pair in form)
|
||||
fields[pair.Key] = pair.Value.FirstOrDefault() ?? string.Empty;
|
||||
}
|
||||
else if ((Request.ContentLength ?? 0) > 0
|
||||
&& Request.ContentType?.Contains("json", StringComparison.OrdinalIgnoreCase) == true)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var doc = await JsonDocument.ParseAsync(Request.Body);
|
||||
if (doc.RootElement.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
foreach (var prop in doc.RootElement.EnumerateObject())
|
||||
fields[prop.Name] = prop.Value.ValueKind == JsonValueKind.String
|
||||
? prop.Value.GetString() ?? string.Empty
|
||||
: prop.Value.GetRawText();
|
||||
}
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
HttpContext.Items[itemKey] = fields;
|
||||
return fields;
|
||||
}
|
||||
|
||||
private async Task SetEventSettingAsync(PlayerEventEntity evt, string key, string value)
|
||||
{
|
||||
var settingKey = $"playerevent:{evt.Id}:{key}";
|
||||
var row = await db.PlayerSettings
|
||||
.FirstOrDefaultAsync(s => s.PlayerId == evt.CreatorPlayerId && s.Key == settingKey);
|
||||
if (row is null)
|
||||
{
|
||||
db.PlayerSettings.Add(new PlayerSettingEntity
|
||||
{
|
||||
PlayerId = evt.CreatorPlayerId,
|
||||
Key = settingKey,
|
||||
Value = value,
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
row.Value = value;
|
||||
}
|
||||
|
||||
evt.UpdatedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
/// <summary>POST <c>api/playerevents/v1/report</c> — file a
|
||||
/// moderation report against an event. Wire response is
|
||||
/// <c>KLAMKCBENEA{Success, Message}</c>; returning a bare
|
||||
@@ -800,4 +1233,213 @@ public class PlayerEventsController(
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new { Success = true, Message = string.Empty });
|
||||
}
|
||||
|
||||
// ── Request parsing ──────────────────────────────────────────────────
|
||||
|
||||
private static string Clamp(string value, int max)
|
||||
=> value.Length <= max ? value : value[..max];
|
||||
|
||||
private async Task<PlayerEventEntity?> GetOwnedEventAsync(long eventId)
|
||||
=> await db.PlayerEvents.FirstOrDefaultAsync(e => e.Id == eventId && e.CreatorPlayerId == Me);
|
||||
|
||||
private async Task<List<long>> ReadEventIdsAsync()
|
||||
{
|
||||
var ids = new List<long>();
|
||||
|
||||
foreach (var value in Request.Query.SelectMany(q => q.Value))
|
||||
AddDelimitedIds(ids, value);
|
||||
|
||||
var body = await ReadBodyAsync();
|
||||
foreach (var key in new[] { "playerEventIds", "eventIds", "ids" })
|
||||
{
|
||||
if (body.Repeated.TryGetValue(key, out var values))
|
||||
foreach (var value in values) AddDelimitedIds(ids, value);
|
||||
}
|
||||
if (body.RootArray is not null)
|
||||
foreach (var value in body.RootArray) AddDelimitedIds(ids, value);
|
||||
|
||||
return ids.Distinct().Take(200).ToList();
|
||||
}
|
||||
|
||||
private static void AddDelimitedIds(List<long> ids, string? value)
|
||||
{
|
||||
foreach (var part in (value ?? string.Empty)
|
||||
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||||
{
|
||||
if (long.TryParse(part, out var id) && id > 0) ids.Add(id);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<string?> ReadStringFieldAsync(params string[] names)
|
||||
{
|
||||
var body = await ReadBodyAsync();
|
||||
foreach (var name in names)
|
||||
if (body.Fields.TryGetValue(name, out var value))
|
||||
return value;
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>Reads a list-valued field. Handles all three encodings the
|
||||
/// client uses: a bare JSON array body (the tags PUT), a JSON array nested
|
||||
/// under a key (the v2 create/edit body's <c>Tags</c>), and repeated /
|
||||
/// comma-joined form values.</summary>
|
||||
private async Task<List<string>?> ReadStringListFieldAsync(params string[] names)
|
||||
{
|
||||
var body = await ReadBodyAsync();
|
||||
if (body.RootArray is not null) return body.RootArray;
|
||||
|
||||
foreach (var name in names)
|
||||
{
|
||||
if (body.Repeated.TryGetValue(name, out var repeated) && repeated.Count > 1)
|
||||
return repeated.Select(v => v.Trim()).Where(v => v.Length > 0).ToList();
|
||||
|
||||
if (!body.Fields.TryGetValue(name, out var raw)) continue;
|
||||
raw = raw.Trim();
|
||||
if (raw.Length == 0) return new List<string>();
|
||||
if (raw.StartsWith('['))
|
||||
{
|
||||
var parsed = ParseJsonStringArray(raw);
|
||||
if (parsed is not null) return parsed;
|
||||
}
|
||||
return raw
|
||||
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.ToList();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static List<string>? ParseJsonStringArray(string raw)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(raw);
|
||||
if (doc.RootElement.ValueKind != JsonValueKind.Array) return null;
|
||||
return ElementToStrings(doc.RootElement);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static List<string> ElementToStrings(JsonElement array)
|
||||
{
|
||||
var list = new List<string>();
|
||||
foreach (var item in array.EnumerateArray())
|
||||
{
|
||||
var value = item.ValueKind == JsonValueKind.String ? item.GetString() : item.GetRawText();
|
||||
if (!string.IsNullOrWhiteSpace(value)) list.Add(value.Trim());
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
private async Task<long?> ReadLongFieldAsync(params string[] names)
|
||||
{
|
||||
var value = await ReadStringFieldAsync(names);
|
||||
return long.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed)
|
||||
? parsed
|
||||
: null;
|
||||
}
|
||||
|
||||
private async Task<int?> ReadIntFieldAsync(params string[] names)
|
||||
{
|
||||
var value = await ReadStringFieldAsync(names);
|
||||
return int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed)
|
||||
? parsed
|
||||
: null;
|
||||
}
|
||||
|
||||
private async Task<bool?> ReadBoolFieldAsync(params string[] names)
|
||||
{
|
||||
var value = await ReadStringFieldAsync(names);
|
||||
if (bool.TryParse(value, out var parsed)) return parsed;
|
||||
// Form-encoded booleans arrive as "True"/"False" from the client's
|
||||
// boxed-bool ToString, but a JSON body may carry 1/0.
|
||||
return value switch { "1" => true, "0" => false, _ => null };
|
||||
}
|
||||
|
||||
private async Task<DateTime?> ReadDateFieldAsync(params string[] names)
|
||||
{
|
||||
var value = await ReadStringFieldAsync(names);
|
||||
// The client always sends UTC (Utf8Json ISO-8601 with a Z, or
|
||||
// DateTime.ToString() on an already-UTC value). Force the parse to land
|
||||
// on Kind=Utc so it compares correctly against DateTime.UtcNow.
|
||||
return DateTime.TryParse(value, CultureInfo.InvariantCulture,
|
||||
DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal, out var parsed)
|
||||
? parsed
|
||||
: null;
|
||||
}
|
||||
|
||||
/// <summary>Everything the request carries, read once and cached — the body
|
||||
/// stream can only be consumed a single time.</summary>
|
||||
private sealed class ParsedBody
|
||||
{
|
||||
public Dictionary<string, string> Fields { get; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
public Dictionary<string, List<string>> Repeated { get; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
/// <summary>Set when the whole body is a bare JSON array — the shape
|
||||
/// the tags PUT sends.</summary>
|
||||
public List<string>? RootArray { get; set; }
|
||||
}
|
||||
|
||||
private const string BodyItemKey = "__playerevent_body";
|
||||
|
||||
private async Task<ParsedBody> ReadBodyAsync()
|
||||
{
|
||||
if (HttpContext.Items.TryGetValue(BodyItemKey, out var cached) && cached is ParsedBody existing)
|
||||
return existing;
|
||||
|
||||
var body = new ParsedBody();
|
||||
foreach (var pair in Request.Query)
|
||||
{
|
||||
body.Fields[pair.Key] = pair.Value.FirstOrDefault() ?? string.Empty;
|
||||
body.Repeated[pair.Key] = pair.Value.Where(v => v is not null).Select(v => v!).ToList();
|
||||
}
|
||||
|
||||
if (Request.HasFormContentType)
|
||||
{
|
||||
var form = await Request.ReadFormAsync();
|
||||
foreach (var pair in form)
|
||||
{
|
||||
body.Fields[pair.Key] = pair.Value.FirstOrDefault() ?? string.Empty;
|
||||
body.Repeated[pair.Key] = pair.Value.Where(v => v is not null).Select(v => v!).ToList();
|
||||
}
|
||||
}
|
||||
else if ((Request.ContentLength ?? 0) > 0)
|
||||
{
|
||||
// Read the raw text rather than gating on Content-Type: the client's
|
||||
// RawJsonForm does set application/json, but the JSON path is also
|
||||
// the fallback for anything else that isn't a form.
|
||||
using var reader = new StreamReader(
|
||||
Request.Body, System.Text.Encoding.UTF8,
|
||||
detectEncodingFromByteOrderMarks: true, leaveOpen: true);
|
||||
var raw = await reader.ReadToEndAsync();
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(raw);
|
||||
switch (doc.RootElement.ValueKind)
|
||||
{
|
||||
case JsonValueKind.Object:
|
||||
foreach (var prop in doc.RootElement.EnumerateObject())
|
||||
{
|
||||
body.Fields[prop.Name] = prop.Value.ValueKind == JsonValueKind.String
|
||||
? prop.Value.GetString() ?? string.Empty
|
||||
: prop.Value.GetRawText();
|
||||
if (prop.Value.ValueKind == JsonValueKind.Array)
|
||||
body.Repeated[prop.Name] = ElementToStrings(prop.Value);
|
||||
}
|
||||
break;
|
||||
case JsonValueKind.Array:
|
||||
body.RootArray = ElementToStrings(doc.RootElement);
|
||||
break;
|
||||
}
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// Empty or non-JSON body → query/form values only.
|
||||
}
|
||||
}
|
||||
|
||||
HttpContext.Items[BodyItemKey] = body;
|
||||
return body;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,10 +18,16 @@ namespace DorkNet.Server.Controllers.API.RoomConsumables;
|
||||
/// LPFAFIGAIOE / HJOKFJKKEJM / IDBFKCFEAHM / PECGEJAAMHB / DPNLANKOHON):
|
||||
///
|
||||
/// desc = { RoomConsumableId, RoomId, Name, Description, ImageName,
|
||||
/// PriceAndCurrency: { Price, CurrencyId } }
|
||||
/// Price, PurchaseCurrencyId, ModifiedAt }
|
||||
/// inventory = { RoomConsumableId, AccountId, Count, ConcurrencyCode,
|
||||
/// ModifiedAt, Consumable: desc }
|
||||
///
|
||||
/// The desc price is FLAT on the way OUT even though it is NESTED on the way
|
||||
/// IN. The response formatter FCIBLPCOODP's key map is exactly the eight keys
|
||||
/// above — Price at index 5, PurchaseCurrencyId at 6, ModifiedAt at 7, and no
|
||||
/// PriceAndCurrency key at all (FCIBLPCOODP.txt:670/686/710). See ToDescWire.
|
||||
/// The inventory key map is likewise exact (CICEHLNDLDE.txt:447-562).
|
||||
///
|
||||
/// <c>Consumable</c> must never be null on an inventory row — the
|
||||
/// client's RoomConsumablesManager dereferences it unconditionally while
|
||||
/// processing the room-join inventory fetch and NREs otherwise (that NRE
|
||||
|
||||
@@ -311,7 +311,11 @@ public class RoomCurrenciesController(DorkNetDbContext db, LevelService level) :
|
||||
if (offerIds.Count > 0) query = query.Where(x => offerIds.Contains(x.o.PublicId));
|
||||
if (currencyIds.Count > 0) query = query.Where(x => currencyIds.Contains(x.c.PublicId));
|
||||
|
||||
var rows = await query.Take(200).ToListAsync();
|
||||
// The client reads an "Order" key (AALDCAANEMM.txt:550) but there is no
|
||||
// column to persist it into yet, so ToOfferWire reports 0 for every pack
|
||||
// and the client keeps the order it received them in — order by insertion
|
||||
// id so that fallback is at least stable across fetches.
|
||||
var rows = await query.OrderBy(x => x.o.Id).Take(200).ToListAsync();
|
||||
|
||||
// The response is GROUPED: List<{CurrencyId, PurchaseOffers}> (formatter
|
||||
// LKBGCOELLIN.txt:215-258 reads only those two keys). A flat offer list
|
||||
@@ -453,13 +457,13 @@ public class RoomCurrenciesController(DorkNetDbContext db, LevelService level) :
|
||||
{
|
||||
var fields = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var pair in Request.Query)
|
||||
fields[pair.Key] = pair.Value.FirstOrDefault() ?? string.Empty;
|
||||
fields[pair.Key] = Flatten(pair.Value);
|
||||
|
||||
if (Request.HasFormContentType)
|
||||
{
|
||||
var form = await Request.ReadFormAsync();
|
||||
foreach (var pair in form)
|
||||
fields[pair.Key] = pair.Value.FirstOrDefault() ?? string.Empty;
|
||||
fields[pair.Key] = Flatten(pair.Value);
|
||||
return fields;
|
||||
}
|
||||
|
||||
@@ -489,6 +493,21 @@ public class RoomCurrenciesController(DorkNetDbContext db, LevelService level) :
|
||||
return fields;
|
||||
}
|
||||
|
||||
/// <summary>Collapses a multi-valued query/form key into the comma-joined form
|
||||
/// <see cref="ReadGuidList"/>/<see cref="ReadLongList"/> already split on.
|
||||
///
|
||||
/// getPurchaseOffersBatch pushes its currency-id list through the generic batch
|
||||
/// param helper (<c>0x181F2DCD0</c> with <c>"ids"</c>,
|
||||
/// MIHKAJFMIPE_NestedType_OBLJADOFIED.txt:228-230), which is the same helper
|
||||
/// every other RecNet batch route uses to REPEAT the key once per id
|
||||
/// (<c>?ids=a&ids=b</c>). Taking only <c>FirstOrDefault()</c> silently
|
||||
/// dropped every id after the first, so a multi-currency room fetched packs for
|
||||
/// exactly one of its currencies.</summary>
|
||||
private static string Flatten(Microsoft.Extensions.Primitives.StringValues values) =>
|
||||
values.Count <= 1
|
||||
? values.FirstOrDefault() ?? string.Empty
|
||||
: string.Join(",", values.Where(v => !string.IsNullOrWhiteSpace(v)));
|
||||
|
||||
private async Task<string?> ReadJsonBodyAsync()
|
||||
{
|
||||
if ((Request.ContentLength ?? 0) <= 0) return null;
|
||||
|
||||
@@ -2409,8 +2409,31 @@ public class RoomsController(
|
||||
[HttpGet("api/rooms/v2/myrecent")]
|
||||
public IActionResult MyOtherTabs() => Ok(Array.Empty<object>());
|
||||
|
||||
/// <summary>Per-(player, room) persistence blob, base64. Lives in the
|
||||
/// generic <see cref="PlayerSettingEntity"/> key/value table — the same
|
||||
/// "no schema change needed" trick MatchPlayerController uses for its
|
||||
/// matchmaking prefs (MatchPlayerController.cs:213-217). The Value column
|
||||
/// is declared <c>MaxLength(1024)</c> but SQLite stores it as unconstrained
|
||||
/// TEXT and EF Core does not run DataAnnotations validation on SaveChanges,
|
||||
/// so oversized blobs round-trip intact.</summary>
|
||||
private static string PlayerRoomDataKey(long roomId) => $"roomplayerdata:{roomId}";
|
||||
|
||||
/// <summary>Fallback blob handed to a player who has never saved anything
|
||||
/// in this room. <c>CAE=</c> is a one-field protobuf the client parses as
|
||||
/// "empty, version 1"; a truly empty string makes the 2023 reader throw.</summary>
|
||||
private const string DefaultPlayerRoomData = "CAE=";
|
||||
|
||||
/// <summary>GET <c>rooms/{roomId}/playerdata/me</c> — the per-room player
|
||||
/// blob (CircuitsV2 "player persistence"). Client contract is
|
||||
/// <c>Task<ILKMFMCOPPO></c> whose reader requires the key
|
||||
/// <c>Data</c> (OPNPNPMKLGI.txt ISIL 034/060); the request is issued from
|
||||
/// NLDBPDCNNCF (NLDBPDCNNCF_NestedType_IGENGEIMGMJ.txt:169), so it arrives
|
||||
/// with the <c>roomserver/</c> prefix — the bare-only registration 404'd.
|
||||
/// This used to return a hardcoded <c>CAE=</c> for everyone; it now returns
|
||||
/// whatever <see cref="SavePlayerDataForMe"/> last stored.</summary>
|
||||
[HttpGet("player_room_data/{roomId:long}")]
|
||||
[HttpGet("rooms/{roomId:long}/playerdata/me")]
|
||||
[HttpGet("roomserver/rooms/{roomId:long}/playerdata/me")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> PlayerDataForMe(long roomId)
|
||||
{
|
||||
@@ -2431,9 +2454,15 @@ public class RoomsController(
|
||||
.Select(v => new { v.VisitCount, v.FirstVisitAt, v.LastVisitAt })
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
var key = PlayerRoomDataKey(roomId);
|
||||
var stored = await db.PlayerSettings.AsNoTracking()
|
||||
.Where(s => s.PlayerId == pid.Value && s.Key == key)
|
||||
.Select(s => s.Value)
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
Data = "CAE=",
|
||||
Data = string.IsNullOrEmpty(stored) ? DefaultPlayerRoomData : stored,
|
||||
RoomId = room?.Id ?? roomId,
|
||||
PlayerId = pid.Value,
|
||||
Favorite = favorited,
|
||||
@@ -2453,6 +2482,71 @@ public class RoomsController(
|
||||
});
|
||||
}
|
||||
|
||||
public sealed class PlayerRoomDataRequest { public string? Data { get; set; } }
|
||||
|
||||
/// <summary>PUT <c>rooms/{roomId}/playerdata/me</c> — persist the caller's
|
||||
/// per-room blob. Client method <c>NLDBPDCNNCF.LIHFBBCDAGO(long, string)</c>
|
||||
/// ("save player room data") moves verb ordinal 3 = PUT into r8 and posts a
|
||||
/// single form field <c>data</c>
|
||||
/// (NLDBPDCNNCF.txt ISIL 045/061/065 around :2566-2586; form key at
|
||||
/// NLDBPDCNNCF_NestedType_ANPKFGHOOAJ.txt ISIL 015). Its return type is the
|
||||
/// non-generic <c>Task</c>, so no response body is parsed.
|
||||
///
|
||||
/// <para>Only the GET was registered before, so every save 405'd and per-room
|
||||
/// player state never persisted.</para></summary>
|
||||
[HttpPut("rooms/{roomId:long}/playerdata/me")]
|
||||
[HttpPost("rooms/{roomId:long}/playerdata/me")]
|
||||
[HttpPut("roomserver/rooms/{roomId:long}/playerdata/me")]
|
||||
[HttpPost("roomserver/rooms/{roomId:long}/playerdata/me")]
|
||||
[HttpPut("player_room_data/{roomId:long}")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> SavePlayerDataForMe(
|
||||
long roomId,
|
||||
[ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))] PlayerRoomDataRequest req)
|
||||
{
|
||||
var pid = CurrentPlayerId;
|
||||
if (pid is null) return Unauthorized();
|
||||
|
||||
var data = req.Data ?? string.Empty;
|
||||
var key = PlayerRoomDataKey(roomId);
|
||||
var row = await db.PlayerSettings
|
||||
.FirstOrDefaultAsync(s => s.PlayerId == pid.Value && s.Key == key);
|
||||
if (row is null)
|
||||
{
|
||||
db.PlayerSettings.Add(new PlayerSettingEntity
|
||||
{
|
||||
PlayerId = pid.Value, Key = key, Value = data,
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
row.Value = data;
|
||||
}
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
// The client ignores the body (non-generic Task); the echo is for
|
||||
// curl/admin debugging only.
|
||||
return Ok(new { Result = 0, RoomId = roomId, PlayerId = pid.Value, Data = data });
|
||||
}
|
||||
|
||||
/// <summary>GET <c>rooms/requiring/{restriction}</c> — the restricted-rooms
|
||||
/// list. The path segment is <c>mode.ToString().ToLower()</c>
|
||||
/// (Matchmaking.txt ISIL 067-072 around :21263, concat onto the literal
|
||||
/// <c>"rooms/requiring/"</c> at :21264).
|
||||
///
|
||||
/// <para><b>Element type is String, not Int64 — verified, do not "fix" this
|
||||
/// to room ids.</b> The issuing method
|
||||
/// <c>Matchmaking.POIMPHMONON(IOCOJFJEDCM)</c> (Matchmaking.txt:21030)
|
||||
/// resolves its promise as <c>FGLDKEJLAKB<List<String>></c>
|
||||
/// (typeof load at Matchmaking.txt:21327, continuation delegate
|
||||
/// <c>Action<List<String>></c> at :21315) and hands the payload to
|
||||
/// <c><RequestRoomRestrictions>b__2(List<String> rooms)</c>
|
||||
/// (Matchmaking_NestedType_PMNEOAMKLJL.txt:14), which forwards it to
|
||||
/// <c>Matchmaking.IINEGHJJBAN(IEnumerable<String>, IOCOJFJEDCM)</c>
|
||||
/// (:20758) where the strings become the KEYS of the restriction map
|
||||
/// (<c>List<IOCOJFJEDCM></c> per key, ISIL 069-079 at :20963-20974).
|
||||
/// So a bare array of room NAMES is the correct wire shape. Failures log
|
||||
/// "Failed to get restricted rooms list: " (Matchmaking.txt:21306).</para></summary>
|
||||
[HttpGet("rooms/requiring/{restriction}")]
|
||||
[HttpGet("roomserver/rooms/requiring/{restriction}")]
|
||||
[Authorize]
|
||||
@@ -2708,19 +2802,31 @@ public class RoomsController(
|
||||
/// <para>We accept both POST and PUT because the watch's ISIL
|
||||
/// doesn't expose the explicit HTTPMethod literal at this call
|
||||
/// site — covering both is cheap and means we can't get bitten
|
||||
/// by the wrong one.</para></summary>
|
||||
/// by the wrong one.</para>
|
||||
///
|
||||
/// <para><b>2023 client:</b> <c>NLDBPDCNNCF.LHNDKOJNKBE</c>
|
||||
/// ("change room name", NLDBPDCNNCF.txt:5069) moves verb ordinal 3 = PUT,
|
||||
/// sends the lowercase form key <c>name</c>
|
||||
/// (NLDBPDCNNCF_NestedType_LMADOAEHOPP.txt ISIL 015) and returns
|
||||
/// <c>Task<FGCPNAACHIK></c> — the FULL room-details object, not a
|
||||
/// status wrapper. It also carries the <c>roomserver/</c> prefix like every
|
||||
/// other room mutation from that client
|
||||
/// (docs/recroom-2023-room-save.md:83-93), so the bare-only routes 404'd.
|
||||
/// The old signature mixed <c>[FromForm]</c> with a <c>[FromBody]</c>
|
||||
/// parameter, which under <c>[ApiController]</c> makes the form PUT 415
|
||||
/// before the handler runs — hence the shared FormOrJsonModelBinder.</para></summary>
|
||||
public sealed class RenameBody { public string? Name { get; set; } }
|
||||
|
||||
[HttpPost("rooms/{roomId:long}/name")]
|
||||
[HttpPut("rooms/{roomId:long}/name")]
|
||||
[HttpPost("roomserver/rooms/{roomId:long}/name")]
|
||||
[HttpPut("roomserver/rooms/{roomId:long}/name")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> RenameRoom(
|
||||
long roomId,
|
||||
[FromForm(Name = "Name")] string? nameForm,
|
||||
[FromForm(Name = "name")] string? nameFormLower,
|
||||
[FromBody] RenameBody? body)
|
||||
[ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))] RenameBody body)
|
||||
{
|
||||
var newName = nameForm ?? nameFormLower ?? body?.Name;
|
||||
var newName = body?.Name;
|
||||
if (string.IsNullOrWhiteSpace(newName))
|
||||
return BadRequest(new { Result = 1, error = "empty_name" });
|
||||
newName = newName.Trim();
|
||||
@@ -2748,35 +2854,24 @@ public class RoomsController(
|
||||
await db.SaveChangesAsync();
|
||||
logger.LogInformation("[room-rename] room={Room} by={By} → '{Name}'", roomId, me, newName);
|
||||
|
||||
// Return the full updated Room DTO. The watch's
|
||||
// <c>PPENFJMFPNE</c> deserialiser inherits from
|
||||
// <c>KLCOGEIGEBJ</c> (the standard Room shape) so we can
|
||||
// forward to the same v3-details payload builder by re-reading.
|
||||
// BuildRoomDetailsV3Async is too heavy here — just return the
|
||||
// minimal mutated subset; the watch UI re-fetches details
|
||||
// shortly after for the room-info screen.
|
||||
return Ok(new
|
||||
{
|
||||
Result = 0,
|
||||
RoomId = room.Id,
|
||||
Name = room.Name,
|
||||
Description = room.Description,
|
||||
ImageName = room.ImageName,
|
||||
CreatorPlayerId = room.CreatorPlayerId,
|
||||
CreatorAccountId = room.CreatorPlayerId,
|
||||
State = room.State,
|
||||
Accessibility = room.Accessibility,
|
||||
IsAGRoom = room.IsAGRoom,
|
||||
IsRRO = false,
|
||||
IsDormRoom = room.IsDormRoom,
|
||||
});
|
||||
// Full room details, not the old minimal {Result,RoomId,Name,…} object:
|
||||
// the 2023 issuing method returns Task<FGCPNAACHIK> (NLDBPDCNNCF.txt:5069)
|
||||
// and that reader needs Room/Scenes/CoOwners/Stats — the trimmed payload
|
||||
// made a successful rename surface as a parse failure in the client.
|
||||
return Ok(await BuildRoomServerDetailsWithRolesAsync(roomId));
|
||||
}
|
||||
|
||||
/// <summary>POST <c>rooms/{roomId}/subrooms/{subRoomId}/name</c> —
|
||||
/// rename a scene/sub-room within a multi-scene room. Watch path:
|
||||
/// <c>EJDCNGBEICB.PAENPJCDLNJ(long roomId, long subRoomId, string newName)</c>.
|
||||
/// Updates the matching <see cref="RoomSceneEntity.Name"/> after
|
||||
/// verifying ownership and intra-room uniqueness.</summary>
|
||||
/// verifying ownership and intra-room uniqueness.
|
||||
///
|
||||
/// <para><b>2023 client:</b> <c>NLDBPDCNNCF.EJOHFBMFEGB</c>
|
||||
/// ("change subroom name", NLDBPDCNNCF.txt:8694) is verb 3 = PUT with form
|
||||
/// key <c>name</c> and returns <c>Task<FGCPNAACHIK></c>. Same
|
||||
/// [FromForm]+[FromBody] 415 hazard as the room rename above, so it binds
|
||||
/// through the shared FormOrJsonModelBinder and echoes full details.</para></summary>
|
||||
[HttpPost("rooms/{roomId:long}/subrooms/{subRoomId:long}/name")]
|
||||
[HttpPut("rooms/{roomId:long}/subrooms/{subRoomId:long}/name")]
|
||||
[HttpPost("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/name")]
|
||||
@@ -2785,11 +2880,9 @@ public class RoomsController(
|
||||
public async Task<IActionResult> RenameSubroom(
|
||||
long roomId,
|
||||
long subRoomId,
|
||||
[FromForm(Name = "Name")] string? nameForm,
|
||||
[FromForm(Name = "name")] string? nameFormLower,
|
||||
[FromBody] RenameBody? body)
|
||||
[ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))] RenameBody body)
|
||||
{
|
||||
var newName = nameForm ?? nameFormLower ?? body?.Name;
|
||||
var newName = body?.Name;
|
||||
if (string.IsNullOrWhiteSpace(newName))
|
||||
return BadRequest(new { Result = 1, error = "empty_name" });
|
||||
newName = newName.Trim();
|
||||
@@ -2822,19 +2915,7 @@ public class RoomsController(
|
||||
await db.SaveChangesAsync();
|
||||
logger.LogInformation("[subroom-rename] room={Room} sub={Sub} by={By} → '{Name}'", roomId, subRoomId, me, newName);
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
Result = 0,
|
||||
RoomId = roomId,
|
||||
RoomSceneId = scene.OrderIndex,
|
||||
SubRoomId = scene.OrderIndex,
|
||||
Name = scene.Name,
|
||||
scene.DataBlobName,
|
||||
RoomSceneLocationId = scene.RoomSceneLocationId,
|
||||
scene.IsSandbox,
|
||||
scene.MaxPlayers,
|
||||
scene.CanMatchmakeInto,
|
||||
});
|
||||
return Ok(await BuildRoomServerDetailsWithRolesAsync(roomId));
|
||||
}
|
||||
|
||||
/// <summary>POST <c>api/rooms/v1/cheer</c> — cheer a room (body
|
||||
@@ -2881,9 +2962,21 @@ public class RoomsController(
|
||||
}
|
||||
|
||||
// ── Bare-path interactionby/me toggles ─────────────────────────────
|
||||
//
|
||||
// Verbs come straight from the 2023 client's four one-liner methods on
|
||||
// NLDBPDCNNCF (each a non-generic Task, so no response body is parsed):
|
||||
// "cheer room" :13467 r8 = 3 (PUT)
|
||||
// "uncheer room" :13550 r8 = 4 (DELETE)
|
||||
// "favorite room" :13633 r8 = 3 (PUT)
|
||||
// "unfavorite room" :13716 r8 = 4 (DELETE)
|
||||
// All four are issued from NLDBPDCNNCF, so they arrive with the
|
||||
// roomserver/ prefix (docs/recroom-2023-room-save.md:83-93) — without the
|
||||
// twins the cheer/favorite buttons 404'd and failed silently.
|
||||
|
||||
[HttpPost("rooms/{roomId:long}/interactionby/me/cheer")]
|
||||
[HttpPut("rooms/{roomId:long}/interactionby/me/cheer")]
|
||||
[HttpPost("roomserver/rooms/{roomId:long}/interactionby/me/cheer")]
|
||||
[HttpPut("roomserver/rooms/{roomId:long}/interactionby/me/cheer")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> BareCheer(long roomId)
|
||||
{
|
||||
@@ -2893,6 +2986,7 @@ public class RoomsController(
|
||||
}
|
||||
|
||||
[HttpDelete("rooms/{roomId:long}/interactionby/me/cheer")]
|
||||
[HttpDelete("roomserver/rooms/{roomId:long}/interactionby/me/cheer")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> BareUncheer(long roomId)
|
||||
{
|
||||
@@ -2903,6 +2997,8 @@ public class RoomsController(
|
||||
|
||||
[HttpPost("rooms/{roomId:long}/interactionby/me/favorite")]
|
||||
[HttpPut("rooms/{roomId:long}/interactionby/me/favorite")]
|
||||
[HttpPost("roomserver/rooms/{roomId:long}/interactionby/me/favorite")]
|
||||
[HttpPut("roomserver/rooms/{roomId:long}/interactionby/me/favorite")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> BareFavorite(long roomId)
|
||||
{
|
||||
@@ -2912,6 +3008,7 @@ public class RoomsController(
|
||||
}
|
||||
|
||||
[HttpDelete("rooms/{roomId:long}/interactionby/me/favorite")]
|
||||
[HttpDelete("roomserver/rooms/{roomId:long}/interactionby/me/favorite")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> BareUnfavorite(long roomId)
|
||||
{
|
||||
@@ -3418,10 +3515,20 @@ public class RoomsController(
|
||||
}
|
||||
|
||||
/// <summary>POST <c>rooms/{id}/subrooms</c> — append a new scene.
|
||||
/// Owner-gated. OrderIndex = max+1, Name unique within room.</summary>
|
||||
/// Owner-gated. OrderIndex = max+1, Name unique within room.
|
||||
///
|
||||
/// <para><b>2023 client:</b> <c>NLDBPDCNNCF.CMHBCOIBDME</c> ("add new
|
||||
/// subroom", NLDBPDCNNCF.txt:9396) is verb 2 = POST with a single form key
|
||||
/// <c>name</c> (NLDBPDCNNCF_NestedType_AIPHLHBILDN.txt ISIL 015) and returns
|
||||
/// <c>Task<FGCPNAACHIK></c>. It needs the roomserver/ twin, a binder
|
||||
/// that accepts the form body (<c>[FromBody]</c> 415s it), and full room
|
||||
/// details in the response instead of the scene row.</para></summary>
|
||||
[HttpPost("rooms/{roomId:long}/subrooms")]
|
||||
[HttpPost("roomserver/rooms/{roomId:long}/subrooms")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> AddSubRoom(long roomId, [FromBody] CreateSubRoomRequest req)
|
||||
public async Task<IActionResult> AddSubRoom(
|
||||
long roomId,
|
||||
[ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))] CreateSubRoomRequest req)
|
||||
{
|
||||
var pid = this.RequireCurrentPlayerId();
|
||||
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
|
||||
@@ -3448,10 +3555,18 @@ public class RoomsController(
|
||||
};
|
||||
db.RoomScenes.Add(scene);
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(SceneWire(scene));
|
||||
return Ok(await BuildRoomServerDetailsWithRolesAsync(roomId));
|
||||
}
|
||||
|
||||
/// <summary>DELETE <c>rooms/{id}/subrooms/{sub}</c>. The 2023 caller
|
||||
/// <c>NLDBPDCNNCF.HGNGMIMJFJL</c> ("delete subroom", NLDBPDCNNCF.txt:9827)
|
||||
/// moves verb ordinal 4 = DELETE and returns
|
||||
/// <c>Task<FGCPNAACHIK></c>; the only registration here used to be the
|
||||
/// bare one (the roomserver/ twin at the top of the file is the GET), so
|
||||
/// deleting a subroom 404'd, and the <c>{Result:0}</c> body was not the
|
||||
/// details object the client re-parses.</summary>
|
||||
[HttpDelete("rooms/{roomId:long}/subrooms/{subRoomId:long}")]
|
||||
[HttpDelete("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> DeleteSubRoom(long roomId, long subRoomId)
|
||||
{
|
||||
@@ -3465,20 +3580,69 @@ public class RoomsController(
|
||||
if (scene.OrderIndex == 0) return BadRequest(new { error = "cannot_delete_entry_scene" });
|
||||
db.RoomScenes.Remove(scene);
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new { Result = 0 });
|
||||
return Ok(await BuildRoomServerDetailsWithRolesAsync(roomId));
|
||||
}
|
||||
|
||||
public sealed class SubRoomIntRequest { public int? Value { get; set; } }
|
||||
public sealed class SubRoomBoolRequest { public bool? Value { get; set; } }
|
||||
// Subroom mutation DTOs. Property names mirror the LOWERCASE form keys the
|
||||
// 2023 client actually sends (FormOrJsonModelBinder matches case-insensitively):
|
||||
// maxPlayers NLDBPDCNNCF_NestedType_BLNOGFGHIIF.txt ISIL 020
|
||||
// accessibility NLDBPDCNNCF_NestedType_GNCLJBNJEKP.txt ISIL 020
|
||||
// name/accessibility/maxPlayers ..._JEEACJHABAF.txt ISIL 021/035/048
|
||||
// newRoomId NLDBPDCNNCF_NestedType_COIOGADEBKJ.txt ISIL 020
|
||||
// The older {Value:…} DTOs matched no client key at all, and being bound
|
||||
// [FromBody] they 415'd the form body before the handler ran.
|
||||
public sealed class SubRoomMaxPlayersRequest { public int? MaxPlayers { get; set; } }
|
||||
|
||||
// Accessibility is typed string, not int, on purpose — the two client call
|
||||
// sites disagree about how they stringify it. The standalone
|
||||
// "change subroom accessibility" closure boxes the value as
|
||||
// typeof(JHAOFLCJNAL) (GNCLJBNJEKP.txt ISIL 015), and the form writer's
|
||||
// object overload falls through to a virtual ToString() for anything that
|
||||
// isn't String/DateTime (BNDIAONDFFF.txt ISIL 107-110) — i.e. an enum
|
||||
// MEMBER NAME goes on the wire. The bulk "modify" closure boxes the very
|
||||
// same value as typeof(System.Int32) (JEEACJHABAF.txt ISIL 031) and sends a
|
||||
// number. ParseSubRoomAccessibility below accepts both spellings.
|
||||
public sealed class SubRoomAccessibilityRequest { public string? Accessibility { get; set; } }
|
||||
|
||||
public sealed class SubRoomModifyRequest
|
||||
{
|
||||
public string? Name { get; set; }
|
||||
public string? Accessibility { get; set; }
|
||||
public int? MaxPlayers { get; set; }
|
||||
public bool? IsSandbox { get; set; }
|
||||
public bool? CanMatchmakeInto { get; set; }
|
||||
public string? RoomSceneLocationId { get; set; }
|
||||
}
|
||||
public sealed class SubRoomMoveRequest { public int? NewIndex { get; set; } }
|
||||
|
||||
public sealed class SubRoomMoveRequest
|
||||
{
|
||||
/// <summary>Target ROOM for the scene — the 2023 "move subroom" flow
|
||||
/// re-parents a scene into another room. Nullable<Int64> on the
|
||||
/// wire (NLDBPDCNNCF.MGGALMIAMBG, NLDBPDCNNCF.txt:9659).</summary>
|
||||
public long? NewRoomId { get; set; }
|
||||
|
||||
/// <summary>Legacy in-room reorder used by the admin/import tooling.
|
||||
/// Not something the 2023 client sends.</summary>
|
||||
public int? NewIndex { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>One row of the per-subroom role-permission matrix the 2023
|
||||
/// client PUTs to <c>subrooms/{sub}/permissions</c>. Keys verified in the
|
||||
/// writer <c>HDOJMEBCAGP.txt</c> ISIL 050/069/085/101/117.</summary>
|
||||
public sealed class SubRoomPermissionEntry
|
||||
{
|
||||
// Field order + CLR types come from the BNLPBMJJOMM constructor
|
||||
// (BNLPBMJJOMM.txt:126): (String Permission, OMMBGJMJJPN Role,
|
||||
// Boolean Override, FEOJFGECKGH Type, String Value). The two enum
|
||||
// members serialize as numbers (the role writer boxes them
|
||||
// System.Int32 — BFKNABDHMGG.txt ISIL 015), hence int? here.
|
||||
public string? Permission { get; set; }
|
||||
public int? Role { get; set; }
|
||||
public bool? Override { get; set; }
|
||||
public int? Type { get; set; }
|
||||
public string? Value { get; set; }
|
||||
}
|
||||
|
||||
public sealed class PublishSubRoomSaveRequest
|
||||
{
|
||||
public long? SubRoomDataSaveId { get; set; }
|
||||
@@ -3501,29 +3665,94 @@ public class RoomsController(
|
||||
[HttpPost("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/maxplayers")]
|
||||
[HttpPut("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/maxplayers")]
|
||||
[Authorize]
|
||||
public Task<IActionResult> SubRoomMaxPlayers(long roomId, long subRoomId, [FromBody] SubRoomIntRequest req) =>
|
||||
MutateScene(roomId, subRoomId, s => { if (req.Value is int v) s.MaxPlayers = Math.Max(1, v); });
|
||||
public Task<IActionResult> SubRoomMaxPlayers(
|
||||
long roomId,
|
||||
long subRoomId,
|
||||
[ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))] SubRoomMaxPlayersRequest req) =>
|
||||
MutateScene(roomId, subRoomId, s => { if (req.MaxPlayers is int v) s.MaxPlayers = Math.Max(1, v); });
|
||||
|
||||
/// <summary>PUT <c>rooms/{id}/subrooms/{sub}/accessibility</c>. Client:
|
||||
/// <c>NLDBPDCNNCF.OAINKEAFPGE(long, long, JHAOFLCJNAL)</c>
|
||||
/// (NLDBPDCNNCF.txt:9032, verb 3), form key <c>accessibility</c>, response
|
||||
/// <c>Task<FGCPNAACHIK></c>. We only model the matchmaking bit
|
||||
/// (<see cref="RoomSceneEntity.CanMatchmakeInto"/>), so Private collapses to
|
||||
/// "not a matchmaking target" and everything else to "is one".</summary>
|
||||
[HttpPost("rooms/{roomId:long}/subrooms/{subRoomId:long}/accessibility")]
|
||||
[HttpPut("rooms/{roomId:long}/subrooms/{subRoomId:long}/accessibility")]
|
||||
[HttpPost("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/accessibility")]
|
||||
[HttpPut("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/accessibility")]
|
||||
[Authorize]
|
||||
public Task<IActionResult> SubRoomAccessibility(long roomId, long subRoomId, [FromBody] SubRoomBoolRequest req) =>
|
||||
MutateScene(roomId, subRoomId, s => { if (req.Value is bool v) s.CanMatchmakeInto = v; });
|
||||
public Task<IActionResult> SubRoomAccessibility(
|
||||
long roomId,
|
||||
long subRoomId,
|
||||
[ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))] SubRoomAccessibilityRequest req) =>
|
||||
MutateScene(roomId, subRoomId, s =>
|
||||
{
|
||||
if (ParseSubRoomAccessibility(req.Accessibility) is int a)
|
||||
s.CanMatchmakeInto = a != AccessibilityPrivate;
|
||||
});
|
||||
|
||||
/// <summary>PUT <c>rooms/{id}/subrooms/{sub}/permissions</c> — the
|
||||
/// role-permission matrix. Client:
|
||||
/// <c>NLDBPDCNNCF.AAMEOMHKPHF(long, long, IReadOnlyList<BNLPBMJJOMM>)</c>
|
||||
/// (NLDBPDCNNCF.txt:9198, verb 3). Unlike its siblings this one does NOT
|
||||
/// form-encode: the closure calls <c>BNDIAONDFFF.FJLLPHFOOJJ(String)</c>
|
||||
/// (CCNOMNNHNNG.txt ISIL 009), which stamps body-kind 4 = raw string
|
||||
/// (BNDIAONDFFF.txt:275, <c>mov dword ptr [rbx+38h],4</c>) — i.e. a raw
|
||||
/// JSON ARRAY. FormOrJsonModelBinder handles that (non-form → JSON
|
||||
/// deserialize into the List).
|
||||
///
|
||||
/// <para><b>Scope note — deliberately not persisted.</b> DorkNet has no
|
||||
/// per-subroom permission storage: <see cref="RoomSceneEntity"/> has no
|
||||
/// column for it and there is no permissions table. Adding one means an
|
||||
/// entity + DbContext + migration, which is outside this file. Until then
|
||||
/// the matrix is accepted, logged, and the room details echoed back so the
|
||||
/// client's promise resolves instead of 415-ing; the values do not survive
|
||||
/// a round-trip. The old handler silently bound an unrelated DTO whose keys
|
||||
/// the client never sends, which was strictly worse.</para></summary>
|
||||
[HttpPost("rooms/{roomId:long}/subrooms/{subRoomId:long}/permissions")]
|
||||
[HttpPut("rooms/{roomId:long}/subrooms/{subRoomId:long}/permissions")]
|
||||
[HttpPost("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/permissions")]
|
||||
[HttpPut("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/permissions")]
|
||||
[Authorize]
|
||||
public Task<IActionResult> SubRoomPermissions(long roomId, long subRoomId, [FromBody] SubRoomModifyRequest? req) =>
|
||||
MutateScene(roomId, subRoomId, s =>
|
||||
{
|
||||
if (req?.CanMatchmakeInto is bool canMatchmakeInto) s.CanMatchmakeInto = canMatchmakeInto;
|
||||
if (req?.MaxPlayers is int maxPlayers) s.MaxPlayers = Math.Max(1, maxPlayers);
|
||||
if (req?.IsSandbox is bool isSandbox) s.IsSandbox = isSandbox;
|
||||
});
|
||||
public async Task<IActionResult> SubRoomPermissions(
|
||||
long roomId,
|
||||
long subRoomId,
|
||||
[ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))] List<SubRoomPermissionEntry> req)
|
||||
{
|
||||
var pid = this.RequireCurrentPlayerId();
|
||||
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
|
||||
if (room is null) return NotFound();
|
||||
if (room.CreatorPlayerId != pid) return Forbid();
|
||||
var scene = await GetOrCreateSceneForMutationAsync(room, subRoomId);
|
||||
if (scene is null) return NotFound();
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
logger.LogWarning(
|
||||
"[subroom-permissions] room={Room} sub={Sub} by={By} entries={Count} — accepted but NOT persisted (no permission storage yet)",
|
||||
roomId, subRoomId, pid, req?.Count ?? 0);
|
||||
|
||||
return Ok(await BuildRoomServerDetailsWithRolesAsync(roomId));
|
||||
}
|
||||
|
||||
// JHAOFLCJNAL member values. The numeric leg is verified (the "modify"
|
||||
// closure boxes the same value as System.Int32 — JEEACJHABAF.txt ISIL 031);
|
||||
// the NAMES are inferred from the room-accessibility enum in the nearest
|
||||
// dump with real names (RecRoom-2023.06.21 dump.cs:1293997-1294004
|
||||
// MKDFFJGGKDL { Private = 0, Public = 1, Unlisted = 2 }), which is the enum
|
||||
// Enum.ToString() would spell out on the standalone accessibility call.
|
||||
private const int AccessibilityPrivate = 0;
|
||||
|
||||
private static int? ParseSubRoomAccessibility(string? raw)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(raw)) return null;
|
||||
var value = raw.Trim();
|
||||
if (int.TryParse(value, out var numeric)) return numeric;
|
||||
if (value.StartsWith("priv", StringComparison.OrdinalIgnoreCase)) return 0;
|
||||
if (value.StartsWith("pub", StringComparison.OrdinalIgnoreCase)) return 1;
|
||||
if (value.StartsWith("unlist", StringComparison.OrdinalIgnoreCase)) return 2;
|
||||
return null;
|
||||
}
|
||||
|
||||
[HttpPost("rooms/{roomId:long}/subrooms/{subRoomId:long}/publish_save")]
|
||||
[HttpPut("rooms/{roomId:long}/subrooms/{subRoomId:long}/publish_save")]
|
||||
@@ -3558,7 +3787,9 @@ public class RoomsController(
|
||||
scene.DataModifiedAt = DateTime.UtcNow;
|
||||
room.UpdatedAt = scene.DataModifiedAt;
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(SceneWire(scene));
|
||||
// "restore subroom save" returns Task<FGCPNAACHIK> (NLDBPDCNNCF.txt:10476),
|
||||
// so the scene row alone made a successful restore read as a failure.
|
||||
return Ok(await BuildRoomServerDetailsWithRolesAsync(roomId));
|
||||
}
|
||||
|
||||
private async Task<PublishSubRoomSaveRequest> ReadPublishSubRoomSaveRequestAsync()
|
||||
@@ -3640,34 +3871,68 @@ public class RoomsController(
|
||||
};
|
||||
db.RoomScenes.Add(clone);
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(SceneWire(clone));
|
||||
// Task<FGCPNAACHIK> ("clone subroom", NLDBPDCNNCF.txt:9554) — the scene
|
||||
// row is not what the client re-parses.
|
||||
return Ok(await BuildRoomServerDetailsWithRolesAsync(roomId));
|
||||
}
|
||||
|
||||
// Subroom settings save POSTs here on the roomserver host ("failed to
|
||||
// modify room" = the bare-only routes 404'd). Same roomserver/ prefix as
|
||||
// the sibling subroom mutations.
|
||||
//
|
||||
// Body is form-urlencoded name/accessibility/maxPlayers ("modify room",
|
||||
// NLDBPDCNNCF.txt:10846 verb 3; keys at JEEACJHABAF.txt ISIL 021/035/048).
|
||||
// [FromBody] rejected that with 415 before the handler ran, and the DTO had
|
||||
// no 'accessibility' member at all, so the bulk subroom-settings save was a
|
||||
// guaranteed failure.
|
||||
[HttpPost("rooms/{roomId:long}/subrooms/{subRoomId:long}/modify")]
|
||||
[HttpPut("rooms/{roomId:long}/subrooms/{subRoomId:long}/modify")]
|
||||
[HttpPost("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/modify")]
|
||||
[HttpPut("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/modify")]
|
||||
[Authorize]
|
||||
public Task<IActionResult> ModifySubRoom(long roomId, long subRoomId, [FromBody] SubRoomModifyRequest req) =>
|
||||
public Task<IActionResult> ModifySubRoom(
|
||||
long roomId,
|
||||
long subRoomId,
|
||||
[ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))] SubRoomModifyRequest req) =>
|
||||
MutateScene(roomId, subRoomId, s =>
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(req.Name)) s.Name = req.Name.Trim();
|
||||
if (req.MaxPlayers is int mp) s.MaxPlayers = Math.Max(1, mp);
|
||||
if (req.IsSandbox is bool sb) s.IsSandbox = sb;
|
||||
if (req.CanMatchmakeInto is bool cmi) s.CanMatchmakeInto = cmi;
|
||||
if (ParseSubRoomAccessibility(req.Accessibility) is int a)
|
||||
s.CanMatchmakeInto = a != AccessibilityPrivate;
|
||||
if (req.RoomSceneLocationId is not null) s.RoomSceneLocationId = req.RoomSceneLocationId;
|
||||
});
|
||||
|
||||
/// <summary>POST <c>rooms/{id}/subrooms/{sub}/move</c> — reorder a
|
||||
/// scene. Body NewIndex is the target OrderIndex; the in-between
|
||||
/// rows shift to fill the gap.</summary>
|
||||
/// <summary>POST <c>rooms/{id}/subrooms/{sub}/move</c>.
|
||||
///
|
||||
/// <para><b>The 2023 semantics are "move to another ROOM", not "reorder".</b>
|
||||
/// <c>NLDBPDCNNCF.MGGALMIAMBG(long roomId, long subRoomId,
|
||||
/// Nullable<Int64> newRoomId)</c> ("move subroom",
|
||||
/// NLDBPDCNNCF.txt:9659, verb 2 = POST) sends the single form key
|
||||
/// <c>newRoomId</c> (COIOGADEBKJ.txt ISIL 013-020, boxed as
|
||||
/// <c>Nullable<Int64></c>) and returns
|
||||
/// <c>Task<FGCPNAACHIK></c>. The old handler bound
|
||||
/// <c>[FromBody] {NewIndex}</c> — a key the client never sends, on a body
|
||||
/// binding that 415s form posts — and reordered scenes in place instead.</para>
|
||||
///
|
||||
/// <para>The caller must own BOTH rooms. The scene is re-parented to the end
|
||||
/// of the target room's scene list and the source room's remaining scenes
|
||||
/// are re-packed so <c>OrderIndex</c> stays a dense 0..n-1 sequence — the
|
||||
/// wire <c>RoomSceneId</c> and matchmaking <c>SubRoomId</c> are that index.
|
||||
/// Scene 0 is the room's entry scene and cannot be moved out.
|
||||
/// <c>newIndex</c> (no client sends it) keeps the old in-room reorder for
|
||||
/// the import tooling.</para></summary>
|
||||
[HttpPost("rooms/{roomId:long}/subrooms/{subRoomId:long}/move")]
|
||||
[HttpPut("rooms/{roomId:long}/subrooms/{subRoomId:long}/move")]
|
||||
[HttpPost("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/move")]
|
||||
[HttpPut("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/move")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> MoveSubRoom(long roomId, long subRoomId, [FromBody] SubRoomMoveRequest req)
|
||||
public async Task<IActionResult> MoveSubRoom(
|
||||
long roomId,
|
||||
long subRoomId,
|
||||
[ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))] SubRoomMoveRequest req)
|
||||
{
|
||||
var pid = this.RequireCurrentPlayerId();
|
||||
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
|
||||
@@ -3677,19 +3942,69 @@ public class RoomsController(
|
||||
.Where(s => s.RoomId == roomId).OrderBy(s => s.OrderIndex).ToListAsync();
|
||||
var moving = scenes.FirstOrDefault(s => s.OrderIndex == subRoomId);
|
||||
if (moving is null) return NotFound();
|
||||
var newIndex = Math.Clamp(req.NewIndex ?? 0, 0, scenes.Count - 1);
|
||||
scenes.Remove(moving);
|
||||
scenes.Insert(newIndex, moving);
|
||||
// Recompute OrderIndex on the entire row set. The unique
|
||||
// (RoomId, OrderIndex) index would clash if we wrote rows in
|
||||
// place — flush a sentinel offset first, then settle to
|
||||
// final indices.
|
||||
|
||||
// `newRoomId` is Nullable<Int64> on the wire and the form writer
|
||||
// stringifies it with ToString(), which yields "" for the empty case —
|
||||
// so an absent target arrives as a blank field, not a missing one.
|
||||
// Treat "no target and no index" as a no-op; the old code fell through
|
||||
// to `NewIndex ?? 0` and silently reordered the scene to the front.
|
||||
if (req.NewRoomId is not long targetRoomId || targetRoomId == roomId)
|
||||
{
|
||||
if (req.NewIndex is not int requestedIndex)
|
||||
return Ok(await BuildRoomServerDetailsWithRolesAsync(roomId));
|
||||
|
||||
var newIndex = Math.Clamp(requestedIndex, 0, scenes.Count - 1);
|
||||
scenes.Remove(moving);
|
||||
scenes.Insert(newIndex, moving);
|
||||
await RepackSceneOrderAsync(scenes);
|
||||
return Ok(await BuildRoomServerDetailsWithRolesAsync(roomId));
|
||||
}
|
||||
|
||||
// Cross-room move — the path the 2023 client actually takes.
|
||||
var target = await db.Rooms.FirstOrDefaultAsync(r => r.Id == targetRoomId);
|
||||
if (target is null) return NotFound(new { error = "target_room_not_found", targetRoomId });
|
||||
if (target.CreatorPlayerId != pid) return Forbid();
|
||||
if (moving.OrderIndex == 0)
|
||||
return BadRequest(new { error = "cannot_move_entry_scene" });
|
||||
|
||||
// Names are unique per room (RoomScenes index on (RoomId, Name)).
|
||||
var movedName = moving.Name;
|
||||
var suffix = 1;
|
||||
while (await db.RoomScenes.AnyAsync(s => s.RoomId == targetRoomId && s.Name == movedName))
|
||||
{
|
||||
suffix += 1;
|
||||
movedName = $"{moving.Name} {suffix}";
|
||||
}
|
||||
|
||||
var targetOrder = (await db.RoomScenes.Where(s => s.RoomId == targetRoomId)
|
||||
.Select(s => (int?)s.OrderIndex).MaxAsync() ?? -1) + 1;
|
||||
moving.RoomId = targetRoomId;
|
||||
moving.OrderIndex = targetOrder;
|
||||
moving.Name = movedName;
|
||||
moving.DataModifiedAt = DateTime.UtcNow;
|
||||
room.UpdatedAt = moving.DataModifiedAt;
|
||||
target.UpdatedAt = moving.DataModifiedAt;
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
// Re-pack the source room so its remaining scenes keep dense indices
|
||||
// (same sentinel dance as the in-room reorder above).
|
||||
await RepackSceneOrderAsync(scenes.Where(s => s.Id != moving.Id).ToList());
|
||||
logger.LogInformation("[subroom-move] scene={Scene} room={From}→{To} by={By}",
|
||||
moving.Id, roomId, targetRoomId, pid);
|
||||
return Ok(await BuildRoomServerDetailsWithRolesAsync(targetRoomId));
|
||||
}
|
||||
|
||||
/// <summary>Rewrite <see cref="RoomSceneEntity.OrderIndex"/> to 0..n-1 in
|
||||
/// list order. The unique (RoomId, OrderIndex) index would clash if rows
|
||||
/// were written in place, so flush a sentinel offset first, then settle.</summary>
|
||||
private async Task RepackSceneOrderAsync(List<RoomSceneEntity> scenes)
|
||||
{
|
||||
if (scenes.Count == 0) return;
|
||||
const int sentinelOffset = 100_000;
|
||||
for (int i = 0; i < scenes.Count; i++) scenes[i].OrderIndex = i + sentinelOffset;
|
||||
await db.SaveChangesAsync();
|
||||
for (int i = 0; i < scenes.Count; i++) scenes[i].OrderIndex = i;
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(SceneWire(moving));
|
||||
}
|
||||
|
||||
/// <summary>POST <c>roomserver/rooms/{id}/subrooms/{sub}/data</c> —
|
||||
@@ -3762,6 +4077,11 @@ public class RoomsController(
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>Shared owner-gate + mutate + save for the subroom setting
|
||||
/// endpoints. Responds with the FULL room details: every 2023 caller that
|
||||
/// lands here (maxplayers / accessibility / modify) is typed
|
||||
/// <c>Task<FGCPNAACHIK></c>, so the old scene-row payload made a
|
||||
/// successful mutation surface as a client-side parse failure.</summary>
|
||||
private async Task<IActionResult> MutateScene(long roomId, long subRoomId, Action<RoomSceneEntity> mutator)
|
||||
{
|
||||
var pid = this.RequireCurrentPlayerId();
|
||||
@@ -3773,7 +4093,7 @@ public class RoomsController(
|
||||
mutator(scene);
|
||||
scene.DataModifiedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(SceneWire(scene));
|
||||
return Ok(await BuildRoomServerDetailsWithRolesAsync(roomId));
|
||||
}
|
||||
|
||||
private async Task<RoomSceneEntity?> GetOrCreateSceneForMutationAsync(RoomEntity room, long subRoomId)
|
||||
@@ -3798,25 +4118,21 @@ public class RoomsController(
|
||||
return scene;
|
||||
}
|
||||
|
||||
private static object SceneWire(RoomSceneEntity s) => new
|
||||
{
|
||||
Result = 0,
|
||||
RoomId = s.RoomId,
|
||||
RoomSceneId = s.OrderIndex,
|
||||
SubRoomId = s.OrderIndex,
|
||||
Name = s.Name,
|
||||
s.DataBlobName,
|
||||
RoomSceneLocationId = s.RoomSceneLocationId,
|
||||
s.IsSandbox,
|
||||
s.MaxPlayers,
|
||||
s.CanMatchmakeInto,
|
||||
};
|
||||
// SceneWire (the old {Result,RoomId,RoomSceneId,…} scene-row payload) is
|
||||
// gone: every subroom mutation the 2023 client issues is typed
|
||||
// Task<FGCPNAACHIK> and re-parses the full room details, so all of these
|
||||
// handlers now go through BuildRoomServerDetailsWithRolesAsync.
|
||||
|
||||
// ── Bare-path roles ────────────────────────────────────────────────
|
||||
|
||||
/// <summary>GET <c>rooms/{id}/roles</c> — account role DTOs consumed by
|
||||
/// 2023's EFHPLDPNGIM deserializer.</summary>
|
||||
/// 2023's EFHPLDPNGIM deserializer
|
||||
/// (<c>NLDBPDCNNCF.LAJDCNIMBAB</c> → <c>Task<List<EFHPLDPNGIM>></c>,
|
||||
/// NLDBPDCNNCF.txt:2111, verb 0 = GET). Like every other NLDBPDCNNCF call it
|
||||
/// arrives with the <c>roomserver/</c> prefix, so the bare-only registration
|
||||
/// 404'd the permissions screen's list fetch.</summary>
|
||||
[HttpGet("rooms/{roomId:long}/roles")]
|
||||
[HttpGet("roomserver/rooms/{roomId:long}/roles")]
|
||||
public async Task<IActionResult> RolesList(long roomId)
|
||||
{
|
||||
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
|
||||
@@ -3939,7 +4255,12 @@ public class RoomsController(
|
||||
return new StudioBundleInfo(saveId, target, version, name);
|
||||
}
|
||||
|
||||
/// <summary>GET <c>rooms/{id}/roles/{playerId}</c> — one role row
|
||||
/// (<c>NLDBPDCNNCF.PCFOIPELKHN</c> → <c>Task<EFHPLDPNGIM></c>,
|
||||
/// NLDBPDCNNCF.txt:2192). roomserver/ twin required, same as the list
|
||||
/// above.</summary>
|
||||
[HttpGet("rooms/{roomId:long}/roles/{playerId:long}")]
|
||||
[HttpGet("roomserver/rooms/{roomId:long}/roles/{playerId:long}")]
|
||||
public async Task<IActionResult> RoleForPlayer(long roomId, long playerId)
|
||||
{
|
||||
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
|
||||
@@ -3954,10 +4275,17 @@ public class RoomsController(
|
||||
|
||||
public sealed class GrantRoleRequest { public int? Role { get; set; } }
|
||||
|
||||
/// <summary>POST <c>rooms/{id}/roles/{playerId}</c> — owner grants
|
||||
/// a role to a player. Auto-accepted (no invite step).</summary>
|
||||
/// <summary>PUT <c>rooms/{id}/roles/{playerId}</c> — owner grants
|
||||
/// a role to a player. Auto-accepted (no invite step).
|
||||
/// <c>NLDBPDCNNCF.LMIEIJMCEAB</c> ("change room account role",
|
||||
/// NLDBPDCNNCF.txt:7380) is verb 3 = PUT, form key <c>role</c> carrying a
|
||||
/// boxed Int32 (BFKNABDHMGG.txt ISIL 015), and returns the full
|
||||
/// <c>FGCPNAACHIK</c> details — which this already does. Only the
|
||||
/// roomserver/ twin was missing.</summary>
|
||||
[HttpPut("rooms/{roomId:long}/roles/{playerId:long}")]
|
||||
[HttpPost("rooms/{roomId:long}/roles/{playerId:long}")]
|
||||
[HttpPut("roomserver/rooms/{roomId:long}/roles/{playerId:long}")]
|
||||
[HttpPost("roomserver/rooms/{roomId:long}/roles/{playerId:long}")]
|
||||
[HttpPut("/api/rooms/v1/rooms/{roomId:long}/roles/{playerId:long}")]
|
||||
[HttpPost("/api/rooms/v1/rooms/{roomId:long}/roles/{playerId:long}")]
|
||||
[HttpPut("/api/rooms/v2/rooms/{roomId:long}/roles/{playerId:long}")]
|
||||
@@ -3999,9 +4327,13 @@ public class RoomsController(
|
||||
/// <summary>POST <c>rooms/{id}/roles/{playerId}/invite</c> — same
|
||||
/// as grant but Accepted=false; the target's accept-invite flow
|
||||
/// flips the flag (separate endpoint, not yet exposed). For now
|
||||
/// invited rows surface in RoomDetails.InvitedCoOwners etc.</summary>
|
||||
/// invited rows surface in RoomDetails.InvitedCoOwners etc.
|
||||
/// <c>NLDBPDCNNCF.KIJDCLINOIC</c> ("invite account to role",
|
||||
/// NLDBPDCNNCF.txt:7550) is verb 3 = PUT under the roomserver/ prefix.</summary>
|
||||
[HttpPut("rooms/{roomId:long}/roles/{playerId:long}/invite")]
|
||||
[HttpPost("rooms/{roomId:long}/roles/{playerId:long}/invite")]
|
||||
[HttpPut("roomserver/rooms/{roomId:long}/roles/{playerId:long}/invite")]
|
||||
[HttpPost("roomserver/rooms/{roomId:long}/roles/{playerId:long}/invite")]
|
||||
[HttpPut("/api/rooms/v1/rooms/{roomId:long}/roles/{playerId:long}/invite")]
|
||||
[HttpPost("/api/rooms/v1/rooms/{roomId:long}/roles/{playerId:long}/invite")]
|
||||
[HttpPut("/api/rooms/v2/rooms/{roomId:long}/roles/{playerId:long}/invite")]
|
||||
|
||||
@@ -721,43 +721,6 @@ public class RoomsModerationController(DorkNetDbContext db) : ControllerBase
|
||||
return null;
|
||||
}
|
||||
|
||||
private async Task<JsonElement?> ReadJsonElementAsync(params string[] stringKeys)
|
||||
{
|
||||
if (Request.HasFormContentType)
|
||||
{
|
||||
var form = await Request.ReadFormAsync();
|
||||
var dict = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
foreach (var key in form.Keys)
|
||||
{
|
||||
var value = form[key].FirstOrDefault();
|
||||
if (!string.IsNullOrWhiteSpace(value)) dict[key] = value;
|
||||
}
|
||||
if (dict.Count == 0) return null;
|
||||
return JsonSerializer.SerializeToElement(dict);
|
||||
}
|
||||
|
||||
if ((Request.ContentLength ?? 0) > 0)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var doc = await JsonDocument.ParseAsync(Request.Body);
|
||||
return doc.RootElement.Clone();
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var key in stringKeys)
|
||||
{
|
||||
var value = Request.Query[key].FirstOrDefault();
|
||||
if (!string.IsNullOrWhiteSpace(value)) return JsonSerializer.SerializeToElement(value);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── Multi-field payload reader ──────────────────────────────────────
|
||||
//
|
||||
// The single-key Read*Async helpers above each parse Request.Body, and a
|
||||
@@ -1184,6 +1147,18 @@ public class RoomsModerationController(DorkNetDbContext db) : ControllerBase
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>PUT <c>rooms/{id}/promo_external</c> — "add room promotional
|
||||
/// content" (verb byte 3 = PUT at NLDBPDCNNCF.txt:8181). Both the add and
|
||||
/// the remove closures send exactly two form keys: <c>type</c> — a BOXED
|
||||
/// System.Int32 (NLDBPDCNNCF/OJAKIAHKALI:017 boxes typeof(System.Int32),
|
||||
/// key at :022) — and <c>reference</c>, a string (:031).
|
||||
///
|
||||
/// The old code stored the raw form dictionary, so Type landed on the
|
||||
/// wire as the STRING "1"; the client's promo-external reader
|
||||
/// (EPPPACFECMH.txt:191/:210 registers Type + Reference, and :233 reads
|
||||
/// Type through the numeric accessor FAHHHNKECAB while :254 reads
|
||||
/// Reference through the string accessor BKKEIINKNCL) rejects a string
|
||||
/// there. Build the two members explicitly and typed.</summary>
|
||||
[HttpPost("rooms/{roomId:long}/promo_external")]
|
||||
[HttpPut("rooms/{roomId:long}/promo_external")]
|
||||
[HttpPost("roomserver/rooms/{roomId:long}/promo_external")]
|
||||
@@ -1191,70 +1166,186 @@ public class RoomsModerationController(DorkNetDbContext db) : ControllerBase
|
||||
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
|
||||
public async Task<IActionResult> BarePromoExternalAdd(long roomId)
|
||||
{
|
||||
var payload = await ReadJsonElementAsync("url", "Url", "value", "Value");
|
||||
if (payload is null) return BadRequest(new { error = "missing_promo_external" });
|
||||
var payload = await ReadPayloadAsync();
|
||||
var reference = Value(payload, "reference", "Reference", "url", "Url", "value", "Value");
|
||||
if (string.IsNullOrWhiteSpace(reference))
|
||||
return BadRequest(new { error = "missing_promo_external" });
|
||||
var type = IntValue(payload, "type", "Type") ?? 0;
|
||||
return await ApplyAndReturn(roomId, r =>
|
||||
{
|
||||
var items = ReadJsonElementList(r.PromoExternalContentJson);
|
||||
var raw = payload.Value.GetRawText();
|
||||
if (!items.Any(i => string.Equals(i.GetRawText(), raw, StringComparison.Ordinal)))
|
||||
items.Add(payload.Value);
|
||||
var items = ReadJsonElementList(r.PromoExternalContentJson)
|
||||
.Where(i => !PromoExternalMatches(i, type, reference))
|
||||
.ToList();
|
||||
items.Add(JsonSerializer.SerializeToElement(new
|
||||
{
|
||||
Type = type,
|
||||
Reference = reference,
|
||||
}));
|
||||
r.PromoExternalContentJson = SerializeLimited(items);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>DELETE <c>rooms/{id}/promo_external</c> — "remove room
|
||||
/// promotional content" (verb byte 4 = DELETE at NLDBPDCNNCF.txt:8343).
|
||||
/// Identity is the (<c>type</c>, <c>reference</c>) PAIR
|
||||
/// (NLDBPDCNNCF/LLFPMJMCLBP:022 and :031), not an id/url — the old
|
||||
/// id/url/value lookup always missed, so every remove 400'd with
|
||||
/// missing_promo_external and promo links could not be deleted.</summary>
|
||||
[HttpDelete("rooms/{roomId:long}/promo_external")]
|
||||
[HttpDelete("roomserver/rooms/{roomId:long}/promo_external")]
|
||||
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
|
||||
public async Task<IActionResult> BarePromoExternalRemove(long roomId)
|
||||
{
|
||||
var match = await ReadStringValueAsync("id", "Id", "url", "Url", "value", "Value");
|
||||
if (string.IsNullOrWhiteSpace(match)) return BadRequest(new { error = "missing_promo_external" });
|
||||
var payload = await ReadPayloadAsync();
|
||||
var reference = Value(payload, "reference", "Reference", "id", "Id", "url", "Url", "value", "Value");
|
||||
if (string.IsNullOrWhiteSpace(reference))
|
||||
return BadRequest(new { error = "missing_promo_external" });
|
||||
// type is absent on the 2020.12 watch's remove — treat "no type" as
|
||||
// "match on reference alone".
|
||||
var type = IntValue(payload, "type", "Type");
|
||||
return await ApplyAndReturn(roomId, r =>
|
||||
{
|
||||
var items = ReadJsonElementList(r.PromoExternalContentJson)
|
||||
.Where(i => !PromoExternalMatches(i, match))
|
||||
.Where(i => !PromoExternalMatches(i, type, reference))
|
||||
.ToList();
|
||||
r.PromoExternalContentJson = SerializeLimited(items);
|
||||
});
|
||||
}
|
||||
|
||||
private static bool PromoExternalMatches(JsonElement item, string match)
|
||||
/// <summary>A stored promo-external entry matches when its Reference
|
||||
/// does and (when the caller supplied one) its Type agrees. Legacy rows
|
||||
/// written before the typed shape landed may be bare strings or carry
|
||||
/// Url/Id instead of Reference, so those spellings are still probed.
|
||||
/// </summary>
|
||||
private static bool PromoExternalMatches(JsonElement item, int? type, string reference)
|
||||
{
|
||||
if (string.Equals(item.GetRawText(), match, StringComparison.OrdinalIgnoreCase)) return true;
|
||||
if (item.ValueKind == JsonValueKind.String)
|
||||
return string.Equals(item.GetString(), match, StringComparison.OrdinalIgnoreCase);
|
||||
return string.Equals(item.GetString(), reference, StringComparison.OrdinalIgnoreCase);
|
||||
if (item.ValueKind != JsonValueKind.Object) return false;
|
||||
foreach (var key in new[] { "Id", "id", "Url", "url", "Value", "value" })
|
||||
|
||||
var referenceMatches = false;
|
||||
foreach (var key in new[] { "Reference", "reference", "Url", "url", "Id", "id", "Value", "value" })
|
||||
{
|
||||
if (item.TryGetProperty(key, out var prop) &&
|
||||
prop.ValueKind == JsonValueKind.String &&
|
||||
string.Equals(prop.GetString(), match, StringComparison.OrdinalIgnoreCase))
|
||||
return true;
|
||||
string.Equals(prop.GetString(), reference, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
referenceMatches = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
if (!referenceMatches) return false;
|
||||
if (type is not int wanted) return true;
|
||||
|
||||
foreach (var key in new[] { "Type", "type" })
|
||||
{
|
||||
if (!item.TryGetProperty(key, out var prop)) continue;
|
||||
if (prop.ValueKind == JsonValueKind.Number && prop.TryGetInt32(out var n)) return n == wanted;
|
||||
if (prop.ValueKind == JsonValueKind.String && int.TryParse(prop.GetString(), out n)) return n == wanted;
|
||||
}
|
||||
// Untyped legacy row — reference alone is enough.
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary><c>rooms/{id}/comments</c> + <c>voice_chat_encryption</c>
|
||||
/// — fields not persisted in the 2020 RoomEntity. Owner-gated
|
||||
/// acknowledgement so the watch's settings panel doesn't surface
|
||||
/// a "save failed" error; populated when the columns land.</summary>
|
||||
/// <summary><c>rooms/{id}/comments</c> (form key <c>disable</c>, Boolean —
|
||||
/// NLDBPDCNNCF/EKLEDLOPLJB:015/:020) and
|
||||
/// <c>rooms/{id}/voice_chat_encryption</c> (form key
|
||||
/// <c>encryptVoiceChat</c>, Boolean — NLDBPDCNNCF/NDKDDENPHJP:015/:020).
|
||||
/// Both are PUT (verb byte 3 at NLDBPDCNNCF.txt:6879 and :7033) and both
|
||||
/// are issued under the roomserver/ prefix by the 2023 in-room settings
|
||||
/// panel — the missing twins made them 404 there.
|
||||
///
|
||||
/// The VALUES still aren't persisted: RoomEntity has no
|
||||
/// DisableRoomComments / EncryptVoiceChat column, so RoomService.cs:1122
|
||||
/// and RoomsController.cs:2130 hardcode both to false on the wire.
|
||||
/// Adding those columns (plus a migration and the two detail builders)
|
||||
/// is outside this file. Until then this stays an owner-gated
|
||||
/// acknowledgement that answers with the full details object the call
|
||||
/// site's reader [0x1884C1948] expects, so the panel doesn't report a
|
||||
/// save failure on top of the toggle not sticking.</summary>
|
||||
[HttpPost("rooms/{roomId:long}/comments")]
|
||||
[HttpPut("rooms/{roomId:long}/comments")]
|
||||
[HttpPost("roomserver/rooms/{roomId:long}/comments")]
|
||||
[HttpPut("roomserver/rooms/{roomId:long}/comments")]
|
||||
[HttpPost("rooms/{roomId:long}/voice_chat_encryption")]
|
||||
[HttpPut("rooms/{roomId:long}/voice_chat_encryption")]
|
||||
[HttpPost("roomserver/rooms/{roomId:long}/voice_chat_encryption")]
|
||||
[HttpPut("roomserver/rooms/{roomId:long}/voice_chat_encryption")]
|
||||
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
|
||||
public async Task<IActionResult> BareAck(long roomId) =>
|
||||
await ApplyAndReturn(roomId, _ => { /* no-op: not modelled yet */ });
|
||||
await ApplyAndReturn(roomId, _ => { /* value not persistable: no column */ });
|
||||
|
||||
/// <summary><c>rooms/{id}/modify</c> — bare-path equivalent
|
||||
/// of <c>api/rooms/v2/modify</c>. Reuses <see cref="Modify"/> by
|
||||
/// forwarding the body with RoomId pre-populated from the URL.</summary>
|
||||
/// <summary>PUT <c>rooms/{id}/modify</c> — the room-settings panel's bulk
|
||||
/// save (verb byte 3 = PUT at NLDBPDCNNCF.txt:10829, "modify room"), and
|
||||
/// the 2023 client issues it under the roomserver/ prefix too.
|
||||
///
|
||||
/// The body is x-www-form-urlencoded with ELEVEN camelCase keys, emitted
|
||||
/// in this order by NLDBPDCNNCF/MMMNLOBFFKO: <c>name</c> (string, :039),
|
||||
/// <c>description</c> (string, :049), <c>accessibility</c> (Int32, boxed
|
||||
/// at :059 / key :063), then eight Booleans — <c>supportsScreens</c>
|
||||
/// (:076), <c>supportsWalkVR</c> (:089), <c>supportsTeleportVR</c>
|
||||
/// (:102), <c>supportsJuniors</c> (:115), <c>cloningAllowed</c> (:128),
|
||||
/// <c>disableMicAutoMute</c> (:141), <c>disableRoomComments</c> (:154)
|
||||
/// and <c>encryptVoiceChat</c> (:167).
|
||||
///
|
||||
/// The old <c>[FromBody] ModifyRoomRequest</c> forwarder 415'd on that
|
||||
/// form body before the handler ran, was missing four of the fields, and
|
||||
/// answered <c>{Result, Room}</c> — but the call site attaches the full
|
||||
/// details reader [0x1884C1948] (:10826), the same one clone uses, so
|
||||
/// the response must be BuildRoomServerDetails.
|
||||
///
|
||||
/// disableRoomComments / encryptVoiceChat are deliberately NOT read here:
|
||||
/// RoomEntity has no column for either, so there is nowhere to put them
|
||||
/// (see <see cref="BareAck"/> above).</summary>
|
||||
[HttpPost("rooms/{roomId:long}/modify")]
|
||||
[HttpPut("rooms/{roomId:long}/modify")]
|
||||
public async Task<IActionResult> BareModify(long roomId, [FromBody] ModifyRoomRequest req)
|
||||
[HttpPost("roomserver/rooms/{roomId:long}/modify")]
|
||||
[HttpPut("roomserver/rooms/{roomId:long}/modify")]
|
||||
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
|
||||
public async Task<IActionResult> BareModify(long roomId)
|
||||
{
|
||||
req.RoomId = roomId;
|
||||
return await Modify(req);
|
||||
var payload = await ReadPayloadAsync();
|
||||
var name = Value(payload, "name", "Name");
|
||||
// RawValue for description: clearing it sends the key with "".
|
||||
var description = RawValue(payload, "description", "Description");
|
||||
var accessibility = IntValue(payload, "accessibility", "Accessibility");
|
||||
var imageName = Value(payload, "imageName", "ImageName");
|
||||
var screens = BoolValue(payload, "supportsScreens", "SupportsScreens");
|
||||
var walkVR = BoolValue(payload, "supportsWalkVR", "SupportsWalkVR");
|
||||
var teleportVR = BoolValue(payload, "supportsTeleportVR", "SupportsTeleportVR");
|
||||
var juniors = BoolValue(payload, "supportsJuniors", "AllowsJuniors");
|
||||
var cloning = BoolValue(payload, "cloningAllowed", "CloningAllowed");
|
||||
var autoMute = BoolValue(payload, "disableMicAutoMute", "DisableMicAutoMute");
|
||||
var mobile = BoolValue(payload, "supportsMobile", "SupportsMobile");
|
||||
var vrLow = BoolValue(payload, "supportsVRLow", "SupportsVRLow");
|
||||
|
||||
var room = await RequireOwnedRoomAsync(roomId);
|
||||
if (room is null) return NotFound();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(name)) room.Name = name.Trim();
|
||||
if (description is not null) room.Description = description;
|
||||
if (imageName is not null) room.ImageName = imageName;
|
||||
if (accessibility is int a) room.Accessibility = Math.Clamp(a, 0, 2);
|
||||
if (screens is bool s) room.SupportsScreens = s;
|
||||
if (walkVR is bool w) room.SupportsWalkVR = w;
|
||||
if (teleportVR is bool t) room.SupportsTeleportVR = t;
|
||||
if (juniors is bool j) room.AllowsJuniors = j;
|
||||
if (cloning is bool c) room.CloningAllowed = c;
|
||||
if (autoMute is bool m) room.DisableMicAutoMute = m;
|
||||
if (mobile is bool mo) room.SupportsMobile = mo;
|
||||
if (vrLow is bool vl) room.SupportsVRLow = vl;
|
||||
room.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
try
|
||||
{
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
catch (DbUpdateException)
|
||||
{
|
||||
// Likely a unique-name collision.
|
||||
return Conflict(new { Result = 2, Message = "name taken" });
|
||||
}
|
||||
return Ok(await BuildRoomDetailsAsync(room));
|
||||
}
|
||||
|
||||
/// <summary>POST <c>rooms/{id}/clone</c> — bare-path alias of
|
||||
@@ -1463,24 +1554,89 @@ public class RoomsModerationController(DorkNetDbContext db) : ControllerBase
|
||||
}
|
||||
|
||||
// ── Bare-path bans aliases ─────────────────────────────────────────
|
||||
|
||||
public sealed class BareBanRequest { public long PlayerId { get; set; } public int BanType { get; set; } public DateTime? Until { get; set; } public string? Reason { get; set; } }
|
||||
//
|
||||
// Every one of these needs the roomserver/ twin: the 2023 in-room
|
||||
// banned-players panel issues them under that prefix
|
||||
// (NLDBPDCNNCF.txt:12901 GET, :13044 POST "ban players", :13191 POST
|
||||
// "rooms/{0}/bans/import", :13353 DELETE "rooms/{0}/bans/{1}"), and a
|
||||
// bare-only registration 404s there.
|
||||
|
||||
[HttpGet("rooms/{roomId:long}/bans")]
|
||||
[HttpGet("roomserver/rooms/{roomId:long}/bans")]
|
||||
public Task<IActionResult> BareBansList(long roomId) => RoomBansList(roomId);
|
||||
|
||||
/// <summary>POST <c>rooms/{id}/bans</c> — "ban players" (verb byte 2 =
|
||||
/// POST at NLDBPDCNNCF.txt:13060). The client sends
|
||||
/// x-www-form-urlencoded with ONE <c>banMask</c> (boxed Int32) plus a
|
||||
/// REPEATED <c>id</c> key — one per account being banned
|
||||
/// (NLDBPDCNNCF/DMMILEJMMCC:024 and :034; the `id` binder is the
|
||||
/// list-valued overload, fed from the
|
||||
/// <c>IReadOnlyList<Int32></c> parameter of the issuing method at
|
||||
/// NLDBPDCNNCF.txt:12920). The old <c>[FromBody] BareBanRequest</c>
|
||||
/// rejected the form body with 415 before the handler ran, and named
|
||||
/// PlayerId/BanType — keys the client never sends — so in-room bans
|
||||
/// never persisted. No response reader is attached at the call site
|
||||
/// (plain BDBHCEGKGDC dispatch, :13065), so the ack shape is free.
|
||||
/// </summary>
|
||||
[HttpPost("rooms/{roomId:long}/bans")]
|
||||
public Task<IActionResult> BareBan(long roomId, [FromBody] BareBanRequest req) =>
|
||||
BanFromRoom(new BanFromRoomRequest
|
||||
[HttpPost("roomserver/rooms/{roomId:long}/bans")]
|
||||
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
|
||||
public async Task<IActionResult> BareBan(long roomId)
|
||||
{
|
||||
var room = await RequireOwnedRoomAsync(roomId);
|
||||
if (room is null) return NotFound();
|
||||
|
||||
var payload = await ReadPayloadAsync();
|
||||
// banMask IS the BanType enum on the wire. Default 1 (Permanent):
|
||||
// the in-room panel's only ban action is a permanent ban.
|
||||
var banType = IntValue(payload, "banMask", "banType", "type", "BanType") ?? 1;
|
||||
var reason = RawValue(payload, "reason", "Reason") ?? string.Empty;
|
||||
DateTime? until = DateTime.TryParse(Value(payload, "until", "Until"), out var parsedUntil)
|
||||
? (DateTime?)parsedUntil.ToUniversalTime()
|
||||
: null;
|
||||
|
||||
// "id" is the 2023 key; the others are 2020.12 / admin spellings. The
|
||||
// payload map is case-insensitive, so PascalCase variants fall out.
|
||||
var ids = Values(payload, "id", "playerId", "accountId")
|
||||
.Select(v => long.TryParse(v, out var n) ? n : 0L)
|
||||
.Where(n => n > 0)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
if (ids.Count == 0) return BadRequest(new { error = "missing_player" });
|
||||
|
||||
var existing = await db.RoomBans
|
||||
.Where(b => b.RoomId == roomId && ids.Contains(b.BannedPlayerId))
|
||||
.ToListAsync();
|
||||
foreach (var id in ids)
|
||||
{
|
||||
RoomId = roomId,
|
||||
PlayerId = req.PlayerId,
|
||||
BanType = req.BanType,
|
||||
Until = req.Until,
|
||||
Reason = req.Reason,
|
||||
});
|
||||
// Re-banning an already-banned player updates the row rather than
|
||||
// stacking duplicates — the panel re-sends the whole selection.
|
||||
var row = existing.FirstOrDefault(b => b.BannedPlayerId == id);
|
||||
if (row is null)
|
||||
{
|
||||
db.RoomBans.Add(new RoomBanEntity
|
||||
{
|
||||
RoomId = roomId,
|
||||
BannedPlayerId = id,
|
||||
BannedByPlayerId = Me,
|
||||
BanType = banType,
|
||||
Until = until,
|
||||
Reason = reason.Length > 512 ? reason[..512] : reason,
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
row.BanType = banType;
|
||||
row.Until = until;
|
||||
if (reason.Length > 0) row.Reason = reason.Length > 512 ? reason[..512] : reason;
|
||||
}
|
||||
}
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new { Result = 0, Banned = ids.Count });
|
||||
}
|
||||
|
||||
[HttpDelete("rooms/{roomId:long}/bans/{playerId:long}")]
|
||||
[HttpDelete("roomserver/rooms/{roomId:long}/bans/{playerId:long}")]
|
||||
public async Task<IActionResult> BareUnban(long roomId, long playerId)
|
||||
{
|
||||
var room = await RequireOwnedRoomAsync(roomId);
|
||||
@@ -1494,11 +1650,59 @@ public class RoomsModerationController(DorkNetDbContext db) : ControllerBase
|
||||
return Ok(new { Removed = rows.Count });
|
||||
}
|
||||
|
||||
/// <summary>POST <c>rooms/{id}/bans/import</c> — "import ban list"
|
||||
/// (verb byte 2 = POST at NLDBPDCNNCF.txt:13207). The client names a
|
||||
/// SOURCE ROOM whose bans should be copied in — a single Int64 form key
|
||||
/// <c>sourceRoomId</c> (NLDBPDCNNCF/KGDNOLMFENE:015 boxes System.Int64,
|
||||
/// :020 is the key) — NOT an explicit ban list. The old
|
||||
/// <c>[FromBody] ImportRoomBansRequest</c> therefore 415'd on the form
|
||||
/// body AND modelled the wrong contract. The JSON ban-list shape stays
|
||||
/// available on the legacy <c>api/rooms/v1/importroombans</c>.</summary>
|
||||
[HttpPost("rooms/{roomId:long}/bans/import")]
|
||||
public Task<IActionResult> BareBansImport(long roomId, [FromBody] ImportRoomBansRequest req)
|
||||
[HttpPost("roomserver/rooms/{roomId:long}/bans/import")]
|
||||
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
|
||||
public async Task<IActionResult> BareBansImport(long roomId)
|
||||
{
|
||||
req.RoomId = roomId;
|
||||
return ImportRoomBans(req);
|
||||
var target = await RequireOwnedRoomAsync(roomId);
|
||||
if (target is null) return NotFound();
|
||||
|
||||
var payload = await ReadPayloadAsync();
|
||||
// Deliberately NOT aliased to "roomId" — that name means the TARGET
|
||||
// room elsewhere in this surface and would silently self-import.
|
||||
var sourceRoomId = LongValue(payload, "sourceRoomId", "SourceRoomId") ?? 0;
|
||||
if (sourceRoomId <= 0) return BadRequest(new { error = "missing_source_room" });
|
||||
if (sourceRoomId == roomId) return Ok(new { Imported = 0 });
|
||||
|
||||
// Owner-gate BOTH rooms — importing bulk-reads the source room's ban
|
||||
// list, which is owner-only information (see RoomBansList).
|
||||
var source = await RequireOwnedRoomAsync(sourceRoomId);
|
||||
if (source is null) return Forbid();
|
||||
|
||||
var sourceBans = await db.RoomBans
|
||||
.Where(b => b.RoomId == sourceRoomId)
|
||||
.ToListAsync();
|
||||
var seen = new HashSet<long>(await db.RoomBans
|
||||
.Where(b => b.RoomId == roomId)
|
||||
.Select(b => b.BannedPlayerId)
|
||||
.ToListAsync());
|
||||
|
||||
var imported = 0;
|
||||
foreach (var b in sourceBans)
|
||||
{
|
||||
if (!seen.Add(b.BannedPlayerId)) continue;
|
||||
db.RoomBans.Add(new RoomBanEntity
|
||||
{
|
||||
RoomId = roomId,
|
||||
BannedPlayerId = b.BannedPlayerId,
|
||||
BannedByPlayerId = Me,
|
||||
BanType = b.BanType,
|
||||
Until = b.Until,
|
||||
Reason = b.Reason,
|
||||
});
|
||||
imported++;
|
||||
}
|
||||
if (imported > 0) await db.SaveChangesAsync();
|
||||
return Ok(new { Imported = imported });
|
||||
}
|
||||
|
||||
private async Task<IActionResult> ApplyAndReturn(long roomId, Action<RoomEntity> mutator)
|
||||
@@ -1509,6 +1713,33 @@ public class RoomsModerationController(DorkNetDbContext db) : ControllerBase
|
||||
room.UpdatedAt = DateTime.UtcNow;
|
||||
try { await db.SaveChangesAsync(); }
|
||||
catch (DbUpdateException) { return Conflict(new { Result = 2 }); }
|
||||
return Ok(Services.RoomService.ToWireRoom(room));
|
||||
return Ok(await BuildRoomDetailsAsync(room));
|
||||
}
|
||||
|
||||
/// <summary>The response body EVERY bare-path room mutation must return.
|
||||
///
|
||||
/// The 2023 roomserver client dispatches all of them through the same
|
||||
/// typed helper (<c>0x1835E50A0</c>) with the same response-reader token
|
||||
/// <c>[0x1884C1948]</c> — see NLDBPDCNNCF.txt:5366 (description),
|
||||
/// :5691 (tags), :6006 (accessibility), :6169 (restrictions),
|
||||
/// :6335 (cloning), :6710 (automute), :6876 (comments), :7195 (min_level),
|
||||
/// :7360 (allow_new_users), :7859 (promo_images), :8178 (promo_external),
|
||||
/// :8516 (loadscreen), :10826 (modify). That is the SAME token the clone
|
||||
/// call site uses (:4815), which the repo already established is the full
|
||||
/// FGCPNAACHIK details object built by BuildRoomServerDetails (see
|
||||
/// <see cref="BareClone"/>). The slim RoomService.ToWireRoom has no
|
||||
/// SubRooms/Roles/Tags/LoadScreens/PromoImages/DataBlob, so the strict
|
||||
/// reader's dispose-walk NRE'd on the missing nested lists and every
|
||||
/// mutation reported failure even though it had persisted.</summary>
|
||||
private async Task<object> BuildRoomDetailsAsync(RoomEntity room)
|
||||
{
|
||||
var scenes = await db.RoomScenes
|
||||
.Where(s => s.RoomId == room.Id)
|
||||
.OrderBy(s => s.OrderIndex)
|
||||
.ToListAsync();
|
||||
var roles = await db.RoomRoles
|
||||
.Where(r => r.RoomId == room.Id)
|
||||
.ToListAsync();
|
||||
return RoomsController.BuildRoomServerDetails(room, scenes, roles: roles);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,6 +40,16 @@ public class StorefrontsBuyController(
|
||||
public int StorefrontType { get; set; }
|
||||
public int PurchasableItemId { get; set; }
|
||||
public int CurrencyType { get; set; } = 2;
|
||||
|
||||
/// <summary>The price the tile displayed, sent as a stale-price guard.
|
||||
/// The field was missing here, so buyTier/buyElite had nothing to
|
||||
/// validate against and fell back to a flat cost. Verified on the
|
||||
/// 2023-03-21 build: <c>Nullable<Int64></c> at +32 in
|
||||
/// <c>RecNet.Runtime/DCFKEFHJAGC_NestedType_RequestPurchaseItemDTO.txt:123-137</c>,
|
||||
/// wire key <c>"RequestedPrice"</c> in the generated JSON contract
|
||||
/// <c>RecNet.Runtime/FPLGJGDJIJG.txt:534</c>.</summary>
|
||||
public long? RequestedPrice { get; set; }
|
||||
|
||||
public long? CouponConsumablePlayerMappingId { get; set; }
|
||||
public GiftDto? Gift { get; set; }
|
||||
}
|
||||
@@ -185,36 +195,75 @@ public class StorefrontsBuyController(
|
||||
isGift ? null : gift.Id));
|
||||
}
|
||||
|
||||
/// <summary>POST <c>api/storefronts/v2/buyTier</c> — season-pass "skip a
|
||||
/// tier" button. The tier is an ordinary StoreItems row on the
|
||||
/// <c>season:{n}</c> storefront (see StorefrontsController.Season), so its
|
||||
/// real price is charged rather than a flat 1 token, and the purchase is
|
||||
/// recorded as a <c>seasonTier:*</c> progress row so the season page can
|
||||
/// count how many tiers the player bought.</summary>
|
||||
[HttpPost("api/storefronts/v2/buyTier")]
|
||||
public async Task<IActionResult> BuyTier([FromBody] RequestPurchaseItemDto req)
|
||||
{
|
||||
// Season-pass tier: no per-item table, so we just deduct a
|
||||
// small fixed cost (1 token) per tier and ack. A full
|
||||
// season-pass system would have a SeasonProgressEntity.
|
||||
if (req is null) return BadRequest("missing body");
|
||||
var pid = Me;
|
||||
const int TierCost = (int)StoreService.StoreItemPrice;
|
||||
var item = await db.StoreItems
|
||||
.FirstOrDefaultAsync(s => s.Id == req.PurchasableItemId && s.IsActive);
|
||||
// Tiers that aren't seeded as StoreItems still cost the catalogue
|
||||
// default so the button isn't free on a bare install.
|
||||
var cost = Math.Max(0, item?.Price ?? StoreService.StoreItemPrice);
|
||||
|
||||
var balance = await level.GetBalanceAsync(pid, req.CurrencyType);
|
||||
if (balance < TierCost) return Ok(BalanceUpdateResponse(balance, req.CurrencyType));
|
||||
var newBalance = await level.GrantCurrencyAsync(pid, req.CurrencyType, -TierCost,
|
||||
$"buyTier:{req.PurchasableItemId}");
|
||||
// Stale-price guard: the client sends the price its tile showed.
|
||||
if (req.RequestedPrice is long requested && requested != cost)
|
||||
return Ok(BalanceUpdateResponse(balance, req.CurrencyType));
|
||||
if (balance < cost) return Ok(BalanceUpdateResponse(balance, req.CurrencyType));
|
||||
|
||||
var newBalance = cost == 0
|
||||
? balance
|
||||
: await level.GrantCurrencyAsync(pid, req.CurrencyType, -cost,
|
||||
$"buyTier:{req.PurchasableItemId}");
|
||||
await RecordSeasonProgressAsync(pid,
|
||||
$"seasonTier:{req.StorefrontType}:{req.PurchasableItemId}");
|
||||
return Ok(BalanceUpdateResponse(newBalance, req.CurrencyType));
|
||||
}
|
||||
|
||||
/// <summary>POST <c>api/storefronts/v2/buyElite</c> — season "elite
|
||||
/// upgrade" (the paid premium track). There is no real-money flow on a
|
||||
/// private server, so it is priced in tokens off the same StoreItems row
|
||||
/// the season storefront advertised. The flag is persisted as a
|
||||
/// <c>seasonElite:*</c> progress row.</summary>
|
||||
[HttpPost("api/storefronts/v2/buyElite")]
|
||||
public async Task<IActionResult> BuyElite([FromBody] RequestPurchaseItemDto req)
|
||||
{
|
||||
// Elite-tier purchase (paid premium track). Without real
|
||||
// money flow on a private server we just ack.
|
||||
if (req is null) return BadRequest("missing body");
|
||||
var pid = Me;
|
||||
var eliteKey = $"seasonElite:{req.StorefrontType}";
|
||||
var balance = await level.GetBalanceAsync(pid, req.CurrencyType);
|
||||
return Ok(BalanceUpdateResponse(balance, req.CurrencyType));
|
||||
|
||||
// Already upgraded — ack without charging again so a replayed tap
|
||||
// can't drain the wallet.
|
||||
if (await db.ObjectiveProgress.AnyAsync(o => o.PlayerId == pid && o.Key == eliteKey))
|
||||
return Ok(BalanceUpdateResponse(balance, req.CurrencyType));
|
||||
|
||||
var item = await db.StoreItems
|
||||
.FirstOrDefaultAsync(s => s.Id == req.PurchasableItemId && s.IsActive);
|
||||
var cost = Math.Max(0, item?.Price ?? StoreService.StoreItemPrice);
|
||||
if (req.RequestedPrice is long requested && requested != cost)
|
||||
return Ok(BalanceUpdateResponse(balance, req.CurrencyType));
|
||||
if (balance < cost) return Ok(BalanceUpdateResponse(balance, req.CurrencyType));
|
||||
|
||||
var newBalance = cost == 0
|
||||
? balance
|
||||
: await level.GrantCurrencyAsync(pid, req.CurrencyType, -cost,
|
||||
$"buyElite:{req.PurchasableItemId}");
|
||||
await RecordSeasonProgressAsync(pid, eliteKey);
|
||||
return Ok(BalanceUpdateResponse(newBalance, req.CurrencyType));
|
||||
}
|
||||
|
||||
/// <summary>GET <c>api/storefronts/v1/buyRoomKey</c> — purchase a
|
||||
/// persisted room key. The 2020.12 client sends roomKeyId and the
|
||||
/// price it displayed so we reject stale-price purchases.</summary>
|
||||
[HttpGet("api/storefronts/v1/buyRoomKey")]
|
||||
[HttpGet("api/storefronts/v1/PurchaseRoomKeyWithCurrency")]
|
||||
public async Task<IActionResult> BuyRoomKey(
|
||||
[FromQuery] long roomKeyId,
|
||||
[FromQuery] int requestedPrice)
|
||||
@@ -252,93 +301,369 @@ public class StorefrontsBuyController(
|
||||
return Ok(RoomKeyPurchaseResponse(RoomKeyStatus.Success, key, newBalance));
|
||||
}
|
||||
|
||||
public sealed class PurchaseRoomKeyWithCurrencyRequest
|
||||
{
|
||||
public long RoomKeyId { get; set; }
|
||||
public long RequestedPrice { get; set; }
|
||||
public Guid RequestedPurchaseCurrencyId { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>POST <c>api/storefronts/v1/PurchaseRoomKeyWithCurrency</c> —
|
||||
/// buying a room key with a ROOM currency instead of tokens.
|
||||
///
|
||||
/// This was only registered as a <c>[HttpGet]</c> alias of BuyRoomKey, so
|
||||
/// every attempt 405'd. The 2023-03-21 client issues it with verb 2 (POST —
|
||||
/// <c>RecNet.Runtime/DCFKEFHJAGC.txt:7426</c>) and three form fields,
|
||||
/// <c>RoomKeyId</c> (Int64, :7439), <c>RequestedPrice</c> (Int64, :7451) and
|
||||
/// <c>RequestedPurchaseCurrencyId</c> (Guid, :7464); the DTO signature is
|
||||
/// <c>INBGDNMFJBP(Int64, Int64, Guid)</c> at :7206.
|
||||
///
|
||||
/// The response is <c>RoomKeyPurchaseWithCurrencyResponseDTO</c>, whose
|
||||
/// generated contract has exactly two members — <c>Balance</c> and
|
||||
/// <c>RoomKeyResponse</c> (<c>RecNet.Runtime/EDMKBBPGJAB.txt:203/222</c>) —
|
||||
/// where Balance is the ROOM-currency balance row
|
||||
/// {AccountId, CurrencyId, Balance, ModifiedAt}
|
||||
/// (<c>RecNet.Runtime/DPNLANKOHON.txt:331-414</c>), NOT the token
|
||||
/// BalanceUpdateResponse that the GET alias returned.</summary>
|
||||
[HttpPost("api/storefronts/v1/PurchaseRoomKeyWithCurrency")]
|
||||
public async Task<IActionResult> PurchaseRoomKeyWithCurrency(
|
||||
[ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))]
|
||||
PurchaseRoomKeyWithCurrencyRequest req)
|
||||
{
|
||||
var pid = Me;
|
||||
var currency = await db.RoomCurrencies.FirstOrDefaultAsync(
|
||||
c => c.PublicId == req.RequestedPurchaseCurrencyId && !c.IsDeleted);
|
||||
var key = await db.RoomKeys.FirstOrDefaultAsync(
|
||||
k => k.Id == req.RoomKeyId && !k.IsDeleted);
|
||||
|
||||
RoomCurrencyBalanceEntity? row = null;
|
||||
if (currency is not null)
|
||||
{
|
||||
var currencyRowId = currency.Id;
|
||||
row = await db.RoomCurrencyBalances.FirstOrDefaultAsync(
|
||||
b => b.PlayerId == pid && b.RoomCurrencyId == currencyRowId);
|
||||
}
|
||||
|
||||
if (currency is null)
|
||||
return Ok(RoomKeyCurrencyPurchaseResponse(
|
||||
RoomKeyStatus.InvalidParameters, key, pid, req.RequestedPurchaseCurrencyId, null));
|
||||
if (key is null)
|
||||
return Ok(RoomKeyCurrencyPurchaseResponse(
|
||||
RoomKeyStatus.DoesNotExist, null, pid, currency.PublicId, row));
|
||||
if (key.CreatorPlayerId == pid)
|
||||
return Ok(RoomKeyCurrencyPurchaseResponse(
|
||||
RoomKeyStatus.PlayerAlreadyOwns, key, pid, currency.PublicId, row));
|
||||
var roomKeyRowId = key.Id;
|
||||
if (await db.RoomKeyPurchases.AnyAsync(p => p.RoomKeyId == roomKeyRowId && p.PlayerId == pid))
|
||||
return Ok(RoomKeyCurrencyPurchaseResponse(
|
||||
RoomKeyStatus.PlayerAlreadyOwns, key, pid, currency.PublicId, row));
|
||||
if (req.RequestedPrice != key.Price)
|
||||
return Ok(RoomKeyCurrencyPurchaseResponse(
|
||||
RoomKeyStatus.InvalidParameters, key, pid, currency.PublicId, row));
|
||||
|
||||
var balance = row?.Balance ?? 0;
|
||||
if (balance < key.Price)
|
||||
return Ok(RoomKeyCurrencyPurchaseResponse(
|
||||
RoomKeyStatus.PurchaseFailed, key, pid, currency.PublicId, row));
|
||||
|
||||
if (row is null)
|
||||
{
|
||||
row = new RoomCurrencyBalanceEntity
|
||||
{
|
||||
PlayerId = pid,
|
||||
RoomCurrencyId = currency.Id,
|
||||
Balance = 0,
|
||||
};
|
||||
db.RoomCurrencyBalances.Add(row);
|
||||
}
|
||||
row.Balance = Math.Max(0, row.Balance - key.Price);
|
||||
row.UpdatedAt = DateTime.UtcNow;
|
||||
db.RoomKeyPurchases.Add(new RoomKeyPurchaseEntity
|
||||
{
|
||||
RoomKeyId = key.Id,
|
||||
PlayerId = pid,
|
||||
PaidPrice = key.Price,
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return Ok(RoomKeyCurrencyPurchaseResponse(
|
||||
RoomKeyStatus.Success, key, pid, currency.PublicId, row));
|
||||
}
|
||||
|
||||
/// <summary>GET <c>api/storefronts/v2/buyInvention</c> — MakerPen store
|
||||
/// purchase of a paid invention. Verb is 0/GET
|
||||
/// (<c>RecNet.Runtime/DCFKEFHJAGC.txt:6772</c>) with query fields
|
||||
/// <c>inventionId</c> (Int64, :6785) and <c>requestedPrice</c> (Int32,
|
||||
/// :6797) — the latter was being ignored.
|
||||
///
|
||||
/// The response type is <c>InventionPurchaseResponseDTO</c>, whose contract
|
||||
/// is {<c>InventionResponse</c>, <c>BalanceUpdateResponse</c>}
|
||||
/// (<c>RecNet.Runtime/MAHHAAEGEEP.txt:215/242</c>). The handler used to
|
||||
/// return the bare token-balance wrapper, so <c>InventionResponse</c> was
|
||||
/// absent and the client got Status/Invention/InventionVersion = null —
|
||||
/// it could never confirm the purchase or spawn what it just bought.
|
||||
/// InventionResponse itself is {Status, Invention, InventionVersion}
|
||||
/// (<c>RecNet.Runtime/GMBGBPNMGBA.txt:255/274/290</c>) — the same triple
|
||||
/// InventionsController already returns from addversion.</summary>
|
||||
[HttpGet("api/storefronts/v2/buyInvention")]
|
||||
[HttpPost("api/storefronts/v2/buyInvention")]
|
||||
[HttpGet("api/storefronts/v2/buyInvention")]
|
||||
public async Task<IActionResult> BuyInvention([FromQuery] long inventionId, [FromQuery] int currencyType = 2)
|
||||
public async Task<IActionResult> BuyInvention(
|
||||
[FromQuery] long inventionId,
|
||||
[FromQuery] long? requestedPrice = null,
|
||||
[FromQuery] int currencyType = 2)
|
||||
{
|
||||
var pid = Me;
|
||||
var inv = await db.Inventions.FirstOrDefaultAsync(i => i.Id == inventionId && !i.IsDeleted && i.IsPublished);
|
||||
if (inv is null) return NotFound();
|
||||
var price = Math.Max(0, inv.Price);
|
||||
var balance = await level.GetBalanceAsync(pid, currencyType);
|
||||
if (balance < price) return Ok(BalanceUpdateResponse(balance, currencyType));
|
||||
var inv = await db.Inventions.FirstOrDefaultAsync(
|
||||
i => i.Id == inventionId && !i.IsDeleted && i.IsPublished);
|
||||
if (inv is null)
|
||||
return Ok(InventionPurchaseResponse(
|
||||
InventionStatusDoesNotExist, null, null, balance, currencyType));
|
||||
|
||||
var version = await db.InventionVersions
|
||||
.Where(v => v.InventionId == inv.Id)
|
||||
.OrderByDescending(v => v.VersionNumber)
|
||||
.FirstOrDefaultAsync();
|
||||
var price = Math.Max(0, inv.Price);
|
||||
|
||||
// Stale-price guard — the MakerPen tile sends the price it rendered.
|
||||
if (requestedPrice is long asked && asked != price)
|
||||
return Ok(InventionPurchaseResponse(
|
||||
InventionStatusInvalidParameters, inv, version, balance, currencyType));
|
||||
|
||||
var key = $"invention_purchase:{inv.Id}";
|
||||
var alreadyOwns = inv.CreatorPlayerId == pid
|
||||
|| await db.ObjectiveProgress.AnyAsync(o => o.PlayerId == pid && o.Key == key);
|
||||
if (alreadyOwns)
|
||||
{
|
||||
// Deliberately Success rather than PlayerAlreadyOwns (=19): the
|
||||
// MakerPen re-issues this call on every spawn of a paid creation,
|
||||
// and a non-zero status blocks the spawn.
|
||||
return Ok(InventionPurchaseResponse(
|
||||
InventionStatusSuccess, inv, version, balance, currencyType));
|
||||
}
|
||||
|
||||
if (balance < price)
|
||||
return Ok(InventionPurchaseResponse(
|
||||
InventionStatusPurchaseFailed, inv, version, balance, currencyType));
|
||||
|
||||
var newBalance = price == 0
|
||||
? balance
|
||||
: await level.GrantCurrencyAsync(pid, currencyType, -price, $"buyInvention:{inv.Id}");
|
||||
var key = $"invention_purchase:{inv.Id}";
|
||||
if (!await db.ObjectiveProgress.AnyAsync(o => o.PlayerId == pid && o.Key == key))
|
||||
db.ObjectiveProgress.Add(new ObjectiveProgressEntity
|
||||
{
|
||||
PlayerId = pid,
|
||||
Key = key,
|
||||
IsCompleted = true,
|
||||
ClearedAt = DateTime.UtcNow,
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
if (price > 0 && inv.CreatorPlayerId != pid)
|
||||
await level.GrantCurrencyAsync(inv.CreatorPlayerId, currencyType, price,
|
||||
$"sellInvention:{inv.Id}");
|
||||
|
||||
return Ok(InventionPurchaseResponse(
|
||||
InventionStatusSuccess, inv, version, newBalance, currencyType));
|
||||
}
|
||||
|
||||
/// <summary>Body of <c>buyForFreeGiftButton</c>. Unlike its siblings this
|
||||
/// route does NOT use form fields: the client serialises a
|
||||
/// <c>RequestPurchaseForFreeGiftButtonDTO</c> and posts it as the whole
|
||||
/// body (<c>RecNet.Runtime/DCFKEFHJAGC.txt:5910-5924</c>, verb 2 at :5912).
|
||||
/// Field offsets +16/+24/+40/+44/+48 in
|
||||
/// <c>DCFKEFHJAGC_NestedType_RequestPurchaseForFreeGiftButtonDTO.txt</c>,
|
||||
/// wire keys PurchasableItemId / CouponConsumablePlayerMappingId / Count /
|
||||
/// CreatorPlayerId / RequestedPrice in <c>RecNet.Runtime/IAPMDDFENDD.txt:395-502</c>.
|
||||
/// </summary>
|
||||
public sealed class FreeGiftButtonRequest
|
||||
{
|
||||
public int PurchasableItemId { get; set; }
|
||||
public long? CouponConsumablePlayerMappingId { get; set; }
|
||||
public int Count { get; set; } = 1;
|
||||
public int CreatorPlayerId { get; set; }
|
||||
public long? RequestedPrice { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>POST <c>api/storefronts/v1/buyForFreeGiftButton</c> — the
|
||||
/// in-room "free gift button" gadget handing an item to whoever pressed it.
|
||||
///
|
||||
/// The handler bound <c>[FromQuery] currencyType/price</c> while the client
|
||||
/// sends everything in the JSON body, so the requested item id never
|
||||
/// arrived and a hardcoded 25-token "FreeGiftButton" package was inserted
|
||||
/// instead — the gadget paid out the wrong reward on every press. Now the
|
||||
/// body is read and the requested StoreItems row is granted directly.
|
||||
///
|
||||
/// The response is the plain <c>BalanceResponseDTO</c>
|
||||
/// (<c>DCFKEFHJAGC.txt:6008/6055</c>) whose contract is exactly
|
||||
/// {Balance, CurrencyType, Platform} — <c>RecNet.Runtime/PECGEJAAMHB.txt:255/274/298</c>.
|
||||
/// There is no gift-package member, so nothing can be handed back through a
|
||||
/// gift box here; the item has to land in the inventory server-side.</summary>
|
||||
[HttpPost("api/storefronts/v1/buyForFreeGiftButton")]
|
||||
[HttpGet("api/storefronts/v1/buyForFreeGiftButton")]
|
||||
public async Task<IActionResult> BuyForFreeGiftButton(
|
||||
[ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))]
|
||||
FreeGiftButtonRequest req,
|
||||
[FromQuery] int currencyType = 2)
|
||||
{
|
||||
var pid = Me;
|
||||
var balance = await level.GetBalanceAsync(pid, currencyType);
|
||||
var item = await db.StoreItems
|
||||
.FirstOrDefaultAsync(s => s.Id == req.PurchasableItemId && s.IsActive);
|
||||
if (item is null) return Ok(BalanceResponse(balance, currencyType));
|
||||
|
||||
// RequestedPrice is what the gadget quoted, and it is the total for the
|
||||
// press — the gadget is normally free, so the item's catalogue price is
|
||||
// deliberately NOT charged here. CouponConsumablePlayerMappingId is
|
||||
// carried by the DTO but there is no coupon-mapping table to redeem it
|
||||
// against yet, so it is accepted and ignored.
|
||||
var price = Math.Max(0, req.RequestedPrice ?? 0);
|
||||
if (balance < price) return Ok(BalanceResponse(balance, currencyType));
|
||||
var newBalance = price == 0
|
||||
? balance
|
||||
: await level.GrantCurrencyAsync(pid, currencyType, -price,
|
||||
$"freeGiftButton:{item.Slug}");
|
||||
|
||||
await GrantPurchasedItemAsync(pid, item, Math.Clamp(req.Count, 1, 50));
|
||||
return Ok(BalanceResponse(newBalance, currencyType));
|
||||
}
|
||||
|
||||
/// <summary>Form body of <c>buyProgressionEventXpBoost</c>, verified at
|
||||
/// <c>RecNet.Runtime/DCFKEFHJAGC.txt:12431</c> (progressionEventId, Int64),
|
||||
/// :12444 (purchasableXpBoostId, Guid), :12456 (requestedPrice, Int32) and
|
||||
/// :12468 (expectedXp, Int32).</summary>
|
||||
public sealed class ProgressionEventXpBoostRequest
|
||||
{
|
||||
public long ProgressionEventId { get; set; }
|
||||
public Guid PurchasableXpBoostId { get; set; }
|
||||
public int RequestedPrice { get; set; }
|
||||
public int ExpectedXp { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>POST <c>api/storefronts/v1/buyProgressionEventXpBoost</c>.
|
||||
///
|
||||
/// Two defects: the handler bound <c>[FromQuery] currencyType/price</c> so
|
||||
/// none of the four form fields arrived (nothing charged, no XP granted),
|
||||
/// and it reused the shared BalanceUpdateResponse whose
|
||||
/// <c>BalanceUpdates[].Data</c> is a LIST. This route's response is
|
||||
/// <c>BalanceUpdateResponseDTO`1<PurchasedProgressionEventXpBoostDTO></c>
|
||||
/// (signature at <c>DCFKEFHJAGC.txt:12118</c>) and that family's inner Data
|
||||
/// is a SINGLE object — <c>get_Data</c> returns <c>DataTypeDTO</c>, not
|
||||
/// <c>List<DataTypeDTO></c>, in
|
||||
/// <c>DCFKEFHJAGC_NestedType_BalanceUpdateResponseDTO`1_NestedType_DEJIAFEKFCK.txt:83</c>
|
||||
/// (contrast the Purchase* variant at
|
||||
/// <c>..._PurchaseBalanceUpdateResponseDTO`1_NestedType_PKKNEGNNABE.txt:83</c>,
|
||||
/// which IS a List). An array there makes the reader throw.
|
||||
/// The payload object is {Xp:Int32} — <c>RecNet.Runtime/NNEJHCKLOHL.txt:139</c>.</summary>
|
||||
[HttpPost("api/storefronts/v1/buyProgressionEventXpBoost")]
|
||||
[HttpGet("api/storefronts/v1/buyProgressionEventXpBoost")]
|
||||
public async Task<IActionResult> BuyProgressionEventXpBoost(
|
||||
[ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))]
|
||||
ProgressionEventXpBoostRequest req,
|
||||
[FromQuery] int currencyType = 2)
|
||||
{
|
||||
var pid = Me;
|
||||
var price = Math.Max(0, req.RequestedPrice);
|
||||
var balance = await level.GetBalanceAsync(pid, currencyType);
|
||||
if (balance < price) return Ok(XpBoostResponse(balance, currencyType, 0));
|
||||
|
||||
var newBalance = price == 0
|
||||
? balance
|
||||
: await level.GrantCurrencyAsync(pid, currencyType, -price,
|
||||
$"progressionEventXpBoost:{req.ProgressionEventId}");
|
||||
|
||||
// The client tells us how much XP the boost it just paid for is worth;
|
||||
// clamped so a spoofed field can't mint arbitrary levels.
|
||||
var xp = Math.Clamp(req.ExpectedXp, 0, 100_000);
|
||||
if (xp > 0)
|
||||
await level.AwardXpAsync(pid, xp, $"progressionEventXpBoost:{req.ProgressionEventId}");
|
||||
|
||||
// Upsert: (PlayerId, Key) is unique, and a boost can legitimately be
|
||||
// bought more than once — a blind Add threw
|
||||
// "UNIQUE constraint failed" and turned the second purchase into a 500
|
||||
// AFTER the currency had already been taken.
|
||||
var progressKey = $"progressionEvent:{req.ProgressionEventId}:xpBoost:{req.PurchasableXpBoostId:N}";
|
||||
var progress = await db.ObjectiveProgress
|
||||
.FirstOrDefaultAsync(o => o.PlayerId == pid && o.Key == progressKey);
|
||||
if (progress is null)
|
||||
{
|
||||
db.ObjectiveProgress.Add(new ObjectiveProgressEntity
|
||||
{
|
||||
PlayerId = pid,
|
||||
Key = key,
|
||||
Key = progressKey,
|
||||
IsCompleted = true,
|
||||
ClearedAt = DateTime.UtcNow,
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
return Ok(BalanceUpdateResponse(newBalance, currencyType, null, null));
|
||||
}
|
||||
|
||||
[HttpPost("api/storefronts/v1/buyForFreeGiftButton")]
|
||||
[HttpGet("api/storefronts/v1/buyForFreeGiftButton")]
|
||||
public async Task<IActionResult> BuyForFreeGiftButton([FromQuery] int currencyType = 2, [FromQuery] int price = 0)
|
||||
{
|
||||
var pid = Me;
|
||||
var balance = await level.GetBalanceAsync(pid, currencyType);
|
||||
if (balance < price) return Ok(BalanceUpdateResponse(balance, currencyType));
|
||||
var newBalance = price == 0
|
||||
? balance
|
||||
: await level.GrantCurrencyAsync(pid, currencyType, -price, "freeGiftButton");
|
||||
db.GiftPackages.Add(new GiftPackageEntity
|
||||
else
|
||||
{
|
||||
RecipientPlayerId = pid,
|
||||
CurrencyType = 2,
|
||||
Currency = 25,
|
||||
Level = 1,
|
||||
PackageVariant = "FreeGiftButton",
|
||||
PackageMaterial = string.Empty,
|
||||
IsValid = true,
|
||||
SupportsCurrentPlatform = true,
|
||||
});
|
||||
progress.IsCompleted = true;
|
||||
progress.ClearedAt = DateTime.UtcNow;
|
||||
}
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(BalanceUpdateResponse(newBalance, currencyType));
|
||||
|
||||
return Ok(XpBoostResponse(newBalance, currencyType, xp));
|
||||
}
|
||||
|
||||
[HttpPost("api/storefronts/v1/buyProgressionEventXpBoost")]
|
||||
[HttpGet("api/storefronts/v1/buyProgressionEventXpBoost")]
|
||||
public async Task<IActionResult> BuyProgressionEventXpBoost([FromQuery] int currencyType = 2, [FromQuery] int price = 0)
|
||||
/// <summary>Form body of <c>buyPurchaseReminder</c>, verified at
|
||||
/// <c>RecNet.Runtime/DCFKEFHJAGC.txt:10203</c> (purchaseReminderId, Int32)
|
||||
/// and :10215 (requestedPrice, Int64).</summary>
|
||||
public sealed class PurchaseReminderRequest
|
||||
{
|
||||
public int PurchaseReminderId { get; set; }
|
||||
public long RequestedPrice { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>POST <c>api/storefronts/v1/buyPurchaseReminder</c> — buying the
|
||||
/// item a "you were looking at this" reminder popup advertises.
|
||||
///
|
||||
/// Was a stub returning <c>{Success:true}</c>; the client deserialises
|
||||
/// <c>JNPGKDJJPPM</c> = {Balance:Int64, CurrencyType, BalanceType, Data:
|
||||
/// List<GiftPackage>} (signature at <c>DCFKEFHJAGC.txt:9928</c>, keys at
|
||||
/// <c>RecNet.Runtime/OLIHBGPDPHF.txt:319/338/362/386</c>), so every field
|
||||
/// defaulted and the wallet read 0. Note this DTO really does spell its
|
||||
/// third member <c>BalanceType</c>, unlike the BalanceUpdateResponse family
|
||||
/// which renames the same property to <c>Platform</c>.
|
||||
///
|
||||
/// There is no reminder catalogue on a private server — the offer feed
|
||||
/// (<c>reminder/currentTokenBundles/v2</c>) is genuinely empty — so the
|
||||
/// reminder id is resolved as the StoreItems row it points at. Unknown ids
|
||||
/// charge nothing and return an empty Data list.</summary>
|
||||
[HttpPost("api/storefronts/v1/buyPurchaseReminder")]
|
||||
[HttpGet("api/storefronts/v1/buyPurchaseReminder")]
|
||||
public async Task<IActionResult> BuyPurchaseReminder(
|
||||
[ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))]
|
||||
PurchaseReminderRequest req,
|
||||
[FromQuery] int currencyType = 2)
|
||||
{
|
||||
var pid = Me;
|
||||
var balance = await level.GetBalanceAsync(pid, currencyType);
|
||||
if (balance < price) return Ok(BalanceUpdateResponse(balance, currencyType));
|
||||
var newBalance = price == 0
|
||||
var item = await db.StoreItems
|
||||
.FirstOrDefaultAsync(s => s.Id == req.PurchaseReminderId && s.IsActive);
|
||||
if (item is null)
|
||||
return Ok(PurchaseReminderResponse(balance, currencyType, null, null));
|
||||
// Stale-price guard; 0 means the popup didn't quote a price.
|
||||
if (req.RequestedPrice > 0 && req.RequestedPrice != item.Price)
|
||||
return Ok(PurchaseReminderResponse(balance, currencyType, null, null));
|
||||
if (balance < item.Price)
|
||||
return Ok(PurchaseReminderResponse(balance, currencyType, null, null));
|
||||
|
||||
var newBalance = item.Price == 0
|
||||
? balance
|
||||
: await level.GrantCurrencyAsync(pid, currencyType, -price, "progressionEventXpBoost");
|
||||
: await level.GrantCurrencyAsync(pid, currencyType, -item.Price,
|
||||
$"buyPurchaseReminder:{item.Slug}");
|
||||
var giftId = await GrantWithGiftBoxAsync(pid, item, currencyType);
|
||||
|
||||
db.ObjectiveProgress.Add(new ObjectiveProgressEntity
|
||||
{
|
||||
PlayerId = pid,
|
||||
Key = $"progressionEvent:xpBoost:{Guid.NewGuid():N}",
|
||||
Key = $"purchaseReminder:{req.PurchaseReminderId}",
|
||||
IsCompleted = true,
|
||||
ClearedAt = DateTime.UtcNow,
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(BalanceUpdateResponse(newBalance, currencyType));
|
||||
}
|
||||
|
||||
[HttpPost("api/storefronts/v1/buyPurchaseReminder")]
|
||||
[HttpGet("api/storefronts/v1/buyPurchaseReminder")]
|
||||
public async Task<IActionResult> BuyPurchaseReminder()
|
||||
{
|
||||
db.ObjectiveProgress.Add(new ObjectiveProgressEntity
|
||||
{
|
||||
PlayerId = Me,
|
||||
Key = $"purchaseReminder:{Guid.NewGuid():N}",
|
||||
IsCompleted = true,
|
||||
ClearedAt = DateTime.UtcNow,
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new { Success = true });
|
||||
return Ok(PurchaseReminderResponse(newBalance, currencyType, item.Slug, giftId));
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────
|
||||
@@ -438,6 +763,53 @@ public class StorefrontsBuyController(
|
||||
// GiftPackageEntity.Id. The previous code used
|
||||
// DateTime.UtcNow.Ticks as a fake id.
|
||||
var giftId = giftPackageId ?? DateTime.UtcNow.Ticks;
|
||||
|
||||
return new
|
||||
{
|
||||
// BalanceResponseDTO required trio.
|
||||
Balance = balance,
|
||||
CurrencyType = currencyType,
|
||||
// The 2023-03-21 build renames the base DTO's third member to
|
||||
// "Platform" on the whole BalanceUpdateResponse family — its
|
||||
// generated contract table lists exactly BalanceUpdates / Balance /
|
||||
// CurrencyType / Platform and has NO "BalanceType"
|
||||
// (RecNet.Runtime/KILGEFOENFN.txt:319/346/362/386, identical in
|
||||
// BBEFAELEANL/IBAPHBCOBHL/ALGKCAKCFAJ/AIEJFLGKIGN). Emitting only
|
||||
// BalanceType left Platform defaulted to 0 on the client. Both are
|
||||
// shipped: the 2020.12 watch reads BalanceType and unknown members
|
||||
// are ignored on both readers.
|
||||
Platform = 0,
|
||||
BalanceType = 0,
|
||||
// PurchaseBalanceUpdateResponseDTO.BalanceUpdates =
|
||||
// List<BalanceUpdateDTO<GiftPackage>>. Note the Data member here is
|
||||
// a LIST (…_PurchaseBalanceUpdateResponseDTO`1_NestedType_PKKNEGNNABE.txt:83)
|
||||
// — the plain BalanceUpdateResponseDTO family uses a single object.
|
||||
BalanceUpdates = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
UpdateResponse = 0, // UpdateResponseTypes.Success
|
||||
Data = grantedSlug is null
|
||||
? Array.Empty<object>()
|
||||
: new object[] { GiftPackageWire(giftId, currencyType, grantedSlug) },
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>One <c>FFFIMAGLKEG/FHMABOHAEED</c> gift package.
|
||||
///
|
||||
/// REQUIRED via Util.GetKey — KeyNotFoundException on miss. Verified keys at
|
||||
/// NLEKGNENMCO_NestedType_LOCNECLOHCA.txt:792-946 and re-confirmed against
|
||||
/// the 2023-03-21 contract table RecNet.Runtime/FKANCKLCCDI.txt:1051-1374:
|
||||
/// Id, Message, FromPlayerId, AvatarItemType, AvatarItemDesc,
|
||||
/// ConsumableItemDesc, EquipmentPrefabName, EquipmentModificationGuid,
|
||||
/// Platform, PlatformsToSpawnOn, BalanceType, CurrencyType, Currency, Xp,
|
||||
/// GiftRarity, GiftContext. Missing any → "Failed to download gifts:
|
||||
/// Malformed Response" (and on a purchase path, the buy times out because
|
||||
/// the response never deserializes).</summary>
|
||||
private static object GiftPackageWire(long giftId, int currencyType, string? grantedSlug)
|
||||
{
|
||||
// Pull the GUID off the slug for wardrobe-* purchases so the
|
||||
// watch's GiftPackage receives the AvatarItem GUID and can
|
||||
// render the unlock animation. dorknet-* slugs leave the desc
|
||||
@@ -460,17 +832,8 @@ public class StorefrontsBuyController(
|
||||
}
|
||||
}
|
||||
|
||||
var giftPackage = new
|
||||
return new
|
||||
{
|
||||
// REQUIRED via Util.GetKey — KeyNotFoundException on miss.
|
||||
// Verified keys at NLEKGNENMCO_NestedType_LOCNECLOHCA.txt:792-946:
|
||||
// Id, Message, FromPlayerId, AvatarItemType, AvatarItemDesc,
|
||||
// ConsumableItemDesc, EquipmentPrefabName,
|
||||
// EquipmentModificationGuid, Platform, PlatformsToSpawnOn,
|
||||
// BalanceType, CurrencyType, Currency, Xp, GiftRarity,
|
||||
// GiftContext. Missing any → "Failed to download gifts:
|
||||
// Malformed Response" (and on a purchase path, the buy
|
||||
// times out because the response never deserializes).
|
||||
Id = giftId,
|
||||
Platform = 0,
|
||||
PlatformsToSpawnOn = -1,
|
||||
@@ -493,28 +856,142 @@ public class StorefrontsBuyController(
|
||||
Consumed = false,
|
||||
ErrorMessage = string.Empty,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Bare <c>BalanceResponseDTO</c> — {Balance, CurrencyType,
|
||||
/// Platform} and nothing else, per the generated contract at
|
||||
/// <c>RecNet.Runtime/PECGEJAAMHB.txt:255/274/298</c>. BalanceType is kept
|
||||
/// alongside Platform for the 2020.12 watch, which reads that spelling.
|
||||
/// </summary>
|
||||
private static object BalanceResponse(long balance, int currencyType) => new
|
||||
{
|
||||
Balance = balance,
|
||||
CurrencyType = currencyType,
|
||||
Platform = 0,
|
||||
BalanceType = 0,
|
||||
};
|
||||
|
||||
/// <summary><c>BalanceUpdateResponseDTO`1<PurchasedProgressionEventXpBoostDTO></c>.
|
||||
/// Data is a SINGLE object here, not a list — see the BuyProgressionEventXpBoost
|
||||
/// remarks for the ISIL citation.</summary>
|
||||
private static object XpBoostResponse(long balance, int currencyType, int xp) => new
|
||||
{
|
||||
Balance = balance,
|
||||
CurrencyType = currencyType,
|
||||
Platform = 0,
|
||||
BalanceType = 0,
|
||||
BalanceUpdates = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
UpdateResponse = 0, // UpdateResponseTypes.Success
|
||||
Data = new { Xp = xp },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/// <summary><c>JNPGKDJJPPM</c> — the buyPurchaseReminder response.
|
||||
/// {Balance, CurrencyType, BalanceType, Data:List<GiftPackage>}
|
||||
/// (<c>RecNet.Runtime/OLIHBGPDPHF.txt:319/338/362/386</c>). "Platform" is
|
||||
/// emitted as an inert alias because this one DTO is the odd one out that
|
||||
/// really does use "BalanceType".</summary>
|
||||
private static object PurchaseReminderResponse(long balance, int currencyType,
|
||||
string? grantedSlug, long? giftPackageId) => new
|
||||
{
|
||||
Balance = balance,
|
||||
CurrencyType = currencyType,
|
||||
BalanceType = 0,
|
||||
Platform = 0,
|
||||
Data = grantedSlug is null
|
||||
? Array.Empty<object>()
|
||||
: new[] { GiftPackageWire(giftPackageId ?? DateTime.UtcNow.Ticks, currencyType, grantedSlug) },
|
||||
};
|
||||
|
||||
// InventionResponse.Status values, from the name-preserved enum
|
||||
// ENOCILAAKGN in the 2023.06.21 dump
|
||||
// (Recnet-old/dist/RecRoom-2023.06.21-steam/il2cppdump/dump.cs:1268793-1268840).
|
||||
private const int InventionStatusSuccess = 0;
|
||||
private const int InventionStatusInvalidParameters = 1;
|
||||
private const int InventionStatusDoesNotExist = 7;
|
||||
private const int InventionStatusPurchaseFailed = 30;
|
||||
|
||||
private static object InventionPurchaseResponse(int status,
|
||||
InventionEntity? inv, InventionVersionEntity? version,
|
||||
long balance, int currencyType)
|
||||
{
|
||||
object? inventionWire = inv is null ? null : InventionWire(inv);
|
||||
object? versionWire = version is null ? null : InventionVersionWire(version);
|
||||
return new
|
||||
{
|
||||
// BalanceResponseDTO required trio.
|
||||
Balance = balance,
|
||||
CurrencyType = currencyType,
|
||||
BalanceType = 0,
|
||||
// PurchaseBalanceUpdateResponseDTO.BalanceUpdates =
|
||||
// List<BalanceUpdateDTO<GiftPackage>>.
|
||||
BalanceUpdates = new[]
|
||||
InventionResponse = new
|
||||
{
|
||||
new
|
||||
{
|
||||
UpdateResponse = 0, // UpdateResponseTypes.Success
|
||||
Data = grantedSlug is null
|
||||
? Array.Empty<object>()
|
||||
: new object[] { giftPackage },
|
||||
},
|
||||
Status = status,
|
||||
Invention = inventionWire,
|
||||
InventionVersion = versionWire,
|
||||
},
|
||||
// InventionPurchaseResponseDTO/EPFIAEMPKJK is
|
||||
// BalanceUpdateResponseDTO`1<Invention> — it extends
|
||||
// BalanceResponseDTO (BalanceUpdates at +32) and its inner Data is
|
||||
// an Invention, not a balance delta, so the list is left empty and
|
||||
// the client takes the balance off the outer trio.
|
||||
BalanceUpdateResponse = new
|
||||
{
|
||||
Balance = balance,
|
||||
CurrencyType = currencyType,
|
||||
Platform = 0,
|
||||
BalanceType = 0,
|
||||
BalanceUpdates = Array.Empty<object>(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Local copy of InventionsController's invention wire shape —
|
||||
/// that builder is private to its own controller, and the buyInvention
|
||||
/// response has to carry the same object. Keep the two in sync; the field
|
||||
/// list is documented there (RecNet.Runtime/IOEPPCKGBFL.txt:1447-1939 is the
|
||||
/// 2023-03-21 key table).</summary>
|
||||
private static object InventionWire(InventionEntity i) => new
|
||||
{
|
||||
InventionId = i.Id,
|
||||
ReplicationId = string.IsNullOrEmpty(i.ReplicationId)
|
||||
? Guid.Empty.ToString("D") : i.ReplicationId,
|
||||
CreatorPlayerId = (int)i.CreatorPlayerId,
|
||||
i.Name,
|
||||
i.Description,
|
||||
i.ImageName,
|
||||
i.CurrentVersionNumber,
|
||||
i.IsPublished,
|
||||
AllowTrial = true,
|
||||
ModifiedAt = i.UpdatedAt,
|
||||
i.CreatedAt,
|
||||
i.FirstPublishedAt,
|
||||
i.CreationRoomId,
|
||||
i.NumPlayersHaveUsedInRoom,
|
||||
NumDownloads = i.SpawnCount,
|
||||
i.CheerCount,
|
||||
i.CreatorPermission,
|
||||
i.GeneralPermission,
|
||||
IsAGInvention = i.IsAgInvention,
|
||||
i.Price,
|
||||
HideFromPlayer = false,
|
||||
};
|
||||
|
||||
/// <summary>Local copy of InventionsController's version wire shape.
|
||||
/// The 2023-03-21 reader also looks for ChipsCost, CloudVariablesCost and
|
||||
/// BlobHash (RecNet.Runtime/OLPHKLCPFEF.txt:786/810/858); none has a column
|
||||
/// on InventionVersionEntity, so they are omitted rather than emitted as
|
||||
/// permanent zeros/nulls.</summary>
|
||||
private static object InventionVersionWire(InventionVersionEntity v) => new
|
||||
{
|
||||
v.InventionId,
|
||||
ReplicationId = string.IsNullOrEmpty(v.ReplicationId)
|
||||
? Guid.Empty.ToString("D") : v.ReplicationId,
|
||||
v.VersionNumber,
|
||||
v.InstantiationCost,
|
||||
v.LightsCost,
|
||||
v.BlobName,
|
||||
};
|
||||
|
||||
private static object RoomKeyPurchaseResponse(RoomKeyStatus status, RoomKeyEntity? key, long balance) => new
|
||||
{
|
||||
RoomKeyResponse = RoomKeysController.RoomKeyResponse(status, key),
|
||||
@@ -522,8 +999,111 @@ public class StorefrontsBuyController(
|
||||
{
|
||||
Balance = balance,
|
||||
CurrencyType = 2,
|
||||
Platform = 0,
|
||||
BalanceType = 0,
|
||||
BalanceUpdates = Array.Empty<object>(),
|
||||
},
|
||||
};
|
||||
|
||||
/// <summary><c>RoomKeyPurchaseWithCurrencyResponseDTO</c> — {Balance,
|
||||
/// RoomKeyResponse} where Balance is the ROOM-currency balance row
|
||||
/// (<c>RecNet.Runtime/EDMKBBPGJAB.txt:203/222</c>, member shape at
|
||||
/// <c>DPNLANKOHON.txt:331-414</c>), matching what
|
||||
/// RoomCurrenciesController already emits for the same row.</summary>
|
||||
private static object RoomKeyCurrencyPurchaseResponse(
|
||||
RoomKeyStatus status, RoomKeyEntity? key, long playerId, Guid currencyId,
|
||||
RoomCurrencyBalanceEntity? row) => new
|
||||
{
|
||||
Balance = new
|
||||
{
|
||||
AccountId = (int)playerId,
|
||||
CurrencyId = currencyId,
|
||||
Balance = row?.Balance ?? 0L,
|
||||
ModifiedAt = row is null
|
||||
? DateTime.UtcNow
|
||||
: DateTime.SpecifyKind(row.UpdatedAt, DateTimeKind.Utc),
|
||||
},
|
||||
RoomKeyResponse = RoomKeysController.RoomKeyResponse(status, key),
|
||||
};
|
||||
|
||||
/// <summary>Grants a purchased StoreItems row. Wardrobe unlocks are a set,
|
||||
/// so a multi-count press still yields one entry; everything else stacks in
|
||||
/// PlayerInventory.</summary>
|
||||
private async Task GrantPurchasedItemAsync(long pid, StoreItemEntity item, int count = 1)
|
||||
{
|
||||
if (StoreService.TryGetAvatarItemPayload(item.Slug, out _, out var avatarItemDesc))
|
||||
{
|
||||
await GrantWardrobeAsync(pid, StoreService.InventoryAvatarItemDesc(avatarItemDesc));
|
||||
return;
|
||||
}
|
||||
for (var n = 0; n < Math.Max(1, count); n++)
|
||||
await GrantInventoryAsync(pid, item.Slug);
|
||||
}
|
||||
|
||||
/// <summary>Grants <paramref name="item"/> and inserts the matching
|
||||
/// gift-box row, mirroring BuyItem: the watch pops a gift box after the
|
||||
/// purchase and POSTs the package id to /api/avatar/v2/gifts/consume/, so
|
||||
/// the id in the response has to be a real GiftPackageEntity.</summary>
|
||||
private async Task<long> GrantWithGiftBoxAsync(long pid, StoreItemEntity item, int currencyType)
|
||||
{
|
||||
await GrantPurchasedItemAsync(pid, item);
|
||||
|
||||
StoreService.TryGetAvatarItemPayload(item.Slug, out var avatarItemType, out var avatarItemDesc);
|
||||
StoreService.TryGetConsumableItemDesc(item.Slug, out var consumableItemDesc);
|
||||
StoreService.TryGetEquipmentPayload(item.Slug, out var equipmentPrefabName, out var equipmentModificationGuid);
|
||||
var inventoryAvatarDesc = StoreService.InventoryAvatarItemDesc(avatarItemDesc);
|
||||
if (StoreService.TryGetHairDyePayload(item.Slug, out var hairDyeConsumableDesc, out var hairDyeColorGuid))
|
||||
{
|
||||
avatarItemType = 1;
|
||||
inventoryAvatarDesc = hairDyeColorGuid;
|
||||
consumableItemDesc = hairDyeConsumableDesc;
|
||||
}
|
||||
// Same 4-part "{guid},,," desc BuyItem ships — the bare guid makes the
|
||||
// client's AvatarItem parser miss on unlock.
|
||||
var giftWireDesc = string.IsNullOrEmpty(avatarItemDesc) ? inventoryAvatarDesc : avatarItemDesc;
|
||||
|
||||
var gift = new GiftPackageEntity
|
||||
{
|
||||
RecipientPlayerId = pid,
|
||||
AvatarItemType = string.IsNullOrEmpty(giftWireDesc) ? null : avatarItemType,
|
||||
AvatarItemDescOrHairDyeDesc = giftWireDesc,
|
||||
ConsumableItemDesc = consumableItemDesc,
|
||||
EquipmentPrefabName = equipmentPrefabName,
|
||||
EquipmentModificationGuid = equipmentModificationGuid,
|
||||
CurrencyType = currencyType,
|
||||
Currency = 0,
|
||||
Xp = 0,
|
||||
Level = 1,
|
||||
GiftContext = 0,
|
||||
GiftRarity = 0,
|
||||
Message = string.Empty,
|
||||
Platform = -1,
|
||||
PackageVariant = "Standard",
|
||||
PackageMaterial = string.Empty,
|
||||
Consumed = false,
|
||||
IsValid = true,
|
||||
SupportsCurrentPlatform = true,
|
||||
};
|
||||
db.GiftPackages.Add(gift);
|
||||
await db.SaveChangesAsync();
|
||||
return gift.Id;
|
||||
}
|
||||
|
||||
/// <summary>Idempotently records a season-pass purchase. There is no
|
||||
/// SeasonProgressEntity, so tier/elite state lives on ObjectiveProgress
|
||||
/// under <c>seasonTier:{storefrontType}:{purchasableItemId}</c> and
|
||||
/// <c>seasonElite:{storefrontType}</c> — the same table the season
|
||||
/// objectives feed already uses.</summary>
|
||||
private async Task RecordSeasonProgressAsync(long pid, string key)
|
||||
{
|
||||
if (await db.ObjectiveProgress.AnyAsync(o => o.PlayerId == pid && o.Key == key)) return;
|
||||
db.ObjectiveProgress.Add(new ObjectiveProgressEntity
|
||||
{
|
||||
PlayerId = pid,
|
||||
Key = key,
|
||||
IsCompleted = true,
|
||||
ClearedAt = DateTime.UtcNow,
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using System.Text.Json;
|
||||
using DorkNet.Server.Auth;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
using DorkNet.Server.Services;
|
||||
|
||||
@@ -24,20 +26,24 @@ namespace DorkNet.Server.Controllers.Clubs;
|
||||
/// POST /announcements/club/{clubId}/{announcementId}/read
|
||||
///
|
||||
/// Wire types (deserialiser JSON keys, per ISIL):
|
||||
/// Club (<c>PLILLKHMNDA</c>): ClubId, Name, Description,
|
||||
/// MainImageName, State, CreatorAccountId, Category,
|
||||
/// Visibility, Joinability, AllowJuniors, MemberCount,
|
||||
/// IsRRO, ClubhouseRoomId, ClubType
|
||||
/// Announcement (<c>NFEMLMAFFIP</c>): AnnouncementId,
|
||||
/// Club (2020 <c>PLILLKHMNDA</c> / 2023 <c>FOIJDINBPFG</c>):
|
||||
/// ClubId, Name, Description, MainImageName, State,
|
||||
/// CreatorAccountId, Category, Visibility, Joinability,
|
||||
/// AllowJuniors, MemberCount, MinLevel, IsRRO,
|
||||
/// ClubChatEnabled, ClubhouseRoomId, ClubType
|
||||
/// Announcement (<c>NFEMLMAFFIP</c> / <c>JDPPAFLFNBD</c>): AnnouncementId,
|
||||
/// CreatorAccountId, ClubId, Title, Body, ImageName,
|
||||
/// CreatedAt, Meta
|
||||
/// UnreadResponse (<c>HPACLJHLHBG</c>) — wraps per-club rows:
|
||||
/// { clubId, LastReadAnnouncementId, announcements: [Announcement] }
|
||||
/// UnreadResponse (<c>EIKPFIDCKNE</c> / <c>NCHLBFPHFJE</c>) — a LIST of
|
||||
/// per-club rows { clubId, LastAnnouncementId,
|
||||
/// LastReadAnnouncementId, announcements: [Announcement] }
|
||||
/// ClubFeed (<c>HPACLJHLHBG</c> / <c>FIAKMDGGIHH</c>) — ONE such row,
|
||||
/// not a list; see <c>AnnouncementsForClub</c>.
|
||||
/// CategoryTags plain <c>List<String></c> (per
|
||||
/// <c>JDJGIBLMFKK.GetPrimaryTags</c> callback shape).
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
public class ClubsController(ClubService clubs) : ControllerBase
|
||||
public class ClubsController(ClubService clubs, DorkNetDbContext db) : ControllerBase
|
||||
{
|
||||
private long Me => this.RequireCurrentPlayerId();
|
||||
|
||||
@@ -154,7 +160,7 @@ public class ClubsController(ClubService clubs) : ControllerBase
|
||||
{
|
||||
var pid = Me;
|
||||
var rows = await clubs.UnreadDirectAsync(pid);
|
||||
return Ok(GroupByClub(rows));
|
||||
return Ok(GroupByClub(rows, await LastReadByClubAsync(pid, rows.Select(a => a.ClubId))));
|
||||
}
|
||||
|
||||
[HttpGet("/announcements/v2/subscription/mine/unread")]
|
||||
@@ -163,7 +169,7 @@ public class ClubsController(ClubService clubs) : ControllerBase
|
||||
{
|
||||
var pid = Me;
|
||||
var rows = await clubs.UnreadSubscriptionAsync(pid);
|
||||
return Ok(GroupByClub(rows));
|
||||
return Ok(GroupByClub(rows, await LastReadByClubAsync(pid, rows.Select(a => a.ClubId))));
|
||||
}
|
||||
|
||||
/// <summary>GET <c>/subscription/mine/member</c> — list of clubs the
|
||||
@@ -204,6 +210,17 @@ public class ClubsController(ClubService clubs) : ControllerBase
|
||||
return Ok(playerSubs + clubSubs);
|
||||
}
|
||||
|
||||
/// <summary>GET <c>/subscription/details/{accountId}</c> — the creator's
|
||||
/// subscription card. Both clients read the same three keys: the 2020 watch
|
||||
/// via <c>LNIKPLKOBDK</c> (exact-match lowercase <c>accountId</c>,
|
||||
/// <c>clubId</c>, <c>subscriberCount</c>) and 2023 via <c>DDFNNBDLBKF</c>
|
||||
/// (tri-cased AccountId/ClubId/SubscriberCount, <c>DDFNNBDLBKF.txt:279-346</c>)
|
||||
/// — camelCase satisfies both, so the casing here is load-bearing for 2020
|
||||
/// and must not be Pascal-ised.
|
||||
///
|
||||
/// <c>clubId</c> used to be missing entirely. The 2023 caller is literally
|
||||
/// <c>GetCreatorClubIdForSubscription</c>, so the field defaulted to 0 and
|
||||
/// profile→creator-club navigation went nowhere.</summary>
|
||||
[HttpGet("/subscription/details/{accountId:long}")]
|
||||
public async Task<IActionResult> SubscriptionDetails(long accountId)
|
||||
{
|
||||
@@ -212,9 +229,14 @@ public class ClubsController(ClubService clubs) : ControllerBase
|
||||
var subscribedCount = await clubs.PlayerSubscribedCountAsync(accountId);
|
||||
var isSubscribed = viewerId is long viewer
|
||||
&& await clubs.IsSubscribedToPlayerAsync(viewer, accountId);
|
||||
// A creator's "creator club" is the club they own; oldest created wins
|
||||
// when an account somehow owns several.
|
||||
var created = await clubs.CreatedByAsync(accountId);
|
||||
var clubId = created.Count > 0 ? created.Min(c => c.Id) : 0L;
|
||||
return Ok(new
|
||||
{
|
||||
accountId,
|
||||
clubId,
|
||||
subscriberCount,
|
||||
subscribedCount,
|
||||
isSubscribed,
|
||||
@@ -297,16 +319,19 @@ public class ClubsController(ClubService clubs) : ControllerBase
|
||||
return await BuildDetailsResponseAsync(clubId);
|
||||
}
|
||||
|
||||
/// <summary>GET <c>/club/{id}/permissions/{role}</c> — bare int of
|
||||
/// the permission bitmask for a role on this club. Used by the
|
||||
/// watch's role-permissions screen. Returns 0 for unknown roles so
|
||||
/// the screen renders an empty checklist instead of erroring.</summary>
|
||||
/// <summary>GET <c>/club/{id}/permissions/{role}</c> — the permission
|
||||
/// record for one role on this club. This used to answer a bare int; both
|
||||
/// clients model it as an OBJECT (2023 <c>MMOCDPPONNG</c>, 2020
|
||||
/// <c>JHEEFBMODPG</c> — identical layout: long ClubId, membership-type
|
||||
/// enum, six bools), so see <see cref="PermissionsForRole"/>. Neither
|
||||
/// client actually issues the GET (both only PUT this template), but the
|
||||
/// shapes now agree.</summary>
|
||||
[HttpGet("/club/{clubId:long}/permissions/{role:int}")]
|
||||
public async Task<IActionResult> ClubPermissions(long clubId, int role)
|
||||
{
|
||||
var club = await clubs.GetByIdAsync(clubId);
|
||||
if (club is null) return NotFound();
|
||||
return Ok(PermissionsForRole(role));
|
||||
return Ok(PermissionsForRole(clubId, role));
|
||||
}
|
||||
|
||||
// ── Account / created lists ──────────────────────────────────────
|
||||
@@ -369,6 +394,17 @@ public class ClubsController(ClubService clubs) : ControllerBase
|
||||
public int? Joinability { get; set; }
|
||||
public bool? AllowJuniors { get; set; }
|
||||
public long? ClubhouseRoomId { get; set; }
|
||||
|
||||
/// <summary>Repeated <c>customTags</c> form field sent by
|
||||
/// <c>club/{0}/modifydetails</c> alongside visibility / joinability /
|
||||
/// allowJuniors (<c>IKMMOCKDKAF_NestedType_MAFIJOPDGHK.txt:139-185</c>).
|
||||
/// Null means "not supplied"; an empty list clears the club's tags.
|
||||
/// Note the form path can only ever deliver null or a non-empty list —
|
||||
/// <c>FormOrJsonModelBinder.Convert</c> collapses an all-blank repeated
|
||||
/// field to null — so a client that clears every tag over
|
||||
/// form-urlencoded leaves the existing set in place. Only the JSON path
|
||||
/// can send an explicit <c>[]</c>.</summary>
|
||||
public List<string>? CustomTags { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>POST <c>/club/{id}/modify</c> + <c>/modifydetails</c> —
|
||||
@@ -397,6 +433,9 @@ public class ClubsController(ClubService clubs) : ControllerBase
|
||||
if (req.ClubhouseRoomId is long ch) c.ClubhouseRoomId = ch;
|
||||
});
|
||||
if (updated is null) return NotFound();
|
||||
// Tags live in the club↔tag junction, not on the club row, so they
|
||||
// are written after ModifyAsync has already vetted the caller.
|
||||
if (req.CustomTags is not null) await ReplaceCustomTagsAsync(updated.Id, req.CustomTags);
|
||||
return await BuildDetailsResponseAsync(updated.Id);
|
||||
}
|
||||
catch (UnauthorizedAccessException) { return Forbid(); }
|
||||
@@ -422,12 +461,17 @@ public class ClubsController(ClubService clubs) : ControllerBase
|
||||
catch (UnauthorizedAccessException) { return Forbid(); }
|
||||
}
|
||||
|
||||
/// <summary>POST <c>/club/{id}/additionalimage/{slot}</c> — the
|
||||
/// watch's gallery-image slot setter. The 2020 ClubEntity doesn't
|
||||
/// have additional image slots persisted yet, so we just return
|
||||
/// the current details unchanged (the gallery renders blank).
|
||||
/// Hooked up so the request doesn't 404 — populated once a future
|
||||
/// schema patch lands.</summary>
|
||||
/// <summary>POST/PUT <c>/club/{id}/additionalimage/{slot}</c> — the club
|
||||
/// gallery's per-slot image setter (<c>IKMMOCKDKAF.txt:16214</c>).
|
||||
///
|
||||
/// The request now binds (the client sends form-urlencoded
|
||||
/// <c>imageName</c>, which <c>[FromBody]</c> used to reject with 415), but
|
||||
/// the slot itself STILL is not persisted: there is no additional-image
|
||||
/// table, and the envelope's <c>AdditionalImages</c> is the client's
|
||||
/// <c>HIKCHBLAMLP</c> — {ImageName, Slot} — with nowhere to come from. So
|
||||
/// the call is accepted and permission-checked, the details envelope comes
|
||||
/// back refreshed, and the gallery renders blank until a
|
||||
/// <c>ClubAdditionalImageEntity</c> lands.</summary>
|
||||
[HttpPost("/club/{clubId:long}/additionalimage/{slot:int}")]
|
||||
[HttpPut("/club/{clubId:long}/additionalimage/{slot:int}")]
|
||||
[Authorize]
|
||||
@@ -709,35 +753,64 @@ public class ClubsController(ClubService clubs) : ControllerBase
|
||||
|
||||
// ── members/bulk + clubreporting ─────────────────────────────────
|
||||
|
||||
/// <summary>GET <c>/members/bulk?clubId=N&playerIds=A&playerIds=B</c>
|
||||
/// — bulk membership lookup. Wire shape is
|
||||
/// <c>List<JHMMGLNJHIB></c> with each entry carrying
|
||||
/// <c>AccountId</c> + <c>MembershipType</c>.</summary>
|
||||
/// <summary>GET/POST <c>/members/bulk?id=A&id=B</c> — bulk membership
|
||||
/// lookup for nameplate club badges. Response rows are
|
||||
/// <c>FCKGOFHNDNJ</c>: <c>AccountId</c> + <c>MembershipType</c>
|
||||
/// (reader <c>FMKALNIKMKF.txt</c>).
|
||||
///
|
||||
/// Two things the original handler got wrong, both visible at
|
||||
/// <c>IKMMOCKDKAF.txt:20976-21005</c>:
|
||||
/// * the account ids ride in repeated <c>id</c> params (:20997), not
|
||||
/// <c>playerIds</c>;
|
||||
/// * <c>clubId</c> is NEVER transmitted — it is only the client's local
|
||||
/// cache key — so hard-requiring it 400'd every single call.
|
||||
/// The verb is picked at runtime by a cmov (:20984-20985,
|
||||
/// <c>cmp rdi,100 / cmovge edx,eax</c> with eax=2): GET under 100 ids,
|
||||
/// POST at 100+. Both are registered.
|
||||
///
|
||||
/// With no club scope the answer is each account's most privileged
|
||||
/// membership across all clubs, which is what the badge renders anyway;
|
||||
/// an explicit <c>clubId</c> (2020 watch, admin tools) still narrows it.
|
||||
/// </summary>
|
||||
[HttpGet("/members/bulk")]
|
||||
[HttpPost("/members/bulk")]
|
||||
public async Task<IActionResult> MembersBulk()
|
||||
{
|
||||
var form = Request.HasFormContentType ? Request.Form : null;
|
||||
if (!long.TryParse(Request.Query["clubId"].ToString(), out var clubId)
|
||||
&& (form is null || !long.TryParse(form["clubId"].ToString(), out clubId)))
|
||||
return BadRequest(new { error = "missing_club_id" });
|
||||
var form = Request.HasFormContentType ? await Request.ReadFormAsync() : null;
|
||||
|
||||
var idsRaw = form is null
|
||||
? Request.Query["playerIds"].AsEnumerable()
|
||||
: Request.Query["playerIds"].Concat(form["playerIds"]);
|
||||
var ids = idsRaw
|
||||
IEnumerable<string?> Values(string key) => form is null
|
||||
? Request.Query[key].AsEnumerable()
|
||||
: Request.Query[key].Concat(form[key]);
|
||||
|
||||
var ids = new[] { "id", "playerIds", "accountIds" }
|
||||
.SelectMany(Values)
|
||||
.SelectMany(v => (v ?? string.Empty).Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||||
.Select(s => long.TryParse(s, out var n) ? n : 0)
|
||||
.Where(n => n != 0)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
if (ids.Count == 0) return Ok(Array.Empty<object>());
|
||||
|
||||
var rows = await clubs.MembershipsBulkAsync(clubId, ids);
|
||||
return Ok(rows.Select(m => new
|
||||
{
|
||||
AccountId = (int)m.PlayerId,
|
||||
MembershipType = ClubService.MembershipTypeFromPerms(m.Permissions),
|
||||
}));
|
||||
long? clubId = long.TryParse(Request.Query["clubId"].ToString(), out var q) ? q
|
||||
: form is not null && long.TryParse(form["clubId"].ToString(), out var f) ? f
|
||||
: null;
|
||||
|
||||
var rows = clubId is long scope
|
||||
? await clubs.MembershipsBulkAsync(scope, ids)
|
||||
: await db.ClubMemberships.AsNoTracking()
|
||||
.Where(m => ids.Contains(m.PlayerId))
|
||||
.ToListAsync();
|
||||
|
||||
// One row per account: highest membership type wins (Creator > CoOwner >
|
||||
// Moderator > Member), and ban/pending markers lose to a real role.
|
||||
return Ok(rows
|
||||
.GroupBy(m => m.PlayerId)
|
||||
.Select(g => new
|
||||
{
|
||||
AccountId = (int)g.Key,
|
||||
MembershipType = g.Max(m => ClubService.MembershipTypeFromPerms(m.Permissions)),
|
||||
})
|
||||
.ToList());
|
||||
}
|
||||
|
||||
// NOTE: POST /api/clubreporting/v1/report is served by
|
||||
@@ -749,15 +822,25 @@ public class ClubsController(ClubService clubs) : ControllerBase
|
||||
|
||||
// ── Announcements (per-club feed + per-announcement) ─────────────
|
||||
|
||||
/// <summary>GET <c>/announcements/club/{clubId}</c> — full
|
||||
/// announcement feed for the club. Wire shape mirrors the unread
|
||||
/// rollup (<c>HPACLJHLHBG</c> envelope) but holds every visible
|
||||
/// announcement, newest first.</summary>
|
||||
/// <summary>GET <c>/announcements/club/{clubId}</c> — full announcement
|
||||
/// feed for one club, newest first.
|
||||
///
|
||||
/// This returns a SINGLE envelope object, never an array: 2023 reads it as
|
||||
/// <c>FGLDKEJLAKB<FIAKMDGGIHH></c>
|
||||
/// (<c>IKMMOCKDKAF.txt:1170</c>, route at :1452) and the 2020 watch as
|
||||
/// <c>IPromise<HPACLJHLHBG></c> (<c>JDJGIBLMFKK.txt:1012,1167</c>).
|
||||
/// Handing either of them the per-club LIST that the unread rollup uses
|
||||
/// failed the reader, so the club announcement board never rendered — and
|
||||
/// a club with no announcements produced <c>[]</c>, not an object at
|
||||
/// all.</summary>
|
||||
[HttpGet("/announcements/club/{clubId:long}")]
|
||||
public async Task<IActionResult> AnnouncementsForClub(long clubId)
|
||||
{
|
||||
var rows = await clubs.AnnouncementsForClubAsync(clubId);
|
||||
return Ok(GroupByClub(rows));
|
||||
var lastRead = ControllerBaseExtensions.CurrentPlayerId(User) is long pid
|
||||
? await LastReadByClubAsync(pid, new[] { clubId })
|
||||
: new Dictionary<long, long>();
|
||||
return Ok(AnnouncementEnvelope(clubId, rows, lastRead.GetValueOrDefault(clubId)));
|
||||
}
|
||||
|
||||
/// <summary>GET <c>/announcements/club/{clubId}/{aid}</c> — single
|
||||
@@ -877,37 +960,110 @@ public class ClubsController(ClubService clubs) : ControllerBase
|
||||
}
|
||||
return Ok(new
|
||||
{
|
||||
// PIHMJGCGNLP deserializer at PIHMJGCGNLP.txt:143-194 reads
|
||||
// 7 fields off the response dict, not the 5 the parser-keys
|
||||
// doc lists. Missing any throws KeyNotFoundException —
|
||||
// CustomTags (List<String>) and AdditionalImages
|
||||
// (List<FKFAKOKIEGN>) need to be present even if empty.
|
||||
// All seven keys are mandatory — 2020's PIHMJGCGNLP deserializer
|
||||
// (PIHMJGCGNLP.txt:143-194) throws KeyNotFoundException on a missing
|
||||
// one, and 2023's LCLFBBPEMIH registers exactly these names
|
||||
// (JAHJGFHFKIB.txt:523-678). AdditionalImages stays empty until the
|
||||
// gallery slots get a table; see ClubAdditionalImage.
|
||||
Club = ToWireClub(club, memberCount),
|
||||
CustomTags = Array.Empty<string>(),
|
||||
CustomTags = await CustomTagsAsync(clubId),
|
||||
AdditionalImages = Array.Empty<object>(),
|
||||
CoownerPermissions = PermissionsForRole(ClubService.MembershipTypeCoOwner),
|
||||
ModeratorPermissions = PermissionsForRole(ClubService.MembershipTypeModerator),
|
||||
MemberPermissions = PermissionsForRole(ClubService.MembershipTypeMember),
|
||||
CoownerPermissions = PermissionsForRole(clubId, ClubService.MembershipTypeCoOwner),
|
||||
ModeratorPermissions = PermissionsForRole(clubId, ClubService.MembershipTypeModerator),
|
||||
MemberPermissions = PermissionsForRole(clubId, ClubService.MembershipTypeMember),
|
||||
MyMembershipType = myType,
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bitmask of capabilities a given role has on a club. The bits
|
||||
/// are arbitrary (the wire enum <c>JHEEFBMODPG</c> is opaque in
|
||||
/// the readable dump) but stay consistent so the watch's
|
||||
/// role-permissions screen renders the same checklist on every
|
||||
/// refresh. Owner / Coowner can manage all; Moderator can manage
|
||||
/// content; Member can read.
|
||||
/// </summary>
|
||||
private static int PermissionsForRole(int membershipType) => membershipType switch
|
||||
/// <summary>The club's player-authored tags, for the envelope's
|
||||
/// <c>CustomTags</c> (<c>List<String></c> on both clients). These used
|
||||
/// to be a hardcoded empty array; they are stored in the existing club↔tag
|
||||
/// junction so the value the client PUT actually comes back.</summary>
|
||||
private Task<List<string>> CustomTagsAsync(long clubId) =>
|
||||
(from a in db.ClubCategoryAssignments
|
||||
join t in db.ClubCategoryTags on a.CategoryTagId equals t.Id
|
||||
where a.ClubId == clubId
|
||||
orderby t.OrderIndex, t.Name
|
||||
select t.Name).ToListAsync();
|
||||
|
||||
/// <summary>Replace a club's tag assignments wholesale — the client always
|
||||
/// PUTs the complete <c>customTags</c> set, never a delta.</summary>
|
||||
private async Task ReplaceCustomTagsAsync(long clubId, List<string> tags)
|
||||
{
|
||||
ClubService.MembershipTypeOwner => 0x7FFF, // everything
|
||||
ClubService.MembershipTypeCoOwner => 0x7FFE, // all except disband
|
||||
ClubService.MembershipTypeModerator => 0x00FF, // moderation only
|
||||
ClubService.MembershipTypeMember => 0x0007, // post + read + react
|
||||
_ => 0x0001, // read-only
|
||||
};
|
||||
var wanted = tags
|
||||
.Select(t => (t ?? string.Empty).Trim())
|
||||
.Where(t => t.Length > 0 && t.Length <= 64)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
var stale = await db.ClubCategoryAssignments
|
||||
.Where(a => a.ClubId == clubId)
|
||||
.ToListAsync();
|
||||
db.ClubCategoryAssignments.RemoveRange(stale);
|
||||
|
||||
foreach (var name in wanted)
|
||||
{
|
||||
var lower = name.ToLower();
|
||||
var tag = await db.ClubCategoryTags.FirstOrDefaultAsync(t => t.Name.ToLower() == lower);
|
||||
if (tag is null)
|
||||
{
|
||||
// A player-authored tag is not part of the admin-curated
|
||||
// category list, so it lands Active=false: assignments still
|
||||
// resolve by id, but /club/categoryTags keeps returning only
|
||||
// the curated set the browse tab is built from.
|
||||
tag = new ClubCategoryTagEntity { Name = name, OrderIndex = 1000, Active = false };
|
||||
db.ClubCategoryTags.Add(tag);
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
db.ClubCategoryAssignments.Add(new ClubCategoryAssignmentEntity
|
||||
{
|
||||
ClubId = clubId,
|
||||
CategoryTagId = tag.Id,
|
||||
});
|
||||
}
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The permission record for one role on a club.
|
||||
///
|
||||
/// These three envelope keys used to carry a plain bitmask int. Both
|
||||
/// clients declare them as a COMPLEX OBJECT — 2023
|
||||
/// <c>LCLFBBPEMIH.JNOKOILEFHD/OMMHPBFEHBH/IJKKPOBDJGG</c> are
|
||||
/// <c>MMOCDPPONNG</c> (recnet-runtime-decomp/LCLFBBPEMIH.cs:64-115) and the
|
||||
/// 2020 watch's <c>PIHMJGCGNLP</c> uses the identically-laid-out
|
||||
/// <c>JHEEFBMODPG</c> — so feeding an integer into the object reader threw
|
||||
/// and took the whole club page (and club creation) down with it.
|
||||
///
|
||||
/// Key names are verbatim from the generated reader
|
||||
/// <c>BPHCOIBNCDP.txt:587-766</c>: ClubId, <b>Type</b> (not
|
||||
/// "MembershipType"), EditDetails, ApproveMember, CreateEvent,
|
||||
/// PostAnnouncement, EditPermissionSettings, BanUnban. The same six names
|
||||
/// are the form keys the client PUTs back on the permissions screen
|
||||
/// (<c>IKMMOCKDKAF_NestedType_BOIMHOCCOEI.txt:173-253</c>).
|
||||
///
|
||||
/// The bools are derived from the role rather than stored: there is no
|
||||
/// per-role permission table yet, so every club uses the default policy —
|
||||
/// Owner/CoOwner do everything, Moderator moderates but cannot re-write the
|
||||
/// permission settings, Member and below are read-only.
|
||||
/// </summary>
|
||||
private static object PermissionsForRole(long clubId, int membershipType)
|
||||
{
|
||||
var admin = membershipType is ClubService.MembershipTypeOwner
|
||||
or ClubService.MembershipTypeCoOwner;
|
||||
var moderator = admin || membershipType == ClubService.MembershipTypeModerator;
|
||||
return new
|
||||
{
|
||||
ClubId = clubId,
|
||||
Type = membershipType,
|
||||
EditDetails = admin,
|
||||
ApproveMember = moderator,
|
||||
CreateEvent = moderator,
|
||||
PostAnnouncement = moderator,
|
||||
EditPermissionSettings = admin,
|
||||
BanUnban = moderator,
|
||||
};
|
||||
}
|
||||
|
||||
private static object ToWireMembership(ClubMembershipEntity row) => new
|
||||
{
|
||||
@@ -947,6 +1103,14 @@ public class ClubsController(ClubService clubs) : ControllerBase
|
||||
Joinability = c.Joinability,
|
||||
AllowJuniors = c.AllowJuniors,
|
||||
MemberCount = memberCount,
|
||||
// MinLevel + ClubChatEnabled are registered by the 2023 Club reader
|
||||
// (BLIBJIHOENF.txt:1234-1322) and were never emitted, so every club came
|
||||
// back with chat disabled on the profile Clubs tab. Neither has a column
|
||||
// on ClubEntity yet — the client-facing setters are club/{0}/minlevel
|
||||
// and club/{0}/clubChatEnabled (IKMMOCKDKAF.txt:16561,25779) — so they
|
||||
// report the permissive defaults until the schema catches up.
|
||||
MinLevel = 0,
|
||||
ClubChatEnabled = true,
|
||||
IsRRO = c.IsRRO,
|
||||
ClubhouseRoomId = c.ClubhouseRoomId,
|
||||
ClubType = c.ClubType,
|
||||
@@ -1011,28 +1175,74 @@ public class ClubsController(ClubService clubs) : ControllerBase
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Group a flat list of announcements into the per-club
|
||||
/// <c>HPACLJHLHBG</c> envelope shape. Uses
|
||||
/// <see cref="Dictionary{TKey, TValue}"/> for the outer object
|
||||
/// because the wire mixes lowercase and PascalCase keys for the
|
||||
/// same record (<c>clubId</c> + <c>LastReadAnnouncementId</c> +
|
||||
/// <c>announcements</c>) and anonymous types can't model that.
|
||||
/// Group a flat list of announcements into per-club envelopes — the shape
|
||||
/// the two unread rollups return as a LIST (2023
|
||||
/// <c>List<NCHLBFPHFJE></c>, 2020 <c>List<EIKPFIDCKNE></c>).
|
||||
/// </summary>
|
||||
private static List<Dictionary<string, object?>> GroupByClub(
|
||||
List<ClubAnnouncementEntity> rows)
|
||||
List<ClubAnnouncementEntity> rows, Dictionary<long, long> lastReadByClub)
|
||||
{
|
||||
return rows
|
||||
.GroupBy(a => a.ClubId)
|
||||
.Select(g =>
|
||||
{
|
||||
var ordered = g.OrderByDescending(a => a.CreatedAt).ToList();
|
||||
return new Dictionary<string, object?>
|
||||
{
|
||||
["clubId"] = g.Key,
|
||||
["LastReadAnnouncementId"] = (long?)null,
|
||||
["announcements"] = ordered.Select(ToWireAnnouncement).ToList(),
|
||||
};
|
||||
})
|
||||
.Select(g => AnnouncementEnvelope(g.Key, g.ToList(), lastReadByClub.GetValueOrDefault(g.Key)))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One club's announcement envelope. Uses
|
||||
/// <see cref="Dictionary{TKey, TValue}"/> rather than an anonymous type
|
||||
/// because the wire mixes casings inside a single record: the 2020 readers
|
||||
/// match key names EXACTLY and want <c>clubId</c> + <c>announcements</c>
|
||||
/// lowercase but <c>LastAnnouncementId</c>/<c>LastReadAnnouncementId</c>
|
||||
/// PascalCase (<c>EIKPFIDCKNE.txt</c>, <c>HPACLJHLHBG.txt:270-280</c>).
|
||||
/// The 2023 readers are tri-cased (<c>CAKCKAMAAPP.txt:331-414</c>,
|
||||
/// <c>AECJMONHBHM.txt:267-326</c>) so camelCase satisfies them too.
|
||||
///
|
||||
/// Two fixes over the original projection:
|
||||
/// * <c>LastAnnouncementId</c> was omitted entirely though both readers
|
||||
/// register it;
|
||||
/// * <c>LastReadAnnouncementId</c> was an explicit JSON <c>null</c> into
|
||||
/// a NON-nullable Int64 field on both clients. Always a number now.
|
||||
/// </summary>
|
||||
private static Dictionary<string, object?> AnnouncementEnvelope(
|
||||
long clubId, List<ClubAnnouncementEntity> rows, long lastReadId)
|
||||
{
|
||||
var ordered = rows.OrderByDescending(a => a.CreatedAt).ThenByDescending(a => a.Id).ToList();
|
||||
return new Dictionary<string, object?>
|
||||
{
|
||||
["clubId"] = clubId,
|
||||
["LastAnnouncementId"] = ordered.Count == 0 ? 0L : ordered.Max(a => a.Id),
|
||||
["LastReadAnnouncementId"] = lastReadId,
|
||||
["announcements"] = ordered.Select(ToWireAnnouncement).ToList(),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Highest announcement id the caller has actually read, per club — the
|
||||
/// <c>LastReadAnnouncementId</c> the envelopes above must carry. Done as
|
||||
/// two index-supported reads plus an in-memory join for the same reason
|
||||
/// <see cref="ClubService"/> avoids the LEFT JOIN: EF Core generates
|
||||
/// pathological SQL for the anti-join on SQLite.
|
||||
/// </summary>
|
||||
private async Task<Dictionary<long, long>> LastReadByClubAsync(
|
||||
long playerId, IEnumerable<long> clubIds)
|
||||
{
|
||||
var ids = clubIds.Distinct().ToList();
|
||||
if (ids.Count == 0) return new Dictionary<long, long>();
|
||||
|
||||
var readIds = await db.ClubAnnouncementReads
|
||||
.Where(r => r.PlayerId == playerId)
|
||||
.Select(r => r.AnnouncementId)
|
||||
.ToListAsync();
|
||||
if (readIds.Count == 0) return new Dictionary<long, long>();
|
||||
|
||||
var read = await db.ClubAnnouncements
|
||||
.Where(a => ids.Contains(a.ClubId) && readIds.Contains(a.Id))
|
||||
.Select(a => new { a.ClubId, a.Id })
|
||||
.ToListAsync();
|
||||
|
||||
return read
|
||||
.GroupBy(a => a.ClubId)
|
||||
.ToDictionary(g => g.Key, g => g.Max(a => a.Id));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user