Files
mochaisvibecoded 42f7f57b72 :3
2026-08-24 17:00:26 -04:00

11053 lines
408 KiB
C#

using Mocha2023.Auth;
using Mocha2023.Classes;
using Mocha2023.Classes.DBs;
using Mocha2023.Classes.DBs.DBClasses;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using SixLabors.ImageSharp;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using static Mocha2023.Classes.DBs.DBClasses.PlayerDBClasses;
using JsonSerializer = System.Text.Json.JsonSerializer;
namespace Mocha2023.Controllers
{
[ApiController]
[Mocha2023.Classes.ApiProtection]
public class APIController : ControllerBase
{
private const int StoreItemPrice = 100;
private const int FriendotronGiftContext = (int)PlayerDBClasses.GiftContext.Consumable;
private const string WeeklyChallengeRewardEquipmentPrefab = "[Basketball]";
private const string WeeklyChallengeRewardModificationGuid =
"WOWwmg7jg0mH2g4LJUp6fA";
private static readonly object CatalogLock = new();
private static readonly object EquipmentInventoryLock = new();
private static DateTime CatalogCacheTimestampUtc;
private static DateTime EquipmentCatalogCacheTimestampUtc;
private static DateTime ConsumableCatalogCacheTimestampUtc;
private static List<CatalogSku> CatalogCache = new();
private static readonly object StorefrontAdminLock = new();
private static StorefrontAdminState? StorefrontAdminCache;
private static readonly FriendotronConsumableReward[]
FriendotronConsumableRewards =
{
new("Film (Black & White)", "frOMH6WxDEG1fBqC4_83vg", 0),
new("Film (Dawn)", "m0bVLwWGj0GuIBSb6wCk6Q", 0),
new("Film (Sepia)", "A5M-yf9tgUihq1uab3v58g", 0),
new("High Five Potion (Golden)", "-hy0qD-iUk-v4NHxNzanmg", 0),
new("High Five Potion (Magic)", "VQSgL2pTLkWx4B3kwYG7UA", 0),
new("Assorted Donuts", "ZuvkidodzkuOfGLDnTOFyg", 20),
new("Candy Apples", "EmPvh3I6L0uK_1i8Wy_ylQ", 20),
new("Cheese Pizza", "5hIAZ9wg5EyG1cILf4FS2A", 0),
new("Chocolate Donuts", "mMCGPgK3tki5S_15q2Z81A", 10),
new("Glazed Donuts", "7OZ5AE3uuUyqa0P-2W1ptg", 0),
new("Hawaiian Pizza", "_jnjYGBcyEWY5Ub4OezXcA", 10),
new("Tray of Lattes", "P15H1ONBhk-5DYYjid1ttg", 10),
new("Pepperoni Pizza", "mq23W-RSP0G8iGNLdrcpUw", 10),
new("Root Beer", "JfnVXFmilU6ysv-VbTAe3A", 0),
new("Salted Pretzels", "InQ25wQMGkG_bvuD5rf2Ag", 0),
new("Supreme Pizza", "wUCIKdJSvEmiQHYMyx4X4w", 20),
new("KO Icon - Grenade", "EAhk3ZZdXEmH5wRAXXT24Q", 20),
new("KO Icon - Sword & Shield", "5AJin8T2iEG7BzOPOgx2HA", 20),
new("KO Icon - Winged Skull", "U38Qe6rhEk6mFvArHfYjng", 20),
new("KO Icon - Tire Track", "J1WqFNUWo0OBi4LGKPDHWw", 20)
};
[HttpGet("/")]
public IActionResult GetNS()
{
if (Request.Headers.Accept.Any(value =>
value?.Contains("text/html", StringComparison.OrdinalIgnoreCase) == true))
{
return Redirect("/recnet/");
}
return BuildNameServerResponse();
}
[HttpGet("/nameserver")]
public IActionResult GetNameServer([FromQuery] int? v = null)
{
return BuildNameServerResponse();
}
private IActionResult BuildNameServerResponse()
{
string url = ServerConfig.BaseURL.TrimEnd('/');
return Ok(new
{
Accounts = url + "/acc",
API = url,
Auth = url + "/auth",
BugReporting = url,
Cards = url,
CDN = url + "/cdn",
Chat = url,
Clubs = url,
CMS = url,
Commerce = url,
Data = url,
DataCollection = url,
Discovery = url,
Econ = url,
GameLogs = url,
Geo = url,
Images = url + "/imageserver-v2",
Leaderboard = url,
Link = url,
Lists = url,
Matchmaking = url + "/match",
Moderation = url,
Notifications = url + "/noti",
PlatformNotifications = url,
PlayerSettings = url,
RoomComments = url,
Rooms = url + "/roomserver",
Storage = url,
Strings = url,
StringsCDN = url,
Studio = url,
Thorn = url,
Videos = url,
WWW = url
});
}
[HttpPost("/api/gamesight/event")]
public IActionResult StubGameSightEvent()
{
return Ok(new { success = true });
}
[HttpGet("/api/storefronts/v3/giftdropstore/{storeId:int}")]
public IActionResult StubGetGiftDropStore([FromRoute] int storeId)
{
List<CatalogSku> storeItems = storeId == 3
? GetWatchStoreSkusWithHairDyes()
: GetDailyStoreSkus(storeId);
if (storeId == 3)
{
Console.WriteLine(
$"[HAIR DYE STOREFRONT] total={storeItems.Count} " +
$"dyes={storeItems.Count(IsHairDyeCatalogItem)}");
}
return Ok(new CatalogStorefront
{
StorefrontType = storeId,
NextUpdate = DateTime.Today.AddDays(1).ToUniversalTime(),
StoreItems = storeItems
});
}
[HttpGet("/api/quickPlay/v1/getandclear")]
public IActionResult StubGetAndClearQuickPlay()
{
return Ok(System.Array.Empty<object>());
}
[HttpGet("/api/challenge/v2/getCurrent")]
public IActionResult GetCurrentChallenges()
{
var (challengeMapId, startsAt, endsAt) = RecNetDB.GetCurrentChallengeWindow();
long accountId = AuthStuff.GetPlayerId(Request) ?? 0;
object[] challenges =
{
CurrentChallenge(
accountId: accountId,
challengeMapId: challengeMapId,
id: 1,
title: "Complete Crimson Cauldron",
description: "Complete Curse of the Crimson Cauldron.",
tooltip: "Complete ^CrimsonCauldron.",
config: "{\"ct\":6,\"vs\":[2],\"ex\":true,\"in\":true}",
goal: 1),
CurrentChallenge(
accountId: accountId,
challengeMapId: challengeMapId,
id: 2,
title: "Complete Quest for the Golden Trophy",
description: "Complete Quest for the Golden Trophy.",
tooltip: "Complete ^GoldenTrophy.",
config: "{\"ct\":6,\"vs\":[2],\"ex\":true,\"in\":true}",
goal: 1),
CurrentChallenge(
accountId: accountId,
challengeMapId: challengeMapId,
id: 3,
title: "Beat the Rise of the Jumbotron",
description: "Complete The Rise of Jumbotron.",
tooltip: "Complete ^TheRiseOfJumbotron.",
config: "{\"ct\":6,\"vs\":[2],\"ex\":true,\"in\":true}",
goal: 1),
CurrentChallenge(
accountId: accountId,
challengeMapId: challengeMapId,
id: 4,
title: "Complete 5 games of Paintball",
description: "Complete 5 games of Paintball.",
tooltip: "Finish 5 full games in ^Paintball.",
config: "{\"ct\":1,\"ctc\":[{\"ct\":6,\"vs\":[2],\"ex\":true,\"in\":true}],\"cc\":5}",
goal: 5),
CurrentChallenge(
accountId: accountId,
challengeMapId: challengeMapId,
id: 5,
title: "Complete 5 games of Dodgeball",
description: "Complete 5 games of Dodgeball.",
tooltip: "Finish 5 full games in ^Dodgeball.",
config: "{\"ct\":1,\"ctc\":[{\"ct\":6,\"vs\":[2],\"ex\":true,\"in\":true}],\"cc\":5}",
goal: 5)
};
int completedChallengeCount = Enumerable.Range(1, 5)
.Count(id => RecNetDB.GetChallengeProgress(
accountId,
challengeMapId,
id,
ChallengeGoal(id)).Complete);
if (accountId > 0 && completedChallengeCount >= 3)
{
GrantPlayerEquipment(
accountId,
WeeklyChallengeRewardEquipmentPrefab,
WeeklyChallengeRewardModificationGuid);
}
Response.Headers.CacheControl = "private, max-age=30";
return Ok(new
{
ChallengeMapId = challengeMapId,
StartAt = startsAt,
EndAt = endsAt,
ServerTime = DateTime.UtcNow,
Challenges = challenges,
Gift = new
{
GiftDropId = 52,
AvatarItemDesc = string.Empty,
Xp = 750,
Level = 0,
EquipmentPrefabName = WeeklyChallengeRewardEquipmentPrefab,
EquipmentModificationGuid = WeeklyChallengeRewardModificationGuid
},
ChallengeThemeString = "Mocha Weekly Challenges"
});
}
private static object CurrentChallenge(
long accountId,
int challengeMapId,
int id,
string title,
string description,
string tooltip,
string config,
int goal)
{
var progress = RecNetDB.GetChallengeProgress(accountId, challengeMapId, id, goal);
return new
{
ChallengeId = id,
Name = title,
Config = config,
Description = description,
Tooltip = tooltip,
Complete = progress.Complete
};
}
private static int ChallengeGoal(int challengeId) =>
challengeId is 4 or 5 ? 5 : 1;
[HttpPost("/api/challenge/v2/updateProgress")]
[HttpPut("/api/challenge/v2/updateProgress")]
public IActionResult UpdateChallengeProgress([FromBody] JsonElement body)
{
long? accountId = AuthStuff.GetPlayerId(Request);
if (accountId == null)
return Unauthorized();
var (currentMapId, _, _) = RecNetDB.GetCurrentChallengeWindow();
int challengeMapId = FindJsonInt(body, "ChallengeMapId") ?? currentMapId;
int challengeId = FindJsonInt(body, "ChallengeId") ?? 0;
if (challengeId is < 1 or > 5)
return BadRequest(new { success = false, error = "invalid_challenge" });
int goal = ChallengeGoal(challengeId);
int? suppliedProgress = FindJsonInt(body, "Progress", "CurrentProgress", "Count");
bool? suppliedComplete = FindJsonBool(body, "Complete", "Completed", "IsCompleted");
var existing = RecNetDB.GetChallengeProgress(
accountId.Value,
challengeMapId,
challengeId,
goal);
int progress = suppliedProgress ?? (suppliedComplete == true ? goal : existing.Progress + 1);
bool? completionOverride = suppliedComplete == true ? true : null;
var saved = RecNetDB.SetChallengeProgress(
accountId.Value,
challengeMapId,
challengeId,
progress,
goal,
completionOverride);
return Ok(new
{
success = true,
ChallengeMapId = saved.ChallengeMapId,
ChallengeId = saved.ChallengeId,
Progress = saved.Progress,
Goal = saved.Goal,
Complete = saved.Complete
});
}
private static int? FindJsonInt(JsonElement element, params string[] names)
{
if (element.ValueKind == JsonValueKind.Object)
{
foreach (var property in element.EnumerateObject())
{
if (names.Any(name => string.Equals(property.Name, name, StringComparison.OrdinalIgnoreCase)) &&
((property.Value.ValueKind == JsonValueKind.Number &&
property.Value.TryGetInt32(out int value)) ||
(property.Value.ValueKind == JsonValueKind.String &&
int.TryParse(property.Value.GetString(), out value))))
{
return value;
}
int? nested = FindJsonInt(property.Value, names);
if (nested.HasValue)
return nested;
}
}
else if (element.ValueKind == JsonValueKind.Array)
{
foreach (var item in element.EnumerateArray())
{
int? nested = FindJsonInt(item, names);
if (nested.HasValue)
return nested;
}
}
return null;
}
private static bool? FindJsonBool(JsonElement element, params string[] names)
{
if (element.ValueKind == JsonValueKind.Object)
{
foreach (var property in element.EnumerateObject())
{
if (names.Any(name => string.Equals(property.Name, name, StringComparison.OrdinalIgnoreCase)))
{
if (property.Value.ValueKind == JsonValueKind.True)
return true;
if (property.Value.ValueKind == JsonValueKind.False)
return false;
}
bool? nested = FindJsonBool(property.Value, names);
if (nested.HasValue)
return nested;
}
}
else if (element.ValueKind == JsonValueKind.Array)
{
foreach (var item in element.EnumerateArray())
{
bool? nested = FindJsonBool(item, names);
if (nested.HasValue)
return nested;
}
}
return null;
}
private static long? FindJsonLong(
JsonElement element,
params string[] names)
{
if (element.ValueKind == JsonValueKind.Object)
{
foreach (JsonProperty property in element.EnumerateObject())
{
bool nameMatches = names.Any(name =>
string.Equals(
property.Name,
name,
StringComparison.OrdinalIgnoreCase));
if (nameMatches)
{
if (property.Value.ValueKind == JsonValueKind.Number &&
property.Value.TryGetInt64(out long numberValue))
{
return numberValue;
}
if (property.Value.ValueKind == JsonValueKind.String &&
long.TryParse(
property.Value.GetString(),
out long stringValue))
{
return stringValue;
}
}
long? nestedValue = FindJsonLong(property.Value, names);
if (nestedValue.HasValue)
return nestedValue;
}
}
else if (element.ValueKind == JsonValueKind.Array)
{
foreach (JsonElement item in element.EnumerateArray())
{
long? nestedValue = FindJsonLong(item, names);
if (nestedValue.HasValue)
return nestedValue;
}
}
return null;
}
private static string? FindJsonString(
JsonElement element,
params string[] names)
{
if (element.ValueKind == JsonValueKind.Object)
{
foreach (JsonProperty property in element.EnumerateObject())
{
bool nameMatches = names.Any(name =>
string.Equals(
property.Name,
name,
StringComparison.OrdinalIgnoreCase));
if (nameMatches)
{
string? result = property.Value.ValueKind switch
{
JsonValueKind.String => property.Value.GetString(),
JsonValueKind.Number or
JsonValueKind.True or
JsonValueKind.False => property.Value.GetRawText(),
_ => null
};
if (!string.IsNullOrWhiteSpace(result))
return result;
}
string? nestedValue = FindJsonString(property.Value, names);
if (!string.IsNullOrWhiteSpace(nestedValue))
return nestedValue;
}
}
else if (element.ValueKind == JsonValueKind.Array)
{
foreach (JsonElement item in element.EnumerateArray())
{
string? nestedValue = FindJsonString(item, names);
if (!string.IsNullOrWhiteSpace(nestedValue))
return nestedValue;
}
}
return null;
}
[HttpGet("/api/roomcurrencies/v1/getAllBalances")]
public IActionResult StubGetAllRoomCurrencyBalances(
[FromQuery] long roomId)
{
return Ok(System.Array.Empty<object>());
}
[HttpGet("/acc/parentalcontrol/me")]
public IActionResult StubGetMyParentalControls()
{
return Ok(new
{
IsEnabled = false,
IsJunior = false
});
}
[HttpGet("/subscription/mine/member")]
public IActionResult GetMySubscriptionMembership()
{
long? accountId = AuthStuff.GetPlayerId(Request);
if (!accountId.HasValue)
return Unauthorized();
bool hasRRPlus = PlayerDB.HasRRPlus(accountId.Value);
return Ok(hasRRPlus
? new[]
{
new
{
IsActive = true,
IsSubscribed = true,
Status = "Active",
ExpiresAt = "2099-12-31T23:59:59Z"
}
}
: Array.Empty<object>());
}
[HttpPost("/api/CampusCard/v1/UpdateAndGetSubscription")]
public IActionResult UpdateAndGetSubscription()
{
var rawAccountId = AuthStuff.GetPlayerId(Request);
if (rawAccountId == null)
return Unauthorized();
long playerId = (long)rawAccountId;
int recNetPlayerId = checked((int)playerId);
bool hasRRPlus = PlayerDB.HasRRPlus(playerId);
if (!hasRRPlus)
return Ok(new { subscription = (object?)null, platformAccountSubscribedPlayerId = (int?)null });
DateTime now = DateTime.UtcNow;
var response = new
{
subscription = new
{
subscriptionId = playerId,
recNetPlayerId = recNetPlayerId,
platformType = 0,
platformId = $"local-{playerId}",
platformPurchaseId = $"local-rrplus-{playerId}",
level = 1,
period = 0,
expirationDate = now.AddYears(10),
isAutoRenewing = false,
createdAt = now.AddDays(-1),
modifiedAt = now
},
platformAccountSubscribedPlayerId = (int?)null
};
return Ok(response);
}
[HttpGet("/api/roomkeys/v1/room")]
public IActionResult StubGetRoomKeys([FromQuery] long roomId)
{
return Ok(System.Array.Empty<object>());
}
[HttpGet("/api/keepsakes/categories")]
[HttpGet("/api/keepsakes/v1/categories")]
public IActionResult StubGetKeepsakeCategories()
{
return Ok(System.Array.Empty<object>());
}
[HttpGet("/api/influencerpartnerprogram/influencers")]
public IActionResult GetInfluencers(
[FromQuery] int take = 1000,
[FromQuery] string? continuationToken = null)
{
int safeTake = Math.Clamp(take, 1, 1000);
var influencerIds = PlayerDB.Players
.FindAll()
.Where(player =>
player.PlayerId > 0 &&
player.PlayerId <= int.MaxValue &&
player.PlayerRoles != null &&
player.PlayerRoles.Contains(
PlayerDBClasses.PlayerRoles.Influencer))
.OrderBy(player => player.PlayerId)
.Take(safeTake)
.Select(player => (int)player.PlayerId)
.ToList();
Console.WriteLine(
$"[INFLUENCER] Returning {influencerIds.Count}: " +
string.Join(", ", influencerIds)
);
return Ok(new
{
influencerIds,
continuationToken = (string?)null
});
}
[HttpGet("/api/incentivizedreferrals/progress")]
public IActionResult StubGetIncentivizedReferralProgress()
{
return Ok(new
{
Progress = 0,
Goal = 0,
IsComplete = false,
Rewards = System.Array.Empty<object>()
});
}
[HttpGet("/api/progressionEvents/active")]
[HttpGet("/api/progressionEvents/v1/active")]
public IActionResult GetActiveProgressionEventId()
{
return Content("null", "application/json");
}
[HttpGet("api/versioncheck/v4")]
public IActionResult VersionCheck()
{
return Ok(new
{
ValidVersion = 0,
VersionStatus = 0,
UpdateNotificationStage = 0,
IsVersionIslanded = false,
IsCrossPlayDisabled = false
});
}
[HttpGet("api/gameconfigs/v1/all")]
public IActionResult GetGameConfigs()
{
string path = Path.Combine(Program.dataDir, "APIS", "GameConfigs.json");
if (!System.IO.File.Exists(path))
return NotFound();
string json = System.IO.File.ReadAllText(path);
try
{
if (JsonNode.Parse(json) is not JsonArray configs)
return Content(json, "application/json");
SetGameConfigValue(
configs,
"Friendotron_AllowSelfGifting",
"True");
SetGameConfigValue(
configs,
"Friendotron_MaxUsersPerSession",
"1");
return Content(configs.ToJsonString(), "application/json");
}
catch (JsonException)
{
return Content(json, "application/json");
}
}
private static void SetGameConfigValue(
JsonArray configs,
string key,
string value)
{
bool found = false;
foreach (JsonNode? node in configs)
{
if (node is not JsonObject config ||
!string.Equals(
config["Key"]?.ToString(),
key,
StringComparison.Ordinal))
{
continue;
}
config["Value"] = value;
found = true;
}
if (!found)
{
configs.Add(new JsonObject
{
["Key"] = key,
["Value"] = value
});
}
}
[HttpGet("api/config/v1/amplitude")]
public IActionResult GetAmplitude()
{
return Ok(new
{
AmplitudeKey = "cb2fb2ecb9953512c29af5bca58f2b4a",
UseRudderStack = true,
RudderStackKey = "23NiJHIgu3koaGNCZIiuYvIQNCu",
UseStatSig = true,
StatSigKey = "client-SBZkOrjD3r1Cat3f3W8K6sBd11WKlXZXIlCWj6l4Aje",
StatSigEnvironment = 0
});
}
[HttpGet("/api/avatar/")]
[HttpGet("/api/avatar")]
public IActionResult GetAvatarBase()
{
long? accountId = AuthStuff.GetPlayerId(Request);
if (accountId == null)
return Unauthorized();
return Ok(new { AccountId = accountId.Value });
}
[HttpGet("/api/config/")]
[HttpGet("/api/config")]
public IActionResult GetGeneralConfig()
{
return Ok(new
{
ServerName = "Mocha",
GameVersion = ServerConfig.GameVersion,
MaintenanceMinutes = 0
});
}
[HttpGet("/api/config/v1/freegiftbutton")]
public IActionResult GetFreeGiftButtonConfig()
{
return Ok(new { Enabled = false, Available = false });
}
[HttpGet("/api/players/v4/current/contact")]
public IActionResult GetMyContactInfo()
{
long? accountId = AuthStuff.GetPlayerId(Request);
if (accountId == null)
return Unauthorized();
var account = PlayerDB.Players.FindById(accountId.Value);
if (account?.Player == null)
return NotFound();
return Ok(new
{
Email = account.Player.Email ?? string.Empty,
EmailVerified = !string.IsNullOrWhiteSpace(account.Player.Email),
Phone = (string?)null,
PhoneVerified = false
});
}
[HttpGet("/api/playerwarnings")]
public IActionResult GetMyPlayerWarnings()
{
long? accountId = AuthStuff.GetPlayerId(Request);
if (accountId == null)
return Unauthorized();
var warnings = (PlayerDB.Players.FindById(accountId.Value)?.Player?.PlayerExtra?.Warnings
?? new List<string>())
.Select(warningKey => new { WarningKey = warningKey, Acknowledged = false })
.ToList();
return Ok(warnings);
}
public class AcknowledgeWarningRequest
{
public string? WarningKey { get; set; }
}
[HttpPost("/api/playerwarnings/acknowledge")]
public IActionResult AcknowledgePlayerWarning([FromBody] AcknowledgeWarningRequest request)
{
long? accountId = AuthStuff.GetPlayerId(Request);
if (accountId == null)
return Unauthorized();
var account = PlayerDB.Players.FindById(accountId.Value);
var warnings = account?.Player?.PlayerExtra?.Warnings;
if (account?.Player?.PlayerExtra != null && warnings != null &&
!string.IsNullOrWhiteSpace(request.WarningKey))
{
warnings.RemoveAll(value => value == request.WarningKey);
PlayerDB.Players.Update(account);
}
return Ok(new { success = true });
}
public class ConsumableTransferRequest
{
public string? ConsumableItemDesc { get; set; }
public long ConsumableItemId { get; set; }
public long ToAccountId { get; set; }
public int Amount { get; set; } = 1;
}
[HttpPost("/api/consumables/v1/transfer")]
public IActionResult TransferConsumable([FromBody] ConsumableTransferRequest request)
{
long? accountId = AuthStuff.GetPlayerId(Request);
if (accountId == null)
return Unauthorized();
if (request.ToAccountId == accountId.Value)
return BadRequest(new { error = "Can't transfer to yourself." });
if (request.Amount <= 0)
return BadRequest(new { error = "Amount must be positive." });
if (PlayerDB.Players.FindById(request.ToAccountId)?.Player == null)
return NotFound(new { error = "Recipient not found." });
string descriptor = request.ConsumableItemDesc ?? string.Empty;
int owned = PlayerInventoryStore.GetConsumableQuantity(accountId.Value, descriptor, request.ConsumableItemId);
if (owned < request.Amount)
return BadRequest(new { error = "You don't have enough of that item." });
PlayerInventoryStore.SetConsumableQuantity(accountId.Value, descriptor, request.ConsumableItemId, null, owned - request.Amount);
PlayerInventoryStore.AddConsumable(request.ToAccountId, descriptor, request.ConsumableItemId, null, request.Amount);
return Ok(new { success = true });
}
[HttpGet("/api/customAvatarItems")]
[HttpGet("/api/customAvatarItems/v1/me")]
public IActionResult GetMyCustomAvatarItems()
{
long? accountId = AuthStuff.GetPlayerId(Request);
if (accountId == null)
return Unauthorized();
return Ok(LoadCustomAvatarItems(accountId.Value));
}
public class CustomAvatarItemsBulkRequest
{
public List<long>? CreatorAccountIds { get; set; }
}
[HttpPost("/api/customAvatarItems/v1/bulk")]
public IActionResult GetCustomAvatarItemsBulk([FromBody] CustomAvatarItemsBulkRequest request)
{
if (AuthStuff.GetPlayerId(Request) == null)
return Unauthorized();
var items = (request.CreatorAccountIds ?? new List<long>())
.Distinct()
.SelectMany(LoadCustomAvatarItems)
.ToList();
return Ok(items);
}
public class ItemWishlistRequest
{
public string? ItemDesc { get; set; }
}
[HttpGet("/api/itemWishlists")]
public IActionResult GetMyItemWishlist()
{
long? accountId = AuthStuff.GetPlayerId(Request);
if (accountId == null)
return Unauthorized();
var wishlist = PlayerDB.Players.FindById(accountId.Value)?.Player?.PlayerExtra?.ItemWishlist
?? new List<string>();
return Ok(wishlist);
}
[HttpPost("/api/itemWishlists/v1/wishlist/")]
[HttpPost("/api/itemWishlists/v1/wishlist")]
public IActionResult AddToItemWishlist([FromBody] ItemWishlistRequest request)
{
long? accountId = AuthStuff.GetPlayerId(Request);
if (accountId == null)
return Unauthorized();
if (string.IsNullOrWhiteSpace(request.ItemDesc))
return BadRequest(new { error = "Missing item." });
var account = PlayerDB.Players.FindById(accountId.Value);
if (account?.Player == null)
return Unauthorized();
account.Player.PlayerExtra ??= new PlayerDBClasses.PlayerExtra();
account.Player.PlayerExtra.ItemWishlist ??= new List<string>();
if (!account.Player.PlayerExtra.ItemWishlist.Contains(request.ItemDesc))
{
account.Player.PlayerExtra.ItemWishlist.Add(request.ItemDesc);
PlayerDB.Players.Update(account);
}
return Ok(new { success = true, wishlist = account.Player.PlayerExtra.ItemWishlist });
}
[HttpDelete("/api/itemWishlists/v1/wishlist/")]
[HttpDelete("/api/itemWishlists/v1/wishlist")]
public IActionResult RemoveFromItemWishlist([FromBody] ItemWishlistRequest request)
{
long? accountId = AuthStuff.GetPlayerId(Request);
if (accountId == null)
return Unauthorized();
var account = PlayerDB.Players.FindById(accountId.Value);
var wishlist = account?.Player?.PlayerExtra?.ItemWishlist;
if (wishlist != null && !string.IsNullOrWhiteSpace(request.ItemDesc))
{
wishlist.Remove(request.ItemDesc);
PlayerDB.Players.Update(account!);
}
return Ok(new { success = true });
}
public class GenericReportRequest
{
public long TargetId { get; set; }
public string? Reason { get; set; }
public string? Details { get; set; }
}
[HttpPost("/api/rooms/v2/report")]
public IActionResult ReportRoomV2([FromBody] GenericReportRequest request)
{
long? accountId = AuthStuff.GetPlayerId(Request);
if (accountId == null)
return Unauthorized();
var room = RoomDB.Rooms.FindById(request.TargetId);
DiscordLogger.LogReport(
$"🚩 **Room reported** - \"{room?.Name ?? $"Room {request.TargetId}"}\" (ID {request.TargetId}) " +
$"by account {accountId.Value}: {request.Reason ?? "No reason given."} {request.Details}");
return Ok(new { success = true });
}
[HttpPost("/api/clubreporting/v1/report")]
public IActionResult ReportClub([FromBody] GenericReportRequest request)
{
long? accountId = AuthStuff.GetPlayerId(Request);
if (accountId == null)
return Unauthorized();
DiscordLogger.LogReport(
$"🚩 **Club reported** - Club {request.TargetId} by account {accountId.Value}: " +
$"{request.Reason ?? "No reason given."} {request.Details}");
return Ok(new { success = true });
}
[HttpPost("/api/screensharereports/v1/report")]
public IActionResult ReportScreenShare([FromBody] GenericReportRequest request)
{
long? accountId = AuthStuff.GetPlayerId(Request);
if (accountId == null)
return Unauthorized();
DiscordLogger.LogReport(
$"🚩 **Screen share reported** - target account {request.TargetId} by account {accountId.Value}: " +
$"{request.Reason ?? "No reason given."} {request.Details}");
return Ok(new { success = true });
}
[HttpPost("/api/inventions/v1/report")]
public IActionResult ReportInvention([FromBody] GenericReportRequest request)
{
long? accountId = AuthStuff.GetPlayerId(Request);
if (accountId == null)
return Unauthorized();
DiscordLogger.LogReport(
$"🚩 **Invention reported** - Invention {request.TargetId} by account {accountId.Value}: " +
$"{request.Reason ?? "No reason given."} {request.Details}");
return Ok(new { success = true });
}
public class BugReportRequest
{
public string? Description { get; set; }
public string? Category { get; set; }
}
[HttpPost("/api/bugreports/v1/report")]
public IActionResult ReportBug([FromBody] BugReportRequest request)
{
var reporter = AuthStuff.GetCurrentPlayer(Request);
if (reporter?.Player == null)
return Unauthorized();
string description = request.Description?.Trim() ?? string.Empty;
if (description.Length is < 3 or > 4000)
return BadRequest(new { error = "Description must be 3-4000 characters." });
var bugReport = new ReportsDB.BugReport
{
ReporterId = reporter.PlayerId,
ReporterUsername = reporter.Player.Username,
Description = description,
Category = request.Category?.Trim()
};
ReportsDB.BugReports.Insert(bugReport);
DiscordLogger.LogReport(
$"🐛 **Bug report** from {reporter.Player.Username ?? reporter.PlayerId.ToString()} " +
$"({(string.IsNullOrEmpty(bugReport.Category) ? "uncategorized" : bugReport.Category)}): " +
$"{description}");
return Ok(new { success = true, id = bugReport.Id });
}
[HttpGet("/api/roomconsumables/v1/roomConsumable")]
public IActionResult GetRoomConsumableCatalog()
{
if (AuthStuff.GetPlayerId(Request) == null)
return Unauthorized();
return Ok(ServerConfig.Bracket);
}
[HttpGet("/api/roomEarningsDistributions/v1/earningsDistribution")]
public IActionResult GetRoomEarningsDistribution()
{
if (AuthStuff.GetPlayerId(Request) == null)
return Unauthorized();
return Ok(new { TotalEarnings = 0, Distributions = Array.Empty<object>() });
}
[HttpPost("/api/roomCurrencies/v2/purchase")]
[HttpPost("/api/roomconsumables/v1/roomconsumable/{skuId}/purchase/tokens")]
public IActionResult PurchaseRoomCurrencyOrConsumable()
{
if (AuthStuff.GetPlayerId(Request) == null)
return Unauthorized();
return Ok(new { success = true });
}
[HttpGet("api/avatar/v1/defaultunlocked")]
public IActionResult GetDefaultUnlocked()
{
return Ok(ServerConfig.Bracket);
}
[HttpGet("api/avatar/v1/defaultbaseavataritems")]
public IActionResult GetDefaultBaseAvatarItems()
{
return Ok(Array.Empty<object>());
}
[HttpPost("/api/ageverification/generatecode")]
[HttpGet("/api/ageverification/generatecode")]
public IActionResult GenerateAgeVerificationCode()
{
var id = AuthStuff.GetPlayerId(Request);
if (id == null)
return Unauthorized();
string code = AgeVerificationDB.GenerateCode(id.Value);
string verificationUrl = $"{ServerConfig.BaseURL.TrimEnd('/')}/recnet/#ageverification?code={code}";
return Ok(new
{
ActionCode = code,
VerificationUrl = verificationUrl
});
}
[HttpGet("/api/banappeal/generatecode")]
public IActionResult GenerateBanAppealCodeDirect()
{
long? accountId = AuthStuff.GetPlayerId(Request);
if (accountId == null)
return Unauthorized(new { error = "You must be logged in." });
var account = PlayerDB.Players.FindById(accountId.Value);
PlayerDBClasses.ModerationBlockDetails? details = account?.Player?.PlayerExtra?.ModerationBlockDetails;
if (account?.Player == null || details == null ||
!PlayerDB.IsPlayerBanned(accountId.Value, out _))
{
return BadRequest(new { error = "This account isn't currently banned." });
}
if (string.IsNullOrWhiteSpace(details.AppealCode))
{
const string appealCodeChars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
Span<char> buffer = stackalloc char[10];
for (int i = 0; i < buffer.Length; i++)
buffer[i] = appealCodeChars[System.Random.Shared.Next(appealCodeChars.Length)];
details.AppealCode = new string(buffer);
PlayerDB.Players.Update(account);
}
return Ok(new
{
code = details.AppealCode,
appealUrl = $"{ServerConfig.BaseURL.TrimEnd('/')}/recnet/banappeal?code={details.AppealCode}",
alreadySubmitted = details.AppealSubmitted
});
}
private static object ToPlayerEventDto(PlayerEventDB.PlayerEvent evt)
{
var creator = PlayerDB.Players.FindById(evt.CreatorAccountId);
return new
{
Id = evt.EventId,
EventId = evt.EventId,
CreatorAccountId = evt.CreatorAccountId,
CreatorDisplayName = creator?.Player?.DisplayName ?? creator?.Player?.Username ?? $"Player {evt.CreatorAccountId}",
Name = evt.Name,
Description = evt.Description,
Image = string.IsNullOrWhiteSpace(evt.ImageName) ? null : $"{ServerConfig.BaseURL.TrimEnd('/')}/imageserver-v2/{evt.ImageName}",
ImageName = evt.ImageName,
StartsAt = evt.StartsAt,
EndsAt = evt.EndsAt,
RoomId = evt.RoomId,
ClubId = evt.ClubId,
Accessibility = evt.Accessibility,
MultiInstance = evt.MultiInstance,
Tags = evt.Tags,
CreatedAt = evt.CreatedAt,
UpdatedAt = evt.UpdatedAt
};
}
private bool CanManagePlayerEvent(PlayerEventDB.PlayerEvent evt, long accountId)
{
if (evt.CreatorAccountId == accountId)
return true;
var account = PlayerDB.Players.FindById(accountId);
return account?.PlayerRoles?.Contains(PlayerDBClasses.PlayerRoles.Developer) == true ||
account?.PlayerRoles?.Contains(PlayerDBClasses.PlayerRoles.Moderator) == true;
}
public class PlayerEventCreateRequest
{
public string? Name { get; set; }
public string? Description { get; set; }
public string? ImageName { get; set; }
public DateTime StartsAt { get; set; }
public DateTime? EndsAt { get; set; }
public long? RoomId { get; set; }
public long? ClubId { get; set; }
public string? Accessibility { get; set; }
public bool MultiInstance { get; set; }
public List<string>? Tags { get; set; }
}
[HttpPost("/api/playerevents/v2")]
public IActionResult CreatePlayerEvent([FromBody] PlayerEventCreateRequest request)
{
long? accountId = AuthStuff.GetPlayerId(Request);
if (accountId == null)
return Unauthorized();
string name = (request.Name ?? string.Empty).Trim();
if (name.Length is < 1 or > 100)
return BadRequest(new { error = "Event name must be 1-100 characters." });
var evt = PlayerEventDB.Create(new PlayerEventDB.PlayerEvent
{
CreatorAccountId = accountId.Value,
Name = name,
Description = (request.Description ?? string.Empty).Trim(),
ImageName = request.ImageName ?? string.Empty,
StartsAt = request.StartsAt == default ? DateTime.UtcNow : request.StartsAt.ToUniversalTime(),
EndsAt = request.EndsAt?.ToUniversalTime(),
RoomId = request.RoomId,
ClubId = request.ClubId,
Accessibility = request.Accessibility is "Private" or "ClubOnly" ? request.Accessibility : "Public",
MultiInstance = request.MultiInstance,
Tags = (request.Tags ?? new List<string>()).Take(20).ToList()
});
return Ok(ToPlayerEventDto(evt));
}
[HttpGet("/api/playerevents/v2/{eventId:long}")]
[HttpGet("/api/playerevents/v1/{eventId:long}")]
public IActionResult GetPlayerEvent(long eventId)
{
long? accountId = AuthStuff.GetPlayerId(Request);
if (accountId == null)
return Unauthorized();
var evt = PlayerEventDB.Get(eventId);
if (evt == null)
return NotFound(new { error = "Event not found." });
return Ok(ToPlayerEventDto(evt));
}
[HttpDelete("/api/playerevents/v2/delete/{eventId:long}")]
public IActionResult DeletePlayerEvent(long eventId)
{
long? accountId = AuthStuff.GetPlayerId(Request);
if (accountId == null)
return Unauthorized();
var evt = PlayerEventDB.Get(eventId);
if (evt == null)
return NotFound(new { error = "Event not found." });
if (!CanManagePlayerEvent(evt, accountId.Value))
return StatusCode(403);
PlayerEventDB.Delete(eventId);
return Ok(new { success = true });
}
public class PlayerEventFieldUpdate
{
public string? Value { get; set; }
}
private IActionResult UpdatePlayerEventField(long eventId, Action<PlayerEventDB.PlayerEvent> apply)
{
long? accountId = AuthStuff.GetPlayerId(Request);
if (accountId == null)
return Unauthorized();
var evt = PlayerEventDB.Get(eventId);
if (evt == null)
return NotFound(new { error = "Event not found." });
if (!CanManagePlayerEvent(evt, accountId.Value))
return StatusCode(403);
apply(evt);
PlayerEventDB.Update(evt);
return Ok(ToPlayerEventDto(evt));
}
[HttpPut("/api/playerevents/v2/{eventId:long}/name")]
public IActionResult UpdatePlayerEventName(long eventId, [FromBody] PlayerEventFieldUpdate request) =>
UpdatePlayerEventField(eventId, evt =>
{
string name = (request.Value ?? string.Empty).Trim();
if (name.Length is >= 1 and <= 100)
evt.Name = name;
});
[HttpPut("/api/playerevents/v2/{eventId:long}/description")]
public IActionResult UpdatePlayerEventDescription(long eventId, [FromBody] PlayerEventFieldUpdate request) =>
UpdatePlayerEventField(eventId, evt => evt.Description = (request.Value ?? string.Empty).Trim());
[HttpPut("/api/playerevents/v2/{eventId:long}/image")]
public IActionResult UpdatePlayerEventImage(long eventId, [FromBody] PlayerEventFieldUpdate request) =>
UpdatePlayerEventField(eventId, evt => evt.ImageName = request.Value ?? string.Empty);
public class PlayerEventTimeUpdate
{
public DateTime StartsAt { get; set; }
public DateTime? EndsAt { get; set; }
}
[HttpPut("/api/playerevents/v2/{eventId:long}/time")]
public IActionResult UpdatePlayerEventTime(long eventId, [FromBody] PlayerEventTimeUpdate request) =>
UpdatePlayerEventField(eventId, evt =>
{
if (request.StartsAt != default)
evt.StartsAt = request.StartsAt.ToUniversalTime();
evt.EndsAt = request.EndsAt?.ToUniversalTime();
});
public class PlayerEventRoomUpdate
{
public long? RoomId { get; set; }
}
[HttpPut("/api/playerevents/v2/{eventId:long}/room")]
public IActionResult UpdatePlayerEventRoom(long eventId, [FromBody] PlayerEventRoomUpdate request) =>
UpdatePlayerEventField(eventId, evt => evt.RoomId = request.RoomId);
public class PlayerEventClubUpdate
{
public long? ClubId { get; set; }
}
[HttpPut("/api/playerevents/v2/{eventId:long}/club")]
public IActionResult UpdatePlayerEventClub(long eventId, [FromBody] PlayerEventClubUpdate request) =>
UpdatePlayerEventField(eventId, evt => evt.ClubId = request.ClubId);
[HttpPut("/api/playerevents/v2/{eventId:long}/accessibility")]
public IActionResult UpdatePlayerEventAccessibility(long eventId, [FromBody] PlayerEventFieldUpdate request) =>
UpdatePlayerEventField(eventId, evt =>
{
if (request.Value is "Public" or "Private" or "ClubOnly")
evt.Accessibility = request.Value;
});
public class PlayerEventMultiInstanceUpdate
{
public bool MultiInstance { get; set; }
}
[HttpPut("/api/playerevents/v2/{eventId:long}/multiinstance")]
public IActionResult UpdatePlayerEventMultiInstance(long eventId, [FromBody] PlayerEventMultiInstanceUpdate request) =>
UpdatePlayerEventField(eventId, evt => evt.MultiInstance = request.MultiInstance);
public class PlayerEventTagsUpdate
{
public List<string>? Tags { get; set; }
}
[HttpPut("/api/playerevents/v2/{eventId:long}/tags")]
public IActionResult UpdatePlayerEventTags(long eventId, [FromBody] PlayerEventTagsUpdate request) =>
UpdatePlayerEventField(eventId, evt => evt.Tags = (request.Tags ?? new List<string>()).Take(20).ToList());
[HttpGet("/api/playerevents/v1/all/{accountId:long}")]
public IActionResult GetPlayerEventsForAccount(long accountId)
{
if (AuthStuff.GetPlayerId(Request) == null)
return Unauthorized();
return Ok(PlayerEventDB.GetByCreator(accountId).Select(ToPlayerEventDto).ToList());
}
[HttpGet("/api/playerevents/v1/club/{clubId:long}")]
public IActionResult GetPlayerEventsForClub(long clubId)
{
if (AuthStuff.GetPlayerId(Request) == null)
return Unauthorized();
return Ok(PlayerEventDB.GetByClub(clubId).Select(ToPlayerEventDto).ToList());
}
[HttpGet("/api/playerevents/v1/clubs")]
public IActionResult GetPlayerEventsForMyClubs()
{
long? accountId = AuthStuff.GetPlayerId(Request);
if (accountId == null)
return Unauthorized();
var clubIds = ClubDB.GetMemberClubs(accountId.Value).Select(club => club.ClubId).ToHashSet();
var events = clubIds
.SelectMany(PlayerEventDB.GetByClub)
.DistinctBy(evt => evt.EventId)
.Select(ToPlayerEventDto)
.ToList();
return Ok(events);
}
public class PlayerEventBulkRequest
{
public List<long>? EventIds { get; set; }
}
[HttpPost("/api/playerevents/v1/bulk")]
public IActionResult GetPlayerEventsBulk([FromBody] PlayerEventBulkRequest request)
{
if (AuthStuff.GetPlayerId(Request) == null)
return Unauthorized();
var ids = request.EventIds ?? new List<long>();
return Ok(PlayerEventDB.GetByIds(ids).Select(ToPlayerEventDto).ToList());
}
public class PlayerEventRespondRequest
{
public long EventId { get; set; }
public string? ResponseType { get; set; }
}
[HttpPost("/api/playerevents/v1/respond")]
public IActionResult RespondToPlayerEvent([FromBody] PlayerEventRespondRequest request)
{
long? accountId = AuthStuff.GetPlayerId(Request);
if (accountId == null)
return Unauthorized();
var evt = PlayerEventDB.Get(request.EventId);
if (evt == null)
return NotFound(new { error = "Event not found." });
string responseType = request.ResponseType is "Going" or "Interested" or "NotGoing"
? request.ResponseType
: "Going";
var response = PlayerEventDB.SetResponse(request.EventId, accountId.Value, responseType);
return Ok(new { response.EventId, response.AccountId, response.ResponseType, response.CreatedAt });
}
[HttpPost("/api/playerevents/v1/deleteResponse")]
[HttpDelete("/api/playerevents/v1/deleteResponse")]
public IActionResult DeletePlayerEventResponse([FromBody] PlayerEventRespondRequest request)
{
long? accountId = AuthStuff.GetPlayerId(Request);
if (accountId == null)
return Unauthorized();
PlayerEventDB.DeleteResponse(request.EventId, accountId.Value);
return Ok(new { success = true });
}
[HttpGet("/api/playerevents/v1/{eventId:long}/responses")]
public IActionResult GetPlayerEventResponses(long eventId)
{
if (AuthStuff.GetPlayerId(Request) == null)
return Unauthorized();
var responses = PlayerEventDB.GetResponses(eventId);
var accountIds = responses.Select(value => value.AccountId).ToHashSet();
var players = PlayerDB.Players.FindAll()
.Where(value => accountIds.Contains(value.PlayerId))
.ToDictionary(value => value.PlayerId);
return Ok(responses.Select(response =>
{
players.TryGetValue(response.AccountId, out var player);
return new
{
response.AccountId,
DisplayName = player?.Player?.DisplayName ?? player?.Player?.Username ?? $"Player {response.AccountId}",
response.ResponseType,
response.CreatedAt
};
}).ToList());
}
public class PlayerEventBulkInviteRequest
{
public long EventId { get; set; }
public List<long>? AccountIds { get; set; }
}
[HttpPost("/api/playerevents/v1/bulkInvite")]
public IActionResult BulkInvitePlayerEvent([FromBody] PlayerEventBulkInviteRequest request)
{
long? accountId = AuthStuff.GetPlayerId(Request);
if (accountId == null)
return Unauthorized();
var evt = PlayerEventDB.Get(request.EventId);
if (evt == null)
return NotFound(new { error = "Event not found." });
var targets = (request.AccountIds ?? new List<long>()).Take(200).ToList();
int invited = 0;
foreach (long targetId in targets)
{
if (targetId == accountId.Value)
continue;
if (PlayerEventDB.GetResponse(evt.EventId, targetId) == null)
PlayerEventDB.SetResponse(evt.EventId, targetId, "Interested");
invited++;
}
return Ok(new { success = true, invited });
}
[HttpPost("/api/playerevents/v1/broadcast")]
public IActionResult BroadcastPlayerEvent([FromBody] PlayerEventBulkInviteRequest request)
{
long? accountId = AuthStuff.GetPlayerId(Request);
if (accountId == null)
return Unauthorized();
var evt = PlayerEventDB.Get(request.EventId);
if (evt == null || !CanManagePlayerEvent(evt, accountId.Value))
return NotFound(new { error = "Event not found." });
return Ok(new { success = true });
}
public class PlayerEventReportRequest
{
public long EventId { get; set; }
public string? Reason { get; set; }
}
[HttpPost("/api/playerevents/v1/report")]
public IActionResult ReportPlayerEvent([FromBody] PlayerEventReportRequest request)
{
long? accountId = AuthStuff.GetPlayerId(Request);
if (accountId == null)
return Unauthorized();
var evt = PlayerEventDB.Get(request.EventId);
if (evt == null)
return NotFound(new { error = "Event not found." });
DiscordLogger.LogReport(
$"🚩 **Player event reported** - \"{evt.Name}\" (Event {evt.EventId}) " +
$"by account {accountId.Value}: {request.Reason ?? "No reason given."}");
return Ok(new { success = true });
}
[HttpGet("/api/objectives/v1/myprogress")]
public IActionResult GetMyObjectiveProgress()
{
var id = AuthStuff.GetPlayerId(Request);
if (id == null)
return Unauthorized();
return Ok(new
{
Objectives = new List<object>(),
ObjectiveGroups = new List<object>()
});
}
[HttpGet("/api/avatar/v2")]
public IActionResult GetMyAvatar()
{
var player = AuthStuff.GetCurrentPlayer(Request);
if (player == null)
return Unauthorized();
return Ok(player.Player.PlayerExtra.Avatar);
}
[HttpPost("/api/avatar/v2/set")]
public IActionResult SetMyAvatar([FromBody] PlayerDBClasses.Avatar request)
{
var id = AuthStuff.GetPlayerId(Request);
if (id == null)
return Unauthorized();
return Ok(PlayerDB.SetAvatar((long)id, request));
}
[HttpGet("/api/avatar/v4/items")]
public IActionResult GetMyAvatarItems()
{
var id = AuthStuff.GetPlayerId(Request);
if (id == null)
return Unauthorized();
string path = Path.Combine(
Program.dataDir,
"APIS",
"Items",
"AvatarItems.json");
if (!System.IO.File.Exists(path))
return NotFound();
return Ok(GetAvatarItemsVisibleToPlayer((long)id, path));
}
[HttpGet("/api/avatar/v1/lockeditems")]
public IActionResult GetMyLockedAvatarItems()
{
var id = AuthStuff.GetPlayerId(Request);
if (id == null)
return Unauthorized();
string path = Path.Combine(
Program.dataDir,
"APIS",
"Items",
"AvatarItems.json");
if (!System.IO.File.Exists(path))
return Ok(Array.Empty<object>());
return Ok(GetAvatarItemsNotOwnedByPlayer((long)id, path));
}
private static List<JsonElement> GetAvatarItemsNotOwnedByPlayer(
long playerId,
string path)
{
string json = System.IO.File.ReadAllText(path);
var items = JsonSerializer.Deserialize<List<JsonElement>>(
json,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true })
?? new List<JsonElement>();
var player = PlayerDB.Players.FindById(playerId);
IEnumerable<string>? legacyItems = player?.Player?.PlayerExtra?.AvatarItems;
List<PlayerInventoryStore.AvatarItemOwnership> ownedItems =
PlayerInventoryStore.GetAvatarItems(playerId, legacyItems);
HashSet<long> ownedIds = ownedItems
.Where(item => item.AvatarItemId > 0)
.Select(item => item.AvatarItemId)
.ToHashSet();
HashSet<string> ownedDescriptors = ownedItems
.Select(item => item.AvatarItemDesc)
.Where(value => !string.IsNullOrWhiteSpace(value))
.ToHashSet(StringComparer.OrdinalIgnoreCase);
return items
.Where(item =>
{
string descriptor = item.TryGetProperty("AvatarItemDesc", out JsonElement descElement)
? descElement.GetString() ?? string.Empty
: string.Empty;
long itemId = item.TryGetProperty("AvatarItemId", out JsonElement idElement) &&
idElement.TryGetInt64(out long parsedId)
? parsedId
: 0;
string friendlyName = item.TryGetProperty("FriendlyName", out JsonElement nameElement)
? nameElement.GetString() ?? string.Empty
: string.Empty;
int rarity = item.TryGetProperty("Rarity", out JsonElement rarityElement) &&
rarityElement.TryGetInt32(out int parsedRarity)
? parsedRarity
: 0;
bool owned = (itemId > 0 && ownedIds.Contains(itemId)) ||
(!string.IsNullOrWhiteSpace(descriptor) &&
ownedDescriptors.Contains(descriptor));
bool unfinished = rarity <= 0 && string.IsNullOrWhiteSpace(friendlyName);
return !owned && !unfinished && !IsExcludedAvatarItemName(friendlyName);
})
.ToList();
}
private static List<JsonElement> GetAvatarItemsVisibleToPlayer(
long playerId,
string path)
{
string json = System.IO.File.ReadAllText(path);
var items = JsonSerializer.Deserialize<List<JsonElement>>(
json,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true })
?? new List<JsonElement>();
var player = PlayerDB.Players.FindById(playerId);
IEnumerable<string>? legacyItems = player?.Player?.PlayerExtra?.AvatarItems;
List<PlayerInventoryStore.AvatarItemOwnership> ownedItems =
PlayerInventoryStore.GetAvatarItems(playerId, legacyItems);
HashSet<long> ownedIds = ownedItems
.Where(item => item.AvatarItemId > 0)
.Select(item => item.AvatarItemId)
.ToHashSet();
HashSet<string> ownedDescriptors = ownedItems
.Select(item => item.AvatarItemDesc)
.Where(value => !string.IsNullOrWhiteSpace(value))
.ToHashSet(StringComparer.OrdinalIgnoreCase);
return items
.Where(item =>
{
string descriptor = item.TryGetProperty("AvatarItemDesc", out JsonElement descElement)
? descElement.GetString() ?? string.Empty
: string.Empty;
long itemId = item.TryGetProperty("AvatarItemId", out JsonElement idElement) &&
idElement.TryGetInt64(out long parsedId)
? parsedId
: 0;
return (itemId > 0 && ownedIds.Contains(itemId)) ||
(!string.IsNullOrWhiteSpace(descriptor) &&
ownedDescriptors.Contains(descriptor));
})
.ToList();
}
private static bool IsAlpacaShirt(JsonElement item)
{
if (item.TryGetProperty("AvatarItemId", out JsonElement itemId) &&
itemId.TryGetInt64(out long parsedItemId) &&
parsedItemId == PlayerDB.AlpacaShirtAvatarItemId)
{
return true;
}
return item.TryGetProperty("FriendlyName", out JsonElement name) &&
string.Equals(
name.GetString(),
"Alpaca Shirt",
StringComparison.OrdinalIgnoreCase);
}
private IActionResult GetJsonItems(string fileName)
{
string path = Path.Combine(Program.dataDir, "APIS", "Items", fileName);
return System.IO.File.Exists(path) ? Content(System.IO.File.ReadAllText(path), "application/json") : Ok(ServerConfig.Bracket);
}
private IActionResult GetNormalizedConsumables(long? accountId = null)
{
string path = Path.Combine(
Program.dataDir,
"APIS",
"Items",
"Consumables.json");
if (!System.IO.File.Exists(path))
return Ok(Array.Empty<object>());
try
{
JsonNode? root = JsonNode.Parse(System.IO.File.ReadAllText(path));
if (root is not JsonArray sourceItems)
{
Console.WriteLine(
"[CONSUMABLE NORMALIZE] Invalid root; expected JSON array.");
return Ok(Array.Empty<object>());
}
var normalizedItems = new JsonArray();
int repairedCreatedAts = 0;
int repairedKeys = 0;
int skipped = 0;
long returnedQuantity = 0;
Dictionary<long, int>? ownedConsumablesById = null;
Dictionary<string, int>? ownedConsumablesByDescriptor = null;
if (accountId.HasValue)
{
List<PlayerInventoryStore.ConsumableOwnership> ownedConsumables =
PlayerInventoryStore.GetConsumables(accountId.Value);
ownedConsumablesById = ownedConsumables
.Where(item => item.ConsumableItemId > 0)
.GroupBy(item => item.ConsumableItemId)
.ToDictionary(
group => group.Key,
group => (int)Math.Min(
100_000L,
group.Sum(item => (long)Math.Max(0, item.Quantity))));
ownedConsumablesByDescriptor = ownedConsumables
.Where(item => !string.IsNullOrWhiteSpace(item.ConsumableItemDesc))
.GroupBy(
item => item.ConsumableItemDesc,
StringComparer.OrdinalIgnoreCase)
.ToDictionary(
group => group.Key,
group => (int)Math.Min(
100_000L,
group.Sum(item => (long)Math.Max(0, item.Quantity))),
StringComparer.OrdinalIgnoreCase);
}
foreach (JsonNode? node in sourceItems)
{
if (node is not JsonObject source)
{
skipped++;
continue;
}
var item = source.DeepClone().AsObject();
List<long> ids = ReadConsumableIds(item);
if (ids.Count == 0)
{
skipped++;
continue;
}
item["Ids"] = new JsonArray(
ids.Select(id => JsonValue.Create(id)).ToArray());
List<string> createdAts = ReadConsumableCreatedAts(item);
string fallbackCreatedAt =
NormalizeConsumableTimestamp(ReadString(item, "CreatedAt"))
?? "2023-04-06T00:00:00.0000000Z";
if (createdAts.Count != ids.Count)
{
repairedCreatedAts++;
if (createdAts.Count > ids.Count)
createdAts = createdAts.Take(ids.Count).ToList();
while (createdAts.Count < ids.Count)
createdAts.Add(fallbackCreatedAt);
}
item["CreatedAts"] = new JsonArray(
createdAts.Select(value => JsonValue.Create(value)).ToArray());
string? descriptor = ReadString(item, "ConsumableItemDesc");
string? friendlyName = ReadString(item, "FriendlyName");
if (string.IsNullOrWhiteSpace(descriptor))
{
item["ConsumableItemDesc"] = $"legacy-consumable-{ids[0]}";
repairedKeys++;
}
if (string.IsNullOrWhiteSpace(friendlyName))
{
friendlyName = $"Consumable {ids[0]}";
item["FriendlyName"] = friendlyName;
repairedKeys++;
}
int ownedQuantity;
if (accountId.HasValue)
{
ownedQuantity = 0;
if (ownedConsumablesById != null &&
ownedConsumablesById.TryGetValue(ids[0], out int byId))
{
ownedQuantity = Math.Max(ownedQuantity, byId);
}
if (!string.IsNullOrWhiteSpace(descriptor) &&
ownedConsumablesByDescriptor != null &&
ownedConsumablesByDescriptor.TryGetValue(descriptor, out int byDescriptor))
{
ownedQuantity = Math.Max(ownedQuantity, byDescriptor);
}
}
else
{
ownedQuantity = Math.Max(1, ids.Count);
}
if (accountId.HasValue && ownedQuantity <= 0)
continue;
ownedQuantity = Math.Max(1, ownedQuantity);
long firstId = ids[0];
string firstCreatedAt = createdAts.FirstOrDefault() ?? fallbackCreatedAt;
item["Ids"] = new JsonArray(
JsonValue.Create(firstId));
item["CreatedAts"] = new JsonArray(
JsonValue.Create(firstCreatedAt));
item["Count"] = ownedQuantity;
item["InitialCount"] = ownedQuantity;
item["Quantity"] = ownedQuantity;
item["IsActive"] = false;
item["ActiveDurationMinutes"] = 0;
item["IsTransferable"] = false;
item["IsOwned"] = true;
item["Owned"] = true;
item["Purchased"] = true;
returnedQuantity += ownedQuantity;
if (IsHairDyeFriendlyName(friendlyName))
{
item["ConsumableItemType"] = 6;
item["ItemType"] = 1;
}
normalizedItems.Add(item);
}
Console.WriteLine(
$"[CONSUMABLE NORMALIZE] source={sourceItems.Count} " +
$"returned={normalizedItems.Count} " +
$"quantity={returnedQuantity} countContract=v25 " +
$"createdAtsFixed={repairedCreatedAts} " +
$"keysFixed={repairedKeys} skipped={skipped}");
return Content(
normalizedItems.ToJsonString(
new JsonSerializerOptions
{
PropertyNamingPolicy = null,
WriteIndented = false
}),
"application/json");
}
catch (Exception ex)
{
Console.WriteLine(
$"[CONSUMABLE NORMALIZE] Failed: {ex.Message}");
return Ok(Array.Empty<object>());
}
}
private static List<long> ReadConsumableIds(JsonObject item)
{
var ids = new List<long>();
if (item["Ids"] is JsonArray idArray)
{
foreach (JsonNode? idNode in idArray)
{
if (TryReadLong(idNode, out long id) && id > 0)
ids.Add(id);
}
}
if (ids.Count == 0)
{
foreach (string propertyName in new[]
{
"ConsumableItemId",
"ItemId",
"Id"
})
{
if (TryReadLong(item[propertyName], out long id) && id > 0)
{
ids.Add(id);
break;
}
}
}
return ids.Distinct().ToList();
}
private static List<string> ReadConsumableCreatedAts(JsonObject item)
{
var values = new List<string>();
if (item["CreatedAts"] is not JsonArray createdAtArray)
return values;
foreach (JsonNode? valueNode in createdAtArray)
{
string? value = null;
try
{
value = valueNode?.GetValue<string>();
}
catch
{
value = valueNode?.ToJsonString().Trim('"');
}
if (!string.IsNullOrWhiteSpace(value) &&
DateTime.TryParse(
value,
System.Globalization.CultureInfo.InvariantCulture,
System.Globalization.DateTimeStyles.RoundtripKind,
out DateTime parsed))
{
values.Add(parsed.ToUniversalTime().ToString("O"));
}
}
return values;
}
private static string? NormalizeConsumableTimestamp(string? value)
{
if (string.IsNullOrWhiteSpace(value))
return null;
return DateTime.TryParse(
value,
System.Globalization.CultureInfo.InvariantCulture,
System.Globalization.DateTimeStyles.RoundtripKind,
out DateTime parsed)
? parsed.ToUniversalTime().ToString("O")
: null;
}
private static string? ReadString(
JsonObject item,
string propertyName)
{
JsonNode? node = item[propertyName];
if (node == null)
return null;
try
{
return node.GetValue<string>();
}
catch
{
string raw = node.ToJsonString().Trim('"');
return string.Equals(raw, "null", StringComparison.OrdinalIgnoreCase)
? null
: raw;
}
}
private static bool TryReadLong(
JsonNode? node,
out long value)
{
value = 0;
if (node == null)
return false;
try
{
if (node is JsonValue jsonValue &&
jsonValue.TryGetValue<long>(out value))
{
return true;
}
}
catch
{
}
return long.TryParse(
node.ToJsonString().Trim('"'),
out value);
}
private static string GetPlayerEquipmentPath(long accountId)
{
return EquipmentInventoryStore.GetPlayerEquipmentPath(accountId);
}
private static List<PlayerDBClasses.EquipmentItem> GetOrCreatePlayerEquipment(long accountId)
{
return EquipmentInventoryStore.GetOrCreate(accountId);
}
private static void SavePlayerEquipment(
long accountId,
List<PlayerDBClasses.EquipmentItem> equipment)
{
EquipmentInventoryStore.Save(accountId, equipment);
}
private static bool OwnsEquipmentItem(
long accountId,
string prefabName,
string modificationGuid)
{
if (string.IsNullOrWhiteSpace(prefabName) ||
string.IsNullOrWhiteSpace(modificationGuid))
{
return false;
}
return GetOrCreatePlayerEquipment(accountId).Any(item =>
string.Equals(
item.PrefabName,
prefabName,
StringComparison.OrdinalIgnoreCase) &&
string.Equals(
item.ModificationGuid,
modificationGuid,
StringComparison.OrdinalIgnoreCase));
}
private static void GrantPlayerEquipment(
long accountId,
string prefabName,
string modificationGuid)
{
var equipment = GetOrCreatePlayerEquipment(accountId);
var existingReward = equipment.FirstOrDefault(item =>
string.Equals(item.PrefabName, prefabName, StringComparison.OrdinalIgnoreCase) &&
string.Equals(item.ModificationGuid, modificationGuid, StringComparison.OrdinalIgnoreCase));
if (existingReward != null)
{
existingReward.FriendlyName = "Ghost Bow Skin";
existingReward.Rarity = 50;
SavePlayerEquipment(accountId, equipment);
return;
}
string masterPath = Path.Combine(Program.dataDir, "APIS", "Items", "Equipment.json");
PlayerDBClasses.EquipmentItem? reward = null;
if (System.IO.File.Exists(masterPath))
{
var masterEquipment = JsonSerializer.Deserialize<List<PlayerDBClasses.EquipmentItem>>(
System.IO.File.ReadAllText(masterPath),
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
reward = masterEquipment?.FirstOrDefault(item =>
string.Equals(item.PrefabName, prefabName, StringComparison.OrdinalIgnoreCase) &&
string.Equals(item.ModificationGuid, modificationGuid, StringComparison.OrdinalIgnoreCase));
}
if (reward != null)
{
reward.FriendlyName = "Ghost Bow Skin";
reward.Rarity = 50;
}
equipment.Add(reward ?? new PlayerDBClasses.EquipmentItem
{
PrefabName = prefabName,
ModificationGuid = modificationGuid,
FriendlyName = "Ghost Bow Skin",
Rarity = 50,
PlatformMask = -1
});
SavePlayerEquipment(accountId, equipment);
}
private static bool GrantPurchasedEquipment(
long accountId,
CatalogSku sku)
{
if (!IsEquipmentSkin(sku))
return false;
var equipment = GetOrCreatePlayerEquipment(accountId);
if (equipment.Any(item =>
string.Equals(
item.PrefabName,
sku.EquipmentPrefabName,
StringComparison.OrdinalIgnoreCase) &&
string.Equals(
item.ModificationGuid,
sku.EquipmentModificationGuid,
StringComparison.OrdinalIgnoreCase)))
{
return true;
}
equipment.Add(new PlayerDBClasses.EquipmentItem
{
PrefabName = sku.EquipmentPrefabName,
ModificationGuid = sku.EquipmentModificationGuid,
UnlockedLevel = 0,
Favorited = false,
PlatformMask = sku.PlatformMask,
FriendlyName = sku.FriendlyName,
Tooltip = sku.Tooltip,
Rarity = sku.Rarity,
ThumbnailImage = sku.ThumbnailImage
});
SavePlayerEquipment(accountId, equipment);
return true;
}
[HttpGet("/api/equipment/v2/getUnlocked")]
public IActionResult GetMyEquipment()
{
var id = AuthStuff.GetPlayerId(Request);
if (id == null)
return Unauthorized();
var equipment = GetOrCreatePlayerEquipment((long)id);
return Ok(equipment);
}
[HttpPost("/api/equipment/v1/update")]
public IActionResult UpdateMyEquipment([FromBody] JsonElement request)
{
var id = AuthStuff.GetPlayerId(Request);
if (id == null)
return Unauthorized();
List<PlayerDBClasses.EquipmentItem> updates;
try
{
JsonElement items = request;
if (request.ValueKind == JsonValueKind.Object)
{
foreach (JsonProperty property in request.EnumerateObject())
{
if (property.Name.Equals("items", StringComparison.OrdinalIgnoreCase) ||
property.Name.Equals("equipment", StringComparison.OrdinalIgnoreCase) ||
property.Name.Equals("results", StringComparison.OrdinalIgnoreCase))
{
items = property.Value;
break;
}
}
}
updates = items.ValueKind switch
{
JsonValueKind.Array =>
JsonSerializer.Deserialize<List<PlayerDBClasses.EquipmentItem>>(
items.GetRawText(),
new JsonSerializerOptions { PropertyNameCaseInsensitive = true }) ??
new List<PlayerDBClasses.EquipmentItem>(),
JsonValueKind.Object => new List<PlayerDBClasses.EquipmentItem>
{
JsonSerializer.Deserialize<PlayerDBClasses.EquipmentItem>(
items.GetRawText(),
new JsonSerializerOptions { PropertyNameCaseInsensitive = true })!
},
_ => new List<PlayerDBClasses.EquipmentItem>()
};
updates.RemoveAll(item => item == null);
}
catch (JsonException)
{
updates = new List<PlayerDBClasses.EquipmentItem>();
}
if (updates.Count == 0)
return BadRequest(new { success = false, error = "Invalid request body." });
var equipment = GetOrCreatePlayerEquipment((long)id);
foreach (var incoming in updates)
{
var item = equipment.FirstOrDefault(e => e.ModificationGuid == incoming.ModificationGuid && e.PrefabName == incoming.PrefabName);
if (item != null)
item.Favorited = incoming.Favorited;
}
SavePlayerEquipment((long)id, equipment);
return Ok(new { success = true });
}
[HttpPost("/api/gamerewards/v1/request")]
public IActionResult RequestGameRewards()
{
var id = AuthStuff.GetPlayerId(Request);
if (id == null)
return Unauthorized();
string path = Path.Combine(Program.dataDir, "APIS", "Items", "AvatarItems.json");
if (!System.IO.File.Exists(path))
return Ok(new { Rewards = Array.Empty<object>() });
string json = System.IO.File.ReadAllText(path);
var items = JsonSerializer.Deserialize<List<JsonElement>>(json, new JsonSerializerOptions { PropertyNameCaseInsensitive = true }) ?? new List<JsonElement>();
if (items.Count == 0)
return Ok(new { Rewards = Array.Empty<object>() });
var random = new Random();
var rewards = items.OrderBy(_ => random.Next()).Take(Math.Min(3, items.Count)).ToList();
return Ok(new { Rewards = rewards });
}
public class EquipmentUpdateRequest
{
public string PrefabName { get; set; } = "";
public string ModificationGuid { get; set; } = "";
public bool Favorited { get; set; }
}
[HttpGet("/api/PlayerReporting/v1/moderationBlockDetails")]
public IActionResult GetMyModerationBlockDetails()
{
var player = AuthStuff.GetCurrentPlayer(Request);
if (player == null)
return Unauthorized();
return Ok(player.Player.PlayerExtra.ModerationBlockDetails);
}
[HttpPost("/api/freegifts/v1/sendmultiple")]
[RequestSizeLimit(64 * 1024)]
public async Task<IActionResult> SendMultipleFreeGifts()
{
long? senderId = AuthStuff.GetPlayerId(Request);
if (!senderId.HasValue)
return Unauthorized();
ParsedFreeGiftRequest parsed = await ReadFreeGiftRequestAsync();
parsed.Message = parsed.Message.Trim();
if (parsed.Message.Length > 200)
{
return BadRequest(new
{
success = false,
error = "gift_message_too_long"
});
}
Console.WriteLine(
$"[FRIENDOTRON REQUEST] sender={senderId.Value} " +
$"recipients=[{string.Join(",", parsed.RecipientIds)}] " +
$"messageLength={parsed.Message.Length} " +
$"ContentType={Request.ContentType}, ContentLength={Request.ContentLength ?? 0}");
List<long> recipientIds = parsed.RecipientIds
.Where(id =>
id > 0 &&
id <= int.MaxValue)
.Distinct()
.ToList();
if (recipientIds.Count == 0)
{
return BadRequest(new
{
success = false,
error = "missing_recipient_ids",
contentType = Request.ContentType
});
}
if (recipientIds.Count != 1)
{
return BadRequest(new
{
success = false,
error = "friendotron_one_recipient_only"
});
}
long recipientId = recipientIds[0];
if (recipientId != senderId.Value)
{
PlayerRelationship? relationship =
RelationshipDB.GetRelationship(
senderId.Value,
recipientId);
if (relationship?.RelationshipType != RelationshipType.Friend)
{
return StatusCode(StatusCodes.Status403Forbidden, new
{
success = false,
error = "friendotron_recipient_not_friend"
});
}
}
HashSet<string> installedConsumables = GetCatalogSkus()
.Where(IsConsumableCatalogItem)
.Select(item => item.ConsumableItemDesc)
.ToHashSet(StringComparer.OrdinalIgnoreCase);
List<FriendotronConsumableReward> rewardPool =
FriendotronConsumableRewards
.Where(reward => installedConsumables.Contains(
reward.ConsumableItemDesc))
.ToList();
if (rewardPool.Count == 0)
{
return StatusCode(500, new
{
success = false,
error = "friendotron_consumable_pool_empty"
});
}
FriendotronConsumableReward reward =
rewardPool[Random.Shared.Next(rewardPool.Count)];
var pendingGift = new GiftPackage
{
FromPlayerId = checked((int)senderId.Value),
Message = parsed.Message,
AvatarItemDesc = string.Empty,
ConsumableItemDesc = reward.ConsumableItemDesc,
ConsumableQuantity = 1,
EquipmentPrefabName = string.Empty,
EquipmentModificationGuid = string.Empty,
CurrencyType = (int)CurrencyType.RecCenterTokens,
GiftContext = FriendotronGiftContext,
Rarity = reward.Rarity,
Platform = -1,
PlatformMask = -1,
BalanceType =
(int)BalanceType.NonPurchasedNotUsableInP2P,
IsQuery = false,
Unique = false
};
PlayerDB.FriendotronGiftStatus queueStatus =
PlayerDB.QueueFriendotronGift(
senderId.Value,
recipientId,
pendingGift,
out GiftPackage? package,
out DateTime nextAvailableAtUtc);
if (queueStatus ==
PlayerDB.FriendotronGiftStatus.DailyLimitReached)
{
long retryAfterSeconds = Math.Max(
1,
(long)Math.Ceiling(
(nextAvailableAtUtc - DateTime.UtcNow).TotalSeconds));
Response.Headers["Retry-After"] = retryAfterSeconds.ToString(
System.Globalization.CultureInfo.InvariantCulture);
Console.WriteLine(
$"[FRIENDOTRON DENIED] sender={senderId.Value} " +
$"recipient={recipientId} reason=daily_limit " +
$"next={nextAvailableAtUtc:O}");
return StatusCode(StatusCodes.Status429TooManyRequests, new
{
success = false,
error = "friendotron_daily_limit",
nextGiftAt = nextAvailableAtUtc,
retryAfterSeconds
});
}
if (queueStatus != PlayerDB.FriendotronGiftStatus.Success ||
package == null)
{
Console.WriteLine(
$"[FRIENDOTRON ERROR] sender={senderId.Value} " +
$"recipient={recipientId} status={queueStatus}");
return queueStatus switch
{
PlayerDB.FriendotronGiftStatus.RecipientNotFound =>
NotFound(new
{
success = false,
error = "friendotron_recipient_not_found"
}),
PlayerDB.FriendotronGiftStatus.RecipientQueueFull =>
StatusCode(StatusCodes.Status409Conflict, new
{
success = false,
error = "friendotron_recipient_gift_queue_full"
}),
_ => StatusCode(
StatusCodes.Status500InternalServerError,
new
{
success = false,
error = "friendotron_delivery_failed"
})
};
}
try
{
await NotiController.NotifyGiftAsync(
senderId.Value,
recipientId,
package);
}
catch (Exception exception)
{
Console.WriteLine(
$"[FRIENDOTRON PUSH ERROR] sender={senderId.Value} " +
$"recipient={recipientId} package={package.GiftPackageId} " +
$"error={exception.Message}");
}
Console.WriteLine(
$"[FRIENDOTRON SENT] sender={senderId.Value} " +
$"recipient={recipientId} reward={reward.FriendlyName} " +
$"stars={reward.Stars} rarity={reward.Rarity} " +
$"package={package.GiftPackageId} " +
$"next={nextAvailableAtUtc:O}");
return Ok();
}
[HttpPost("/api/avatar/v2/gifts/generate")]
[HttpPost("/api/avatar/v3/gifts/generate")]
[RequestSizeLimit(64 * 1024)]
public async Task<IActionResult> GenerateGiftPackage()
{
long? playerId = AuthStuff.GetPlayerId(Request);
if (!playerId.HasValue)
return Unauthorized();
var player = PlayerDB.Players.FindById(playerId.Value);
if (player?.Player == null)
return NotFound();
if (player.PlayerRoles?.Contains(PlayerRoles.Developer) != true)
{
return StatusCode(StatusCodes.Status403Forbidden, new
{
success = false,
error = "developer_only_legacy_reward"
});
}
ParsedFreeGiftRequest parsed = await ReadFreeGiftRequestAsync();
parsed.Message = parsed.Message.Trim();
if (parsed.Message.Length > 200)
return BadRequest(new { success = false, error = "gift_message_too_long" });
player.Player.PlayerExtra ??= new PlayerExtra();
player.Player.PlayerExtra.AvatarItems ??= new List<string>();
HashSet<string> ownedItems =
player.Player.PlayerExtra.AvatarItems.ToHashSet(
StringComparer.OrdinalIgnoreCase);
List<CatalogSku> availableGifts = GetCatalogSkus()
.Where(IsUsableStoreItem)
.Where(item =>
!string.IsNullOrWhiteSpace(item.AvatarItemDesc) &&
!ownedItems.Contains(item.AvatarItemDesc))
.ToList();
if (availableGifts.Count == 0)
{
return NotFound(new
{
success = false,
error = "no_available_gifts"
});
}
CatalogSku gift =
availableGifts[Random.Shared.Next(availableGifts.Count)];
GiftPackage? package = PlayerDB.QueueGiftPackage(
playerId.Value,
new GiftPackage
{
FromPlayerId = 0,
Message = parsed.Message,
AvatarItemDesc = gift.AvatarItemDesc,
ConsumableItemDesc = gift.ConsumableItemDesc,
EquipmentPrefabName = gift.EquipmentPrefabName,
EquipmentModificationGuid =
gift.EquipmentModificationGuid,
CurrencyType = gift.CurrencyType,
GiftContext = parsed.GiftContext,
Rarity = gift.Rarity,
Platform = -1,
PlatformMask = gift.PlatformMask,
IsQuery = gift.GiftDrop.IsQuery,
Unique = gift.GiftDrop.Unique
});
if (package == null)
return StatusCode(500);
await NotiController.NotifyGiftAsync(
senderPlayerId: 0,
receiverPlayerId: playerId.Value,
package);
return Ok(package);
}
private sealed class FriendotronConsumableReward
{
public FriendotronConsumableReward(
string friendlyName,
string consumableItemDesc,
int rarity)
{
FriendlyName = friendlyName;
ConsumableItemDesc = consumableItemDesc;
Rarity = rarity;
}
public string FriendlyName { get; }
public string ConsumableItemDesc { get; }
public int Rarity { get; }
public int Stars => (Rarity / 10) + 1;
}
private sealed class ParsedFreeGiftRequest
{
public HashSet<long> RecipientIds { get; } = new();
public string Message { get; set; } = string.Empty;
public int GiftContext { get; set; }
}
private static readonly string[] FreeGiftRecipientKeys =
{
"ToPlayerIds",
"toPlayerIds",
"to_player_ids",
"PlayerIds",
"playerIds",
"RecipientIds",
"recipientIds",
"AccountIds",
"accountIds",
"ToPlayerId",
"toPlayerId",
"PlayerId",
"playerId",
"RecipientId",
"recipientId",
"AccountId",
"accountId",
"Ids",
"ids"
};
private async Task<ParsedFreeGiftRequest> ReadFreeGiftRequestAsync()
{
var result = new ParsedFreeGiftRequest();
foreach (string key in FreeGiftRecipientKeys)
{
foreach (string? value in Request.Query[key])
AddIdsFromText(value, result.RecipientIds);
}
result.Message =
Request.Query["Message"].FirstOrDefault() ??
Request.Query["message"].FirstOrDefault() ??
string.Empty;
if (int.TryParse(
Request.Query["GiftContext"].FirstOrDefault() ??
Request.Query["giftContext"].FirstOrDefault(),
out int queryContext))
{
result.GiftContext = queryContext;
}
if (Request.HasFormContentType)
{
var form = await Request.ReadFormAsync();
foreach (string key in FreeGiftRecipientKeys)
{
foreach (string? value in form[key])
AddIdsFromText(value, result.RecipientIds);
}
result.Message =
form["Message"].FirstOrDefault() ??
form["message"].FirstOrDefault() ??
result.Message;
if (int.TryParse(
form["GiftContext"].FirstOrDefault() ??
form["giftContext"].FirstOrDefault(),
out int formContext))
{
result.GiftContext = formContext;
}
return result;
}
if (!Request.Body.CanRead)
return result;
using var reader = new StreamReader(
Request.Body,
Encoding.UTF8,
detectEncodingFromByteOrderMarks: false,
bufferSize: 4096,
leaveOpen: true);
string body = await reader.ReadToEndAsync();
if (string.IsNullOrWhiteSpace(body))
return result;
try
{
using JsonDocument document = JsonDocument.Parse(body);
JsonElement root = document.RootElement;
CollectRecipientIds(root, result.RecipientIds);
result.Message =
FindJsonString(root, "Message", "message") ??
result.Message;
result.GiftContext =
FindJsonInt(root, "GiftContext", "giftContext") ??
result.GiftContext;
}
catch (JsonException exception)
{
Console.WriteLine(
$"[FREE GIFTS] Invalid JSON: {exception.Message}");
AddIdsFromText(body, result.RecipientIds);
}
return result;
}
private static void CollectRecipientIds(
JsonElement element,
HashSet<long> recipientIds)
{
switch (element.ValueKind)
{
case JsonValueKind.Array:
foreach (JsonElement child in element.EnumerateArray())
AddIdsFromJsonValue(child, recipientIds);
break;
case JsonValueKind.Object:
bool foundKnownProperty = false;
foreach (JsonProperty property in element.EnumerateObject())
{
if (FreeGiftRecipientKeys.Any(key =>
string.Equals(
key,
property.Name,
StringComparison.OrdinalIgnoreCase)))
{
foundKnownProperty = true;
AddIdsFromJsonValue(property.Value, recipientIds);
}
}
if (!foundKnownProperty)
{
foreach (JsonProperty property in element.EnumerateObject())
{
if (property.Value.ValueKind is
JsonValueKind.Object or JsonValueKind.Array)
{
CollectRecipientIds(
property.Value,
recipientIds);
}
}
}
break;
case JsonValueKind.Number:
case JsonValueKind.String:
AddIdsFromJsonValue(element, recipientIds);
break;
}
}
private static void AddIdsFromJsonValue(
JsonElement value,
HashSet<long> recipientIds)
{
switch (value.ValueKind)
{
case JsonValueKind.Number:
if (value.TryGetInt64(out long number))
recipientIds.Add(number);
break;
case JsonValueKind.String:
AddIdsFromText(value.GetString(), recipientIds);
break;
case JsonValueKind.Array:
foreach (JsonElement child in value.EnumerateArray())
AddIdsFromJsonValue(child, recipientIds);
break;
case JsonValueKind.Object:
CollectRecipientIds(value, recipientIds);
break;
}
}
private static void AddIdsFromText(
string? value,
HashSet<long> recipientIds)
{
if (string.IsNullOrWhiteSpace(value))
return;
string cleaned = value
.Replace("[", " ")
.Replace("]", " ")
.Replace("{", " ")
.Replace("}", " ")
.Replace("\"", " ")
.Replace("'", " ");
string[] pieces = cleaned.Split(
new[]
{
',', ';', '|', ' ', '\t', '\r', '\n'
},
StringSplitOptions.RemoveEmptyEntries |
StringSplitOptions.TrimEntries);
foreach (string piece in pieces)
{
string candidate = piece;
int equalsIndex = candidate.LastIndexOf('=');
if (equalsIndex >= 0)
candidate = candidate[(equalsIndex + 1)..];
int colonIndex = candidate.LastIndexOf(':');
if (colonIndex >= 0)
candidate = candidate[(colonIndex + 1)..];
if (long.TryParse(candidate, out long playerId))
recipientIds.Add(playerId);
}
}
[HttpGet("/api/relationships/v2/get")]
[HttpGet("/api/relationships/")]
[HttpGet("/api/relationships")]
public IActionResult GetMyRelationships([FromQuery] long? id = null)
{
long? accountId = AuthStuff.GetPlayerId(Request);
if (!accountId.HasValue)
return Ok(Array.Empty<ClientRelationshipDTO>());
return Ok(RelationshipDB.GetClientRelationships(accountId.Value));
}
[HttpPost("/api/relationships/sendfriendintroductions")]
public IActionResult SendFriendIntroductions()
{
if (AuthStuff.GetPlayerId(Request) == null)
return Unauthorized();
return Ok(new { success = true, introductions = Array.Empty<object>() });
}
public class AddFriendWithCodeRequest
{
public string? Code { get; set; }
}
[HttpPost("/api/relationships/v1/addfriendwithcode")]
public IActionResult AddFriendWithCode([FromBody] AddFriendWithCodeRequest request)
{
long? accountId = AuthStuff.GetPlayerId(Request);
if (accountId == null)
return Unauthorized();
string username = (request.Code ?? string.Empty).Trim().TrimStart('@');
if (username.Length == 0)
return BadRequest(new { error = "Enter a friend code." });
var target = PlayerDB.Players.FindAll().FirstOrDefault(value =>
string.Equals(value.Player?.Username, username, StringComparison.OrdinalIgnoreCase));
if (target?.Player == null)
return NotFound(new { error = "No player found with that code." });
if (target.PlayerId == accountId.Value)
return BadRequest(new { error = "That's your own code." });
RelationshipDB.AddFriend(accountId.Value, target.PlayerId);
return Ok(RelationshipDB.GetClientRelationship(accountId.Value, target.PlayerId));
}
[HttpGet("/api/messages/v2/get")]
public IActionResult GetMyMessages()
{
long? id = AuthStuff.GetPlayerId(Request);
if (!id.HasValue)
return Unauthorized();
List<NotificationDB.ClientNotification> messages =
NotificationDB.GetMessages(id.Value);
Console.WriteLine(
$"[MESSAGES GET] player={id.Value} count={messages.Count} " +
$"types=[{string.Join(",", messages.Select(value => value.Type))}]");
return Ok(messages);
}
[HttpPost("/api/messages/v2/send")]
[RequestSizeLimit(64 * 1024)]
public async Task<IActionResult> SendPlayerMessage(
[FromForm] long ToPlayerId,
[FromForm] int Type,
[FromForm] string? Data,
[FromForm] long? RoomId)
{
long? senderPlayerId = AuthStuff.GetPlayerId(Request);
if (!senderPlayerId.HasValue)
return Unauthorized();
if (ToPlayerId <= 0 ||
ToPlayerId == senderPlayerId.Value ||
PlayerDB.Players.FindById(ToPlayerId)?.Player == null)
{
return BadRequest(new
{
success = false,
error = "invalid_message_recipient"
});
}
var recipientRelationship =
RelationshipDB.GetClientRelationship(
ToPlayerId,
senderPlayerId.Value);
if ((Type is
(int)NotificationDB.MessageType.GameInvite or
(int)NotificationDB.MessageType.GameInviteV2) &&
(recipientRelationship?.Ignored is 1 or 3))
{
return StatusCode(403, new
{
success = false,
error = "gameplay_invites_not_allowed"
});
}
NotificationDB.ClientNotification message;
if (Type is
(int)NotificationDB.MessageType.GameInvite or
(int)NotificationDB.MessageType.GameInviteV2)
{
RoomInstance? instance = PlayerDB
.GetPlayerHeartbeat(senderPlayerId.Value)
.roomInstance;
if (instance == null)
{
return BadRequest(new
{
success = false,
error = "sender_not_in_room"
});
}
if (!Sessions.IsConfirmedParticipant(
senderPlayerId.Value,
instance.roomInstanceId))
{
return StatusCode(409, new
{
success = false,
error = "room_instance_membership_not_confirmed"
});
}
if (RoomId.HasValue && RoomId.Value > 0 &&
RoomId.Value != instance.roomId)
{
return StatusCode(409, new
{
success = false,
error = "room_invite_source_mismatch",
requestedRoomId = RoomId.Value,
currentRoomId = instance.roomId
});
}
message = await NotiController.NotifyRoomInviteAsync(
senderPlayerId.Value,
ToPlayerId,
instance.roomId,
instance.roomInstanceId,
instance.photonRoomId);
}
else
{
NotificationDB.MessageType messageType =
Enum.IsDefined(typeof(NotificationDB.MessageType), Type)
? (NotificationDB.MessageType)Type
: NotificationDB.MessageType.TextMessage;
message = NotificationDB.CreatePlayerMessage(
senderPlayerId.Value,
ToPlayerId,
messageType,
Data,
RoomId);
await NotiController.NotifyMessageAsync(ToPlayerId, message);
}
Console.WriteLine(
$"[MESSAGES SEND] from={senderPlayerId.Value} " +
$"to={ToPlayerId} type={Type} room={RoomId?.ToString() ?? "none"} " +
$"messageId={message.Id}");
return NoContent();
}
[HttpPost("/api/messages/v2/delete")]
[HttpDelete("/api/messages/v2/delete")]
[HttpPost("/api/messages/v3/delete")]
[HttpDelete("/api/messages/v3/delete")]
[RequestSizeLimit(64 * 1024)]
public async Task<IActionResult> DeleteMyMessages()
{
long? playerId = AuthStuff.GetPlayerId(Request);
if (!playerId.HasValue)
return Unauthorized();
HashSet<long> ids = await ReadLongValuesAsync(
"MessageIds", "messageIds", "MessageId", "messageId", "id");
int acceptedCheers = PlayerDB.ResolvePlayerCheers(
playerId.Value,
ids,
CheerStatus.Accepted);
int deleted = NotificationDB.DeleteMessages(playerId.Value, ids);
return Ok(new
{
success = true,
deleted,
acceptedCheers
});
}
[HttpPost("/api/messages/v1/sendMultiple")]
[RequestSizeLimit(64 * 1024)]
public async Task<IActionResult> SendMultipleMessages()
{
long? senderPlayerId = AuthStuff.GetPlayerId(Request);
if (!senderPlayerId.HasValue)
return Unauthorized();
HashSet<long> recipientIds = await ReadLongValuesAsync(
"ToPlayerIds",
"toPlayerIds",
"RecipientPlayerIds",
"recipientPlayerIds",
"RecipientIds",
"recipientIds",
"PlayerIds",
"playerIds",
"ToAccountIds",
"toAccountIds");
HashSet<long> aboutIds = await ReadLongValuesAsync(
"AboutPlayerId",
"aboutPlayerId",
"AboutAccountId",
"aboutAccountId");
long aboutPlayerId = aboutIds.FirstOrDefault();
if (aboutPlayerId > 0 &&
PlayerDB.Players.FindById(aboutPlayerId)?.Player == null)
{
Console.WriteLine(
$"[MESSAGES SEND MULTIPLE] sender={senderPlayerId.Value} " +
$"about={aboutPlayerId} rejected=about_player_not_found");
return NotFound(new
{
success = false,
error = "about_player_not_found"
});
}
long[] validRecipients = recipientIds
.Where(id =>
id > 0 &&
id <= int.MaxValue &&
id != senderPlayerId.Value &&
(aboutPlayerId <= 0 || id != aboutPlayerId) &&
PlayerDB.Players.FindById(id)?.Player != null)
.Distinct()
.Take(50)
.ToArray();
if (validRecipients.Length == 0)
{
Console.WriteLine(
$"[MESSAGES SEND MULTIPLE] sender={senderPlayerId.Value} " +
$"about={aboutPlayerId} rejected=no_valid_recipients");
return BadRequest(new
{
success = false,
error = "missing_recipient_ids"
});
}
if (aboutPlayerId > 0)
{
foreach (long receiverPlayerId in validRecipients)
{
await NotiController.NotifyFriendIntroductionAsync(
senderPlayerId.Value,
receiverPlayerId,
aboutPlayerId);
}
Console.WriteLine(
$"[MESSAGES SEND MULTIPLE] sender={senderPlayerId.Value} " +
$"type={(int)NotificationDB.MessageType.FriendIntroduction} " +
$"about={aboutPlayerId} recipients=" +
$"[{string.Join(",", validRecipients)}] " +
$"sent={validRecipients.Length}");
return Ok();
}
(int type, string data) = await ReadMultipleMessagePayloadAsync();
NotificationDB.MessageType messageType =
Enum.IsDefined(typeof(NotificationDB.MessageType), type)
? (NotificationDB.MessageType)type
: NotificationDB.MessageType.TextMessage;
bool isGameInvite = messageType is
NotificationDB.MessageType.GameInvite or
NotificationDB.MessageType.GameInviteV2;
RoomInstance? inviteInstance = null;
if (isGameInvite)
{
inviteInstance = PlayerDB
.GetPlayerHeartbeat(senderPlayerId.Value)
.roomInstance;
if (inviteInstance == null)
{
return BadRequest(new
{
success = false,
error = "sender_not_in_room"
});
}
if (!Sessions.IsConfirmedParticipant(
senderPlayerId.Value,
inviteInstance.roomInstanceId))
{
return StatusCode(409, new
{
success = false,
error = "room_instance_membership_not_confirmed"
});
}
}
var deliveredRecipients = new List<long>();
var blockedRecipients = new List<long>();
foreach (long receiverPlayerId in validRecipients)
{
if (isGameInvite)
{
var relationship = RelationshipDB.GetClientRelationship(
receiverPlayerId,
senderPlayerId.Value);
if (relationship?.Ignored is 1 or 3)
{
blockedRecipients.Add(receiverPlayerId);
continue;
}
await NotiController.NotifyRoomInviteAsync(
senderPlayerId.Value,
receiverPlayerId,
inviteInstance!.roomId,
inviteInstance.roomInstanceId,
inviteInstance.photonRoomId);
deliveredRecipients.Add(receiverPlayerId);
}
else
{
NotificationDB.ClientNotification message =
NotificationDB.CreatePlayerMessage(
senderPlayerId.Value,
receiverPlayerId,
messageType,
data);
await NotiController.NotifyMessageAsync(
receiverPlayerId,
message);
deliveredRecipients.Add(receiverPlayerId);
}
}
Console.WriteLine(
$"[MESSAGES SEND MULTIPLE] sender={senderPlayerId.Value} " +
$"type={(int)messageType} recipients=" +
$"[{string.Join(",", deliveredRecipients)}] " +
$"sent={deliveredRecipients.Count} " +
$"blocked={blockedRecipients.Count}");
return Ok();
}
private async Task<(int Type, string Data)>
ReadMultipleMessagePayloadAsync()
{
string? rawType =
Request.Query["Type"].FirstOrDefault() ??
Request.Query["type"].FirstOrDefault() ??
Request.Query["MessageType"].FirstOrDefault() ??
Request.Query["messageType"].FirstOrDefault();
string data =
Request.Query["Data"].FirstOrDefault() ??
Request.Query["data"].FirstOrDefault() ??
string.Empty;
if (Request.HasFormContentType)
{
IFormCollection form =
await Request.ReadFormAsync(HttpContext.RequestAborted);
rawType =
form["Type"].FirstOrDefault() ??
form["type"].FirstOrDefault() ??
form["MessageType"].FirstOrDefault() ??
form["messageType"].FirstOrDefault() ??
rawType;
data =
form["Data"].FirstOrDefault() ??
form["data"].FirstOrDefault() ??
data;
}
else if (Request.Body.CanRead)
{
Request.EnableBuffering();
if (Request.Body.CanSeek)
Request.Body.Position = 0;
using var reader = new StreamReader(
Request.Body,
Encoding.UTF8,
detectEncodingFromByteOrderMarks: false,
leaveOpen: true);
string body = await reader.ReadToEndAsync();
if (Request.Body.CanSeek)
Request.Body.Position = 0;
if (!string.IsNullOrWhiteSpace(body))
{
try
{
using JsonDocument document = JsonDocument.Parse(body);
JsonElement root = document.RootElement;
rawType =
FindJsonInt(
root,
"Type",
"type",
"MessageType",
"messageType")?
.ToString() ??
rawType;
data =
FindJsonString(root, "Data", "data") ??
data;
}
catch (JsonException)
{
}
}
}
return (
int.TryParse(rawType, out int type)
? type
: (int)NotificationDB.MessageType.TextMessage,
data);
}
[HttpGet("/playersettings")]
public IActionResult GetMySettings()
{
var player = AuthStuff.GetCurrentPlayer(Request);
if (player == null)
return Unauthorized();
return Ok(player.Player.PlayerExtra.Settings);
}
[HttpPut("/playersettings")]
[RequestSizeLimit(16 * 1024)]
public IActionResult SetMySettings([FromForm] string key, [FromForm] string value)
{
var id = AuthStuff.GetPlayerId(Request);
if (id == null)
return Unauthorized();
PlayerDB.SetPlayerSetting(key, value ?? "", (long)id);
return NoContent();
}
[HttpGet("/econ/customAvatarItems/v1/owned")]
public IActionResult GetMyOwnedCustomAvatarItems([FromQuery] int skip, [FromQuery] int take)
{
long? accountId = AuthStuff.GetPlayerId(Request);
if (!accountId.HasValue)
return Unauthorized();
skip = Math.Max(0, skip);
take = Math.Clamp(take <= 0 ? 100 : take, 1, 100);
List<JsonObject> items = LoadCustomAvatarItems(accountId.Value);
List<JsonObject> page = items.Skip(skip).Take(take).ToList();
return Ok(new
{
Results = page,
TotalResults = items.Count
});
}
[HttpGet("/api/checklist/v1/current")]
public IActionResult GetMyChecklist()
{
var id = AuthStuff.GetPlayerId(Request);
if (id == null)
return Unauthorized();
return Ok(ServerConfig.Bracket);
}
[HttpGet("/api/players/v2/progression/bulk")]
public IActionResult GetProgressionForPlayers([FromQuery] List<long> id)
{
var authId = AuthStuff.GetPlayerId(Request);
if (authId == null)
return Unauthorized();
if (id == null || id.Count == 0)
return Ok(new List<PlayerDBClasses.PlayerProgressionDTO>());
var progressions = PlayerDB.GetProgressionBulk(id);
return Ok(progressions);
}
[HttpPost("/api/players/v2/progression/update")]
[HttpPost("/api/players/v1/progression/update")]
[HttpPost("/api/progression/v1/update")]
[RequestSizeLimit(32 * 1024)]
public async Task<IActionResult> UpdateMyProgression()
{
long? playerId = AuthStuff.GetPlayerId(Request);
if (!playerId.HasValue)
return Unauthorized();
var current = PlayerDB.Players.FindById(playerId.Value);
if (current?.Player == null)
return NotFound(new { success = false, error = "player_not_found" });
if (current.PlayerRoles?.Contains(PlayerRoles.Developer) != true)
{
return StatusCode(StatusCodes.Status403Forbidden, new
{
success = false,
error = "server_authoritative_progression"
});
}
var values = new Dictionary<string, string>(
StringComparer.OrdinalIgnoreCase);
foreach (var queryValue in Request.Query)
values[queryValue.Key] = queryValue.Value.ToString();
if (Request.HasFormContentType)
{
var form = await Request.ReadFormAsync();
foreach (var formValue in form)
values[formValue.Key] = formValue.Value.ToString();
}
else if ((Request.ContentLength ?? 0) > 0)
{
using var reader = new StreamReader(Request.Body);
string body = await reader.ReadToEndAsync();
if (!string.IsNullOrWhiteSpace(body))
{
try
{
using JsonDocument document = JsonDocument.Parse(body);
AddJsonValues(document.RootElement, values);
}
catch (JsonException)
{
foreach (var bodyValue in
Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(
"?" + body))
{
values[bodyValue.Key] = bodyValue.Value.ToString();
}
}
}
}
int level = current.Player.Level;
int xp = current.Player.XP;
string? levelText = GetValue(values,
"Level", "level", "NewLevel", "newLevel");
string? xpText = GetValue(values,
"XP", "Xp", "xp", "Experience", "experience", "NewXP", "newXp");
string? xpDeltaText = GetValue(values,
"XPDelta", "xpDelta", "ExperienceDelta", "experienceDelta", "AddXP", "addXp");
if (int.TryParse(levelText, out int parsedLevel))
level = parsedLevel;
if (int.TryParse(xpText, out int parsedXp))
xp = parsedXp;
if (int.TryParse(xpDeltaText, out int xpDelta))
xp = Math.Max(0, xp + xpDelta);
var progression = PlayerDB.SetProgression(
playerId.Value,
level,
xp);
return progression == null
? StatusCode(500, new { success = false, error = "progression_update_failed" })
: Ok(progression);
}
[HttpGet("/api/playerReputation/v2/bulk")]
public IActionResult GetReputationBulk([FromQuery] List<long> id)
{
var authId = AuthStuff.GetPlayerId(Request);
if (authId == null)
return Unauthorized();
if (id == null || id.Count == 0)
return Ok(new List<PlayerDBClasses.Reputation>());
var results = PlayerDB.GetReputationBulk(id);
return Ok(results);
}
[HttpPost("/api/PlayerReporting/v1/hile")]
public IActionResult PlayerReportingHile()
{
var authId = AuthStuff.GetPlayerId(Request);
if (authId == null)
return Unauthorized();
return Ok(false);
}
[HttpGet("api/config/v2")]
public IActionResult GetConfigV2()
{
string path = Path.Combine(Program.dataDir, "APIS", "ConfigV2.json");
return System.IO.File.Exists(path) ? Content(System.IO.File.ReadAllText(path), "application/json") : NotFound();
}
[HttpGet("/api/avatar/v3/saved")]
public IActionResult GetSavedAvatars()
{
var player = AuthStuff.GetCurrentPlayer(Request);
if (player?.Player == null)
return Unauthorized();
player.Player.PlayerExtra ??= new PlayerDBClasses.PlayerExtra();
player.Player.PlayerExtra.SavedAvatars ??= new List<PlayerDBClasses.SavedOutfit>();
var saved = player.Player.PlayerExtra.SavedAvatars;
var photoEntries = saved.Where(s => !HasSavedOutfitState(s)).ToList();
foreach (var photoEntry in photoEntries)
MigrateLegacyPhotoEntry(player, photoEntry);
if (photoEntries.Count > 0)
{
foreach (var photoEntry in photoEntries)
saved.Remove(photoEntry);
PlayerDB.Players.Update(player);
}
var result = saved
.OrderBy(s => s.Slot)
.Select(s => new
{
Slot = s.Slot,
OutfitSelections = s.OutfitSelections,
FaceFeatures = s.FaceFeatures,
SkinColor = s.SkinColor,
HairColor = s.HairColor,
PreviewImageName = s.PreviewImageName
}).ToList();
return Ok(result);
}
[HttpPost("/api/avatar/v3/saved/set")]
[HttpPost("/api/avatar/v4/saved/set")]
[RequestSizeLimit(256 * 1024)]
public IActionResult SetSavedAvatar([FromBody] PlayerDBClasses.SavedOutfit request)
{
var player = AuthStuff.GetCurrentPlayer(Request);
if (player?.Player == null)
return Unauthorized();
if (request == null)
return BadRequest(new { success = false, error = "Invalid request body." });
if (request.Slot > 350)
return BadRequest(new { success = false, error = "Invalid outfit slot." });
if (!HasSavedOutfitState(request))
{
return BadRequest(new
{
success = false,
error = "An outfit save must include avatar data."
});
}
if ((request.OutfitSelections?.Length ?? 0) > 64_000 ||
(request.FaceFeatures?.Length ?? 0) > 64_000 ||
(request.SkinColor?.Length ?? 0) > 1_024 ||
(request.HairColor?.Length ?? 0) > 1_024 ||
request.PreviewImageName?.Length > 260)
{
return BadRequest(new { success = false, error = "Outfit data is too large." });
}
player.Player.PlayerExtra ??= new PlayerDBClasses.PlayerExtra();
player.Player.PlayerExtra.SavedAvatars ??= new List<PlayerDBClasses.SavedOutfit>();
if (request.Slot < 0)
{
request.Slot = Enumerable.Range(0, 100).FirstOrDefault(slot =>
player.Player.PlayerExtra.SavedAvatars.All(saved => saved.Slot != slot), -1);
if (request.Slot < 0)
return Conflict(new { success = false, error = "All saved outfit slots are in use." });
}
var existing = player.Player.PlayerExtra.SavedAvatars.FirstOrDefault(x => x.Slot == request.Slot);
if (existing != null)
{
existing.OutfitSelections = request.OutfitSelections;
existing.FaceFeatures = request.FaceFeatures;
existing.SkinColor = request.SkinColor;
existing.HairColor = request.HairColor;
existing.PreviewImageName = request.PreviewImageName;
}
else
{
player.Player.PlayerExtra.SavedAvatars.Add(request);
}
PlayerDB.Players.Update(player);
return Ok(new
{
success = true,
slot = request.Slot,
previewImageName = request.PreviewImageName,
url = request.PreviewImageName != null ? $"{ServerConfig.BaseURL}/imageserver-v2/{request.PreviewImageName}" : null
});
}
private static bool HasSavedOutfitState(PlayerDBClasses.SavedOutfit outfit)
{
return !string.IsNullOrWhiteSpace(outfit.OutfitSelections) ||
!string.IsNullOrWhiteSpace(outfit.FaceFeatures) ||
!string.IsNullOrWhiteSpace(outfit.SkinColor) ||
!string.IsNullOrWhiteSpace(outfit.HairColor);
}
private static void MigrateLegacyPhotoEntry(
PlayerDBClasses.FullPlayer player,
PlayerDBClasses.SavedOutfit entry)
{
string? path = NormalizeLegacyImagePath(entry.PreviewImageName);
if (path == null)
return;
string? profilePath = NormalizeLegacyImagePath(player.Player?.ProfileImage);
if (string.Equals(path, profilePath, StringComparison.OrdinalIgnoreCase))
return;
string fullPath = Path.Combine(
Program.dataDir,
"Images",
path.Replace('/', Path.DirectorySeparatorChar));
if (!System.IO.File.Exists(fullPath))
return;
RecNetDB.SavedImages.Upsert(new RecNetDB.SavedImage
{
PhotoPath = path,
AccountId = player.PlayerId,
SavedImageType = 1,
Accessibility = 1,
CreatedAt = System.IO.File.GetCreationTimeUtc(fullPath)
});
}
private static string? NormalizeLegacyImagePath(string? value)
{
if (string.IsNullOrWhiteSpace(value))
return null;
string clean = value.Trim().Replace('\\', '/');
if (Uri.TryCreate(clean, UriKind.Absolute, out var uri))
{
string? marker = new[] { "/imageserver-v2/", "/imageserver/" }
.FirstOrDefault(candidate => uri.AbsolutePath.Contains(
candidate,
StringComparison.OrdinalIgnoreCase));
if (marker == null)
return null;
int markerIndex = uri.AbsolutePath.IndexOf(marker, StringComparison.OrdinalIgnoreCase);
clean = Uri.UnescapeDataString(uri.AbsolutePath[(markerIndex + marker.Length)..]);
}
clean = clean.TrimStart('/');
if (!clean.Contains('/'))
clean = $"PlayerImages/{Path.GetFileName(clean)}";
string[] segments = clean.Split('/', StringSplitOptions.RemoveEmptyEntries);
if (clean.Length > 260 || segments.Length == 0 ||
clean.Any(char.IsControl) || clean.Contains(':') ||
Path.IsPathRooted(clean) || segments.Any(segment => segment is "." or ".."))
{
return null;
}
string extension = Path.GetExtension(clean);
if (!new[] { ".png", ".jpg", ".jpeg", ".webp", ".gif", ".bmp" }
.Contains(extension, StringComparer.OrdinalIgnoreCase))
{
return null;
}
return string.Join('/', segments);
}
[HttpGet("/api/PlayerReporting/v1/voteToKickReasons")]
[HttpGet("/api/images/v2/named")]
[HttpGet("/api/gamerewards/v1/pending")]
[HttpGet("/api/roomkeys/v1/mine")]
[HttpGet("/api/roomcurrencies/v1/currencies")]
[HttpGet("/api/AppIntegrity/v1/iosproducts")]
[HttpGet("/api/externalfriendinvite/v1/gettextmessagereferrers")]
[HttpGet("/api/images/")]
[HttpGet("/api/images")]
[HttpGet("/api/incentivizedreferrals/")]
[HttpGet("/api/incentivizedreferrals")]
[HttpGet("/api/inventions/")]
[HttpGet("/api/inventions")]
[HttpGet("/api/inventions/v1/versions")]
[HttpGet("/api/keepsakes")]
[HttpGet("/api/keepsakes/events")]
[HttpGet("/api/progressionEvents")]
[HttpGet("/api/roomEarningsDistributions")]
[HttpGet("/api/roomconsumables")]
[HttpGet("/api/roomcurrencies")]
[HttpGet("/api/roomkeys/")]
[HttpGet("/api/roomkeys/v1/")]
[HttpGet("/api/storefronts/")]
[HttpGet("/api/storefronts")]
[HttpGet("/api/storefronts/v1/objectives")]
[HttpGet("/api/testcasemanagement/")]
[HttpGet("/api/testcasemanagement/v1/testcase/")]
[HttpGet("/api/testcasemanagement/v1/testplans")]
[HttpGet("/api/testcasemanagement/v1/testpasssummary")]
[HttpGet("/api/ugcPurchasables")]
[HttpGet("/api/consumables/")]
[HttpGet("/api/consumables")]
public IActionResult TodoImplement()
{
return Ok(ServerConfig.Bracket);
}
[HttpGet("/api/CampusCard/PS5RecRoomPlusEnabledForAllPlayers")]
[HttpGet("/api/apple/musicpromotion/active")]
[HttpGet("/api/playstationplus/membership")]
[HttpGet("/api/roomkeys/v1/owns")]
public IActionResult TodoImplementFalse() => Ok(false);
[HttpGet("/api/roomcurrencies/v1/getBalance")]
[HttpGet("/api/storefronts/v1/trialInvention/duration")]
public IActionResult TodoImplementZero() => Ok(0);
[HttpGet("/api/apple/musicpromotion/code")]
[HttpGet("/api/inventions/v1/fulllineageowner")]
[HttpGet("/api/royale/v1/current")]
public IActionResult TodoImplementNull() => Ok(null);
[HttpPost("/api/AppIntegrity/v1/iospaymentqueuefailed")]
[HttpPost("/api/PlayerReporting/v1/instantKick")]
[HttpPost("/api/PlayerReporting/v3/voteToKick")]
[HttpPost("/api/checklist/v1/complete")]
[HttpPost("/api/externalfriendinvite/v1/createplatforminvite")]
[HttpPost("/api/externalfriendinvite/v1/sendtextmessageinvite")]
[HttpPost("/api/gamerewards/v1/select")]
[HttpPost("/api/incentivizedreferrals/claim")]
[HttpPost("/api/inventions/v1/delete")]
[HttpDelete("/api/inventions/v1/delete")]
[HttpPost("/api/inventions/v1/dormskinsfromids")]
[HttpPost("/api/inventions/v1/unpublish")]
[HttpPost("/api/inventions/v1/updateprice")]
[HttpPost("/api/inventions/v3/publish")]
[HttpPost("/api/inventions/v4/addversion")]
[HttpPost("/api/objectives/v1/completegroup")]
[HttpPost("/api/objectives/v1/updateobjective")]
[HttpPost("/api/playstationplus/expire")]
[HttpPost("/api/roomconsumables/v1/roomConsumable/awardBulk")]
[HttpPost("/api/roomcurrencies/v1/awardCurrency/bulk")]
[HttpPost("/api/roomcurrencies/v1/createCurrency")]
[HttpPost("/api/roomcurrencies/v1/createPurchaseOffer")]
[HttpPost("/api/roomcurrencies/v1/deletePurchaseOffer")]
[HttpPost("/api/roomcurrencies/v1/updateCurrency")]
[HttpPost("/api/roomcurrencies/v1/updatePurchaseOffer")]
[HttpPost("/api/roomkeys/v1/awardbulk")]
[HttpPost("/api/roomkeys/v1/create")]
[HttpPost("/api/roomkeys/v1/revoke")]
[HttpPost("/api/royale/v2/matchcomplete")]
[HttpPost("/api/storefronts/v1/PurchaseRoomKeyWithCurrency")]
[HttpPost("/api/storefronts/v1/buyForFreeGiftButton")]
[HttpPost("/api/storefronts/v1/buyProgressionEventXpBoost")]
[HttpPost("/api/storefronts/v1/buyPurchaseReminder")]
[HttpPost("/api/storefronts/v1/buyRoomKey")]
[HttpPost("/api/storefronts/v1/trialInvention")]
[HttpPost("/api/storefronts/v2/buyElite")]
[HttpPost("/api/storefronts/v2/buyTier")]
[HttpPost("/api/roomconsumables/v1/roomconsumable/{skuId}/purchase/currency")]
public IActionResult AckOnly()
{
if (AuthStuff.GetPlayerId(Request) == null)
return Unauthorized();
return Ok(new { success = true });
}
[HttpGet("/api/roomcurrencies/v1/getPurchaseOffersBatch")]
[HttpPost("/api/roomcurrencies/v1/getPurchaseOffersBatch")]
[HttpPost("/api/roomkeys/v1/owns/bulk")]
public IActionResult AckOnlyEmptyList()
{
if (AuthStuff.GetPlayerId(Request) == null)
return Unauthorized();
return Ok(ServerConfig.Bracket);
}
[HttpGet("/api/avatar/v2/gifts")]
public IActionResult GetPendingGiftPackages()
{
long? playerId = AuthStuff.GetPlayerId(Request);
if (!playerId.HasValue)
return Unauthorized();
List<GiftPackage> pendingGifts =
PlayerDB.GetPendingGiftPackages(playerId.Value);
object[] gifts = pendingGifts
.Select(gift => ToClientGiftPackage(gift, playerId.Value))
.ToArray();
string packageSummary = string.Join(",", pendingGifts.Select(gift =>
$"{gift.GiftPackageId}:from{NormalizeGiftSenderId(gift.FromPlayerId, playerId.Value)}:" +
GiftKind(gift)));
Console.WriteLine(
$"[GIFTS] pending player={playerId.Value} count={gifts.Length} " +
$"packages=[{packageSummary}]");
return Ok(gifts);
}
[HttpPost("/api/avatar/v2/gifts/consume")]
[HttpPost("/api/avatar/v2/gifts/consume/")]
[RequestSizeLimit(32 * 1024)]
public async Task<IActionResult> ConsumeGiftPackage()
{
long? playerId = AuthStuff.GetPlayerId(Request);
if (!playerId.HasValue)
return Unauthorized();
long? giftPackageId = await ReadGiftPackageIdAsync();
if (!giftPackageId.HasValue || giftPackageId.Value <= 0)
{
return BadRequest(new
{
Success = false,
Error = "missing_gift_package_id"
});
}
GiftPackage? consumed = PlayerDB.ConsumeGiftPackage(
playerId.Value,
giftPackageId.Value);
if (consumed == null)
{
return NotFound(new
{
Success = false,
Error = "gift_package_not_found"
});
}
List<GiftPackage> remaining =
PlayerDB.GetPendingGiftPackages(playerId.Value);
await NotiController.NotifyGiftConsumedAsync(
playerId.Value,
consumed.GiftPackageId,
remaining);
Console.WriteLine(
$"[GIFTS] consumed player={playerId.Value} " +
$"gift={consumed.GiftPackageId} remaining={remaining.Count}");
return Ok(ToClientGiftPackage(consumed, playerId.Value));
}
private static object ToClientGiftPackage(
GiftPackage gift,
long recipientPlayerId)
{
return new
{
AvatarItemDesc = EmptyIfWhiteSpace(gift.AvatarItemDesc),
AvatarItemType = 0,
BalanceType = gift.BalanceType ??
(int)BalanceType.NonPurchasedNotUsableInP2P,
ConsumableItemDesc = EmptyIfWhiteSpace(gift.ConsumableItemDesc),
Currency = gift.Currency,
CurrencyType = gift.CurrencyType,
EquipmentModificationGuid =
EmptyIfWhiteSpace(gift.EquipmentModificationGuid),
EquipmentPrefabName =
EmptyIfWhiteSpace(gift.EquipmentPrefabName),
FromGiftDropId = 0,
FromPlayerId = NormalizeGiftSenderId(
gift.FromPlayerId,
recipientPlayerId),
GiftContext = NormalizeGiftContext(gift.GiftContext),
GiftRarity = gift.Rarity,
Id = gift.GiftPackageId,
Level = 0,
Message = gift.Message ?? string.Empty,
Platform = gift.Platform,
PlatformsToSpawnOn = gift.PlatformMask,
Xp = gift.XP
};
}
private static int NormalizeGiftSenderId(
int senderId,
long recipientPlayerId)
{
if (senderId > 0 && senderId == recipientPlayerId)
return recipientPlayerId == 1 ? 2 : 1;
return senderId;
}
private static int NormalizeGiftContext(int context) =>
context is (int)PlayerDBClasses.GiftContext.Default
or (int)PlayerDBClasses.GiftContext.Store_RecCenter
? (int)PlayerDBClasses.GiftContext.Game_Drop
: context;
private static string GiftKind(GiftPackage gift) =>
gift.Currency != 0 ? "tokens" :
gift.XP > 0 ? "xp" :
!string.IsNullOrWhiteSpace(gift.ConsumableItemDesc) ? "consumable" :
!string.IsNullOrWhiteSpace(gift.EquipmentModificationGuid) ? "equipment" :
!string.IsNullOrWhiteSpace(gift.AvatarItemDesc) ? "avatar" :
"empty";
private static string EmptyIfWhiteSpace(string? value) =>
string.IsNullOrWhiteSpace(value) ? string.Empty : value;
private async Task<long?> ReadGiftPackageIdAsync()
{
string[] names =
{
"GiftPackageId",
"giftPackageId",
"PackageId",
"packageId",
"Id",
"id"
};
foreach (string name in names)
{
if (long.TryParse(
Request.Query[name].FirstOrDefault(),
out long queryValue))
{
return queryValue;
}
}
if (Request.HasFormContentType)
{
var form = await Request.ReadFormAsync();
foreach (string name in names)
{
if (long.TryParse(
form[name].FirstOrDefault(),
out long formValue))
{
return formValue;
}
}
return null;
}
if (!Request.Body.CanRead ||
((Request.ContentLength ?? 0) <= 0 &&
Request.Headers.TransferEncoding.Count == 0))
{
return null;
}
try
{
using JsonDocument document =
await JsonDocument.ParseAsync(Request.Body);
return FindJsonLong(document.RootElement, names);
}
catch (JsonException)
{
return null;
}
}
[HttpGet("/api/consumables/v2/getUnlocked")]
public IActionResult GetUnlockedConsumables()
{
long? accountId = AuthStuff.GetPlayerId(Request);
if (!accountId.HasValue)
return Unauthorized();
Response.Headers.CacheControl = "no-store, no-cache, must-revalidate";
Response.Headers.Pragma = "no-cache";
Response.Headers.Expires = "0";
PlayerInventoryStore.EnsureConsumablesInitialized(accountId.Value);
return GetNormalizedConsumables(accountId.Value);
}
[HttpGet("/cdn/config/LoadingScreenTipData")]
public IActionResult GetLoadingScreenTipData()
{
return GetLoadingScreenData();
}
[HttpGet("/api/playerevents/v1")]
[HttpGet("/api/playerevents/v1/all")]
public IActionResult GetAllPlayerEvents()
{
return Ok(new
{
Created = Array.Empty<object>(),
Responses = Array.Empty<object>()
});
}
[HttpGet("/api/customAvatarItems/v1/isCreationAllowedForAccount")]
public IActionResult GetCustomAvatarItemsIsCreationAllowedForAccount()
{
return Ok(new
{
success = true,
value = (object?)null
});
}
[HttpGet("/api/influencerpartnerprogram/myinfluencer")]
[HttpGet("/api/influencerpartnerprogram/v1/myinfluencer")]
public IActionResult GetMySupportedInfluencer()
{
long? accountId = AuthStuff.GetPlayerId(Request);
if (accountId == null)
return Unauthorized();
FullPlayer? account = PlayerDB.Players.FindById(accountId.Value);
int supportedInfluencerId = account?.Player?.SupportedInfluencerId ?? 0;
Console.WriteLine(
$"[INFLUENCER MY] account={accountId.Value} " +
$"supported={supportedInfluencerId}");
return Ok(supportedInfluencerId);
}
[HttpGet("/api/influencerpartnerprogram/influencer")]
[HttpGet("/api/influencerpartnerprogram/v1/influencer")]
public IActionResult GetSupportedInfluencer(
[FromQuery] int accountId)
{
if (accountId <= 0)
return Content("null", "application/json");
FullPlayer? account = PlayerDB.Players.FindById((long)accountId);
int supportedInfluencerId = account?.Player?.SupportedInfluencerId ?? 0;
Console.WriteLine(
$"[INFLUENCER GET] account={accountId} " +
$"supported={supportedInfluencerId}");
return Ok(supportedInfluencerId);
}
[HttpGet("/api/influencerpartnerprogram/isinfluencer")]
[HttpGet("/api/influencerpartnerprogram/accountisinfluencer")]
[HttpGet("/api/influencerpartnerprogram/v1/isinfluencer")]
public IActionResult GetAccountIsInfluencer(
[FromQuery] int accountId)
{
if (accountId <= 0)
return Ok(false);
FullPlayer? account = PlayerDB.Players.FindById((long)accountId);
bool isInfluencer = account?.PlayerRoles?.Contains(
PlayerDBClasses.PlayerRoles.Influencer) == true;
Console.WriteLine(
$"[INFLUENCER CHECK] account={accountId} " +
$"isInfluencer={isInfluencer}");
return Ok(isInfluencer);
}
[HttpPost("/api/influencerpartnerprogram/support")]
[HttpPost("/api/influencerpartnerprogram/support/{influencerAccountId:int}")]
public async Task<IActionResult> SupportInfluencer(
[FromRoute] int? influencerAccountId = null)
{
long? accountId = AuthStuff.GetPlayerId(Request);
if (accountId == null)
return Unauthorized();
int? requestedInfluencerId =
await ReadInfluencerAccountIdAsync(influencerAccountId);
if (!requestedInfluencerId.HasValue)
{
return BadRequest(new
{
error = "missing_influencer_account_id"
});
}
if (requestedInfluencerId.Value == 0)
return SetSupportedInfluencer(accountId.Value, 0);
if (requestedInfluencerId.Value < 0)
{
return BadRequest(new
{
error = "invalid_influencer_account_id"
});
}
if (accountId.Value == requestedInfluencerId.Value)
{
return BadRequest(new
{
error = "cannot_support_self"
});
}
FullPlayer? influencer =
PlayerDB.Players.FindById((long)requestedInfluencerId.Value);
if (influencer?.Player == null ||
influencer.PlayerRoles?.Contains(
PlayerDBClasses.PlayerRoles.Influencer) != true)
{
return NotFound(new
{
error = "influencer_not_found"
});
}
return SetSupportedInfluencer(
accountId.Value,
requestedInfluencerId.Value);
}
[HttpDelete("/api/influencerpartnerprogram/support")]
[HttpDelete("/api/influencerpartnerprogram/support/{influencerAccountId:int}")]
[HttpPost("/api/influencerpartnerprogram/unsupport")]
[HttpPost("/api/influencerpartnerprogram/remove")]
public IActionResult RemoveSupportedInfluencer(
[FromRoute] int? influencerAccountId = null)
{
long? accountId = AuthStuff.GetPlayerId(Request);
if (accountId == null)
return Unauthorized();
return SetSupportedInfluencer(accountId.Value, 0);
}
private IActionResult SetSupportedInfluencer(
long accountId,
int supportedInfluencerId)
{
FullPlayer? account = PlayerDB.Players.FindById(accountId);
if (account?.Player == null)
return NotFound(new { error = "account_not_found" });
account.Player.SupportedInfluencerId = supportedInfluencerId;
if (!PlayerDB.Players.Update(account))
{
return StatusCode(
StatusCodes.Status500InternalServerError,
new { error = "support_update_failed" });
}
Console.WriteLine(
$"[INFLUENCER SUPPORT] supporter={accountId} " +
$"influencer={supportedInfluencerId} persisted=true");
return Ok(new
{
Value = true,
Success = true,
ErrorId = (string?)null,
Error = (string?)null,
LocalizationContext = (object?)null,
InfluencerAccountId = supportedInfluencerId,
SupportedInfluencerId = supportedInfluencerId
});
}
private async Task<int?> ReadInfluencerAccountIdAsync(
int? routeInfluencerAccountId)
{
if (routeInfluencerAccountId.HasValue)
return routeInfluencerAccountId.Value;
string[] names =
{
"influencerAccountId",
"InfluencerAccountId",
"accountId",
"AccountId",
"id",
"Id"
};
foreach (string name in names)
{
if (int.TryParse(
Request.Query[name].FirstOrDefault(),
out int queryValue))
{
return queryValue;
}
}
if (Request.HasFormContentType)
{
var form = await Request.ReadFormAsync(
HttpContext.RequestAborted);
foreach (string name in names)
{
if (int.TryParse(
form[name].FirstOrDefault(),
out int formValue))
{
return formValue;
}
}
return null;
}
if (!Request.Body.CanRead ||
((Request.ContentLength ?? 0) <= 0 &&
Request.Headers.TransferEncoding.Count == 0))
{
return null;
}
using var reader = new StreamReader(
Request.Body,
Encoding.UTF8,
detectEncodingFromByteOrderMarks: true,
bufferSize: 4_096,
leaveOpen: true);
string rawBody = await reader.ReadToEndAsync(
HttpContext.RequestAborted);
rawBody = rawBody.Trim();
if (rawBody.Length == 0)
return null;
if (int.TryParse(rawBody.Trim('"'), out int rawValue))
return rawValue;
try
{
using JsonDocument document = JsonDocument.Parse(rawBody);
JsonElement root = document.RootElement;
if (root.ValueKind == JsonValueKind.Number &&
root.TryGetInt32(out int numberValue))
{
return numberValue;
}
if (root.ValueKind == JsonValueKind.String &&
int.TryParse(root.GetString(), out int stringValue))
{
return stringValue;
}
return FindJsonInt(root, names);
}
catch (JsonException)
{
return null;
}
}
[HttpPost("/api/consumables/v1/consume")]
public async Task<IActionResult> ConsumeConsumable()
{
long? accountId = AuthStuff.GetPlayerId(Request);
if (!accountId.HasValue)
return Unauthorized();
JsonElement? request = null;
try
{
if (Request.Body.CanRead &&
((Request.ContentLength ?? 0) > 0 ||
Request.Headers.TransferEncoding.Count > 0))
{
using JsonDocument document =
await JsonDocument.ParseAsync(
Request.Body,
cancellationToken: HttpContext.RequestAborted);
request = document.RootElement.Clone();
}
}
catch (JsonException)
{
}
long? consumableId = request.HasValue
? FindJsonLong(request.Value, "Id", "id")
: null;
long? requestedDelta = request.HasValue
? FindJsonLong(
request.Value,
"DeltaCount",
"deltaCount")
: null;
int delta = requestedDelta is > 0 and <= int.MaxValue
? (int)requestedDelta.Value
: 1;
string disposition = "missing_id";
int? loggedPreviousQuantity = null;
int? loggedRemainingQuantity = null;
if (consumableId is > 0)
{
bool resolved = TryGetConsumableInfo(
consumableId.Value,
out string descriptor,
out string friendlyName,
out bool isHairDye,
out string createdAt);
if (!resolved)
{
resolved = TryGetOwnedConsumableInfo(
accountId.Value,
consumableId.Value,
out descriptor,
out friendlyName,
out isHairDye,
out createdAt);
}
if (resolved)
{
bool consumed = PlayerInventoryStore.TryConsumeConsumable(
accountId.Value,
descriptor,
consumableId.Value,
delta,
out int previousQuantity,
out int remainingQuantity);
disposition = consumed ? "consumed" : "already_spent";
if (consumed)
{
loggedPreviousQuantity = previousQuantity;
loggedRemainingQuantity = remainingQuantity;
if (isHairDye)
{
PlayerDB.SetPlayerSetting(
$"Avatar.HairDye.{descriptor}",
"True",
accountId.Value);
Console.WriteLine(
$"[HAIR DYE CONSUME] player={accountId.Value} " +
$"id={consumableId.Value} name={friendlyName} " +
$"desc={descriptor}");
}
if (remainingQuantity <= 0)
{
await NotiController.NotifyConsumableRemovedAsync(
accountId.Value,
consumableId.Value,
descriptor,
createdAt,
previousQuantity,
remainingQuantity);
}
}
}
else
{
disposition = "unknown_id";
}
}
Console.WriteLine(
$"[CONSUMABLE CONSUME] player={accountId.Value} " +
$"id={consumableId?.ToString() ?? "none"} delta={delta} " +
$"result={disposition} " +
$"before={loggedPreviousQuantity?.ToString() ?? "n/a"} " +
$"remaining={loggedRemainingQuantity?.ToString() ?? "n/a"}");
return Ok(new
{
error = string.Empty,
success = true,
value = (object?)null
});
}
private static bool TryGetConsumableInfo(
long consumableId,
out string descriptor,
out string friendlyName,
out bool isHairDye,
out string createdAt)
{
descriptor = string.Empty;
friendlyName = string.Empty;
isHairDye = false;
createdAt = "2023-04-06T00:00:00.0000000Z";
string path = Path.Combine(
Program.dataDir,
"APIS",
"Items",
"Consumables.json");
if (!System.IO.File.Exists(path))
return false;
try
{
JsonNode? root = JsonNode.Parse(System.IO.File.ReadAllText(path));
if (root is not JsonArray items)
return false;
foreach (JsonNode? node in items)
{
if (node is not JsonObject item)
{
continue;
}
List<long> ids = ReadConsumableIds(item);
int mappingIndex = ids.IndexOf(consumableId);
if (mappingIndex < 0)
continue;
friendlyName = ReadString(item, "FriendlyName") ?? string.Empty;
descriptor = ReadString(item, "ConsumableItemDesc") ?? string.Empty;
if (string.IsNullOrWhiteSpace(descriptor))
descriptor = $"legacy-consumable-{consumableId}";
isHairDye = IsHairDyeFriendlyName(friendlyName);
List<string> createdAts = ReadConsumableCreatedAts(item);
createdAt = mappingIndex < createdAts.Count
? createdAts[mappingIndex]
: NormalizeConsumableTimestamp(
ReadString(item, "CreatedAt"))
?? createdAt;
return true;
}
}
catch (Exception ex) when (ex is JsonException or IOException)
{
Console.WriteLine(
$"[CONSUMABLE LOOKUP] Could not read Consumables.json: {ex.Message}");
}
return false;
}
private static bool TryGetOwnedConsumableInfo(
long accountId,
long consumableId,
out string descriptor,
out string friendlyName,
out bool isHairDye,
out string createdAt)
{
descriptor = string.Empty;
friendlyName = string.Empty;
isHairDye = false;
createdAt = "2023-04-06T00:00:00.0000000Z";
try
{
PlayerInventoryStore.ConsumableOwnership? owned =
PlayerInventoryStore.GetConsumables(accountId)
.FirstOrDefault(item =>
item.ConsumableItemId == consumableId &&
item.Quantity > 0);
if (owned == null)
return false;
descriptor = string.IsNullOrWhiteSpace(owned.ConsumableItemDesc)
? $"legacy-consumable-{consumableId}"
: owned.ConsumableItemDesc.Trim();
friendlyName = string.IsNullOrWhiteSpace(owned.FriendlyName)
? descriptor
: owned.FriendlyName.Trim();
isHairDye = IsHairDyeFriendlyName(friendlyName);
Console.WriteLine(
$"[CONSUMABLE LOOKUP] player={accountId} id={consumableId} " +
"source=owned_inventory_fallback");
return true;
}
catch (Exception ex) when (ex is JsonException or IOException)
{
Console.WriteLine(
$"[CONSUMABLE LOOKUP] Could not read player inventory: {ex.Message}");
return false;
}
}
private static bool TryGetHairDyeDescriptor(
long consumableId,
out string descriptor,
out string friendlyName)
{
descriptor = string.Empty;
friendlyName = string.Empty;
string path = Path.Combine(
Program.dataDir,
"APIS",
"Items",
"Consumables.json");
if (!System.IO.File.Exists(path))
return false;
try
{
JsonNode? root = JsonNode.Parse(System.IO.File.ReadAllText(path));
if (root is not JsonArray items)
return false;
foreach (JsonNode? node in items)
{
if (node is not JsonObject item ||
!ReadConsumableIds(item).Contains(consumableId))
{
continue;
}
friendlyName = ReadString(item, "FriendlyName") ?? string.Empty;
if (!IsHairDyeFriendlyName(friendlyName))
return false;
descriptor = ReadString(item, "ConsumableItemDesc") ?? string.Empty;
return !string.IsNullOrWhiteSpace(descriptor);
}
}
catch (JsonException ex)
{
Console.WriteLine(
$"[HAIR DYE CONSUME] Could not parse Consumables.json: {ex.Message}");
}
catch (IOException ex)
{
Console.WriteLine(
$"[HAIR DYE CONSUME] Could not read Consumables.json: {ex.Message}");
}
return false;
}
[HttpGet("/api/storefronts/v4/balance/{currencyType:int}")]
public IActionResult GetStorefrontBalance(int currencyType)
{
var accountId = AuthStuff.GetPlayerId(Request);
if (accountId == null)
return Unauthorized();
if (!Enum.IsDefined(typeof(CurrencyType), currencyType))
return BadRequest(new { error = "invalid_currency" });
var parsedCurrency = (CurrencyType)currencyType;
int balance = PlayerDB.GetCurrencyBalance(
accountId.Value,
parsedCurrency);
int balanceType = (int)BalanceType.NonPurchasedDefault;
return Ok(new[]
{
new
{
Balance = (long)balance,
CurrencyType = currencyType,
BalanceType = balanceType
}
});
}
[HttpPost("/api/storefronts/v2/balance")]
public async Task<IActionResult> GetStorefrontBalancesV2()
{
var accountId = AuthStuff.GetPlayerId(Request);
if (accountId == null)
return Unauthorized();
HashSet<long> requestedTypes = await ReadLongValuesAsync(
"currencyType", "CurrencyType",
"currencyTypes", "CurrencyTypes",
"type", "Type");
int balanceType = (int)BalanceType.NonPurchasedDefault;
IEnumerable<CurrencyType> typesToReturn = requestedTypes.Count > 0
? requestedTypes
.Where(value => Enum.IsDefined(typeof(CurrencyType), (int)value))
.Select(value => (CurrencyType)value)
.Distinct()
: PlayerDB.GetAllCurrencyBalances(accountId.Value)
.Select(entry => entry.CurrencyType);
var balances = typesToReturn
.Select(currency => new
{
Balance = (long)PlayerDB.GetCurrencyBalance(accountId.Value, currency),
CurrencyType = (int)currency,
BalanceType = balanceType
})
.ToArray();
return Ok(balances);
}
[HttpGet("/api/storefronts/v1/balanceAddType/{currencyType:int}/{balanceAddType:int}")]
public IActionResult GetStorefrontBalanceByAddType(
int currencyType,
int balanceAddType)
{
var account = AuthStuff.GetCurrentPlayer(Request);
if (account?.Player == null)
return Unauthorized();
if (!Enum.IsDefined(typeof(CurrencyType), currencyType) ||
!Enum.IsDefined(typeof(BalanceType), balanceAddType))
{
return BadRequest(new { error = "invalid_balance_type" });
}
int totalBalance = PlayerDB.GetCurrencyBalance(
account.PlayerId,
(CurrencyType)currencyType);
bool isPlayStation = account.PlatformIds?.Any(platform =>
platform.Platform == Platforms.PlayStation) == true;
int applicableBalanceType = (int)(isPlayStation
? BalanceType.PlayStationNonPurchasedP2P
: BalanceType.NonPlayStationNonPurchasedP2P);
long balance = balanceAddType == applicableBalanceType
? totalBalance
: 0L;
return Ok(new
{
Balance = balance,
CurrencyType = currencyType,
BalanceType = balanceAddType,
BalanceAddType = balanceAddType
});
}
[HttpGet("/subscription/subscriberCount/{accountId}")]
public IActionResult GetSubscriberCount(long accountId)
{
return Ok(PlayerDB.GetSubscriberCount(accountId));
}
[HttpGet("/subscription/details/{accountId:long}")]
public IActionResult GetSubscriptionDetails(long accountId)
{
if (accountId <= 0 || accountId > int.MaxValue)
{
return BadRequest(new
{
error = "invalid_account_id"
});
}
int subscriberCount = PlayerDB.GetSubscriberCount(accountId);
Console.WriteLine(
$"[SUBSCRIPTION DETAILS] account={accountId} " +
$"club=0 subscribers={subscriberCount}");
return Ok(new
{
accountId,
clubId = 0,
subscriberCount
});
}
[HttpGet("/subscription/details/{subscription}")]
public IActionResult GetNamedSubscriptionDetails(string subscription)
{
Console.WriteLine(
$"[SUBSCRIPTION DETAILS] name={subscription} simulated=empty");
return Content("{}", "application/json");
}
[HttpPost("/subscription/{accountId:long}")]
[HttpPut("/subscription/{accountId:long}")]
[HttpPost("/subscription/{accountId:long}/subscribe")]
[HttpPut("/subscription/{accountId:long}/subscribe")]
public IActionResult SubscribeToPlayer(long accountId)
{
var subscriberId = AuthStuff.GetPlayerId(Request);
if (subscriberId == null)
return Unauthorized();
if (!PlayerDB.SetSubscription(
(long)subscriberId,
accountId,
subscribe: true))
return BadRequest(new { success = false, error = "invalid_player" });
return Ok(new
{
AccountId = accountId,
SubscriberId = subscriberId,
IsSubscribed = true,
Subscribed = true,
SubscriberCount = PlayerDB.GetSubscriberCount(accountId)
});
}
[HttpDelete("/subscription/{accountId:long}")]
[HttpDelete("/subscription/{accountId:long}/unsubscribe")]
[HttpPost("/subscription/{accountId:long}/unsubscribe")]
[HttpPut("/subscription/{accountId:long}/unsubscribe")]
public IActionResult UnsubscribeFromPlayer(long accountId)
{
var subscriberId = AuthStuff.GetPlayerId(Request);
if (subscriberId == null)
return Unauthorized();
if (!PlayerDB.SetSubscription(
(long)subscriberId,
accountId,
subscribe: false))
return BadRequest(new { success = false, error = "invalid_player" });
return Ok(new
{
AccountId = accountId,
SubscriberId = subscriberId,
IsSubscribed = false,
Subscribed = false,
SubscriberCount = PlayerDB.GetSubscriberCount(accountId)
});
}
[HttpGet("/subscription/{accountId:long}")]
[HttpGet("/subscription/{accountId:long}/status")]
[HttpGet("/subscription/subscribed/{accountId:long}")]
public IActionResult GetPlayerSubscription(long accountId)
{
var subscriberId = AuthStuff.GetPlayerId(Request);
if (subscriberId == null)
return Unauthorized();
bool subscribed = PlayerDB.IsSubscribed(
(long)subscriberId,
accountId);
return Ok(new
{
AccountId = accountId,
SubscriberId = subscriberId,
IsSubscribed = subscribed,
Subscribed = subscribed,
SubscriberCount = PlayerDB.GetSubscriberCount(accountId)
});
}
[HttpGet("/api/customAvatarItems/v2/fromCreator/{creatorId}")]
public IActionResult GetCustomAvatarItemsFromCreator(long creatorId)
{
List<JsonObject> items = LoadCustomAvatarItems(creatorId);
return Ok(new
{
CreatorId = creatorId,
Results = items,
TotalResults = items.Count
});
}
[HttpGet("/api/customAvatarItems/v1/isCreationEnabled")]
[HttpGet("/api/customAvatarItems/v1/isRenderingEnabled")]
public IActionResult CustomAvatarItemsIsEnabled()
{
return Ok(true);
}
[HttpGet("/api/roomconsumables/v1/roomConsumable/room/{roomId}")]
public IActionResult GetRoomConsumablesForRoom(long roomId)
{
Console.WriteLine(
$"[ROOM CONSUMABLES] room={roomId} definitions=0 compatibility=april-2023");
return Ok(Array.Empty<object>());
}
[HttpGet("/api/roomconsumables/v1/roomConsumable/room/{roomId}/me")]
public IActionResult GetRoomConsumablesForRoomMe(long roomId)
{
long? accountId = AuthStuff.GetPlayerId(Request);
Console.WriteLine(
$"[ROOM CONSUMABLES] room={roomId} account={accountId?.ToString() ?? "unknown"} " +
"inventory=0 compatibility=april-2023");
return Ok(Array.Empty<object>());
}
[HttpPost("/api/sanitize/v1")]
public IActionResult SanitizeV1([FromBody] SanitizeRequest request)
{
var id = AuthStuff.GetPlayerId(Request);
if (id != null)
{
var player = PlayerDB.Players.FindById((long)id);
var username = player?.Player?.Username;
var severity = ProfanityFilter.GetSeverity(request.Value);
switch (severity)
{
case ProfanitySeverity.SevereBypass:
PlayerDB.BanPlayer((long)id, 2147483647, "Attempted to bypass filter with slur/severe language");
DiscordLogger.LogSanitize($"🔨 **PERMA BANNED** — `{username ?? "unknown"}` (ID: `{id}`) tried to bypass filter: {request.Value}");
break;
case ProfanitySeverity.Severe:
PlayerDB.BanPlayer((long)id, 1814400, "Slur/severe language");
DiscordLogger.LogSanitize($"🔨 **3 WEEK BAN** — `{username ?? "unknown"}` (ID: `{id}`) said: {request.Value}");
break;
default:
DiscordLogger.LogSanitizeChat((long)id, request.Value, username);
break;
}
}
var cleaned = ProfanityFilter.Blur(request.Value);
return Ok(JsonSerializer.Serialize(cleaned));
}
[HttpPost("/api/sanitize/v1/isPure")]
public IActionResult SanitizeV1IsPure()
{
return Ok(new
{
IsPure = true
});
}
[HttpGet("/api/AppIntegrity/v1/token")]
[HttpPost("/api/AppIntegrity/v1/verify")]
public IActionResult AppIntegrityStub()
{
return Ok(new { valid = true });
}
[HttpGet("/api/PlayersBanned/v1/check/{accountId}")]
public IActionResult PlayersBannedCheck(long accountId)
{
bool isBanned = PlayerDB.IsPlayerBanned(accountId, out var details);
return Ok(new
{
accountId,
isBanned,
banExpiresAt = isBanned ? DateTimeOffset.FromUnixTimeSeconds(details!.ModerationSetUnixTime + details.Duration).UtcDateTime : (DateTime?)null,
reason = isBanned ? details!.Message : (string?)null
});
}
[HttpGet("/api/challenge/v1/current")]
[HttpGet("/api/challenge/v1/all")]
public IActionResult ChallengeStub()
{
return Ok(ServerConfig.Bracket);
}
[HttpGet("/api/communityboard/v1/get")]
public IActionResult CommunityBoardStub()
{
return Ok(new
{
Results = Array.Empty<object>(),
TotalResults = 0
});
}
[HttpGet("/api/communityboard/v2/current")]
public IActionResult CommunityBoardCurrent()
{
string path = Path.Combine(Program.dataDir, "communityboard.json");
if (!System.IO.File.Exists(path))
{
string defaultJson = @"{
""Id"": 1,
""Title"": ""Community Board"",
""Posts"": []
}";
System.IO.File.WriteAllText(path, defaultJson);
Console.WriteLine($"[CommunityBoard] No communityboard.json found, created default template at {path}");
}
try
{
string rewritten = LoadingScreenImageService.RewriteConfiguration(
System.IO.File.ReadAllText(path));
return Content(rewritten, "application/json");
}
catch (Exception ex) when (ex is IOException or JsonException)
{
Console.WriteLine($"[CommunityBoard] Could not rewrite image URLs: {ex.Message}");
return Content(System.IO.File.ReadAllText(path), "application/json");
}
}
[HttpPost("/api/communityboard/v2/current")]
[HttpPut("/api/communityboard/v2/current")]
[RequestSizeLimit(256 * 1024)]
public async Task<IActionResult> SetCommunityBoardCurrent([FromBody] JsonElement request)
{
var player = AuthStuff.GetCurrentPlayer(Request);
if (player?.Player == null)
return Unauthorized();
if (player.PlayerRoles?.Any(role =>
role is PlayerDBClasses.PlayerRoles.Developer or
PlayerDBClasses.PlayerRoles.Moderator) != true)
return StatusCode(403);
string path = Path.Combine(Program.dataDir, "communityboard.json");
string json = JsonSerializer.Serialize(request, new JsonSerializerOptions { WriteIndented = true });
System.IO.File.WriteAllText(path, json);
await NotiController.BroadcastAnnouncementsUpdatedAsync();
try
{
string rewritten = LoadingScreenImageService.RewriteConfiguration(json);
return Content(rewritten, "application/json");
}
catch (JsonException ex)
{
Console.WriteLine($"[CommunityBoard] Saved board but could not rewrite image URLs: {ex.Message}");
return Ok(request);
}
}
[HttpPost("/api/externalfriendinvite/v1/send")]
public IActionResult ExternalFriendInviteStub()
{
var id = AuthStuff.GetPlayerId(Request);
if (id == null)
return Unauthorized();
return Ok(new { success = true });
}
[HttpPost("/api/gamesight/v1/event")]
public IActionResult GamesightStub()
{
return Ok();
}
[HttpGet("/api/incentivizedreferrals/v1/status")]
public IActionResult IncentivizedReferralsStub()
{
var id = AuthStuff.GetPlayerId(Request);
if (id == null)
return Unauthorized();
return Ok(new
{
referralCode = (string?)null,
referralsCompleted = 0,
rewardsAvailable = Array.Empty<object>()
});
}
[HttpGet("/api/keepsakes/v1/mine")]
[HttpGet("/api/keepsakes/mine")]
public IActionResult KeepsakesStub()
{
var id = AuthStuff.GetPlayerId(Request);
if (id == null)
return Unauthorized();
return Ok(new
{
KeepsakeInstances = Array.Empty<object>(),
CollectionRecords = Array.Empty<object>(),
CollectedKeepsakeIds = Array.Empty<long>()
});
}
[HttpGet("/api/itemWishlists/v1/wishlist/me")]
public IActionResult ItemWishlistMe()
{
var id = AuthStuff.GetPlayerId(Request);
if (id == null)
return Unauthorized();
return Ok(CreateWishlistItems(id.Value));
}
[HttpPut("/api/itemWishlists/v1/wishlist/me/{purchasableItemId:int}")]
public IActionResult AddItemToWishlist(int purchasableItemId)
{
var id = AuthStuff.GetPlayerId(Request);
if (id == null)
return Unauthorized();
if (!PlayerDB.SetWishlistItem(id.Value, purchasableItemId, wished: true))
return BadRequest(new { success = false, error = "wishlist_limit_or_invalid_item" });
return Ok(new
{
WishlistItemId = DeterministicWishlistId(id.Value, purchasableItemId),
AccountId = id.Value,
PurchasableItemId = purchasableItemId,
CreatedAt = DateTime.UtcNow
});
}
[HttpDelete("/api/itemWishlists/v1/wishlist/me/{purchasableItemId:int}")]
public IActionResult RemoveItemFromWishlist(int purchasableItemId)
{
var id = AuthStuff.GetPlayerId(Request);
if (id == null)
return Unauthorized();
PlayerDB.SetWishlistItem(id.Value, purchasableItemId, wished: false);
return Ok(new
{
success = true,
AccountId = id.Value,
PurchasableItemId = purchasableItemId
});
}
[HttpGet("/api/itemWishlists/v1/wishlist/{accountId:long}")]
public IActionResult GetAccountWishlist(long accountId)
{
if (PlayerDB.Players.FindById(accountId) == null)
return NotFound();
return Ok(CreateWishlistItems(accountId));
}
private static object[] CreateWishlistItems(long accountId) =>
PlayerDB.GetWishlist(accountId)
.Select(purchasableItemId => (object)new
{
WishlistItemId = DeterministicWishlistId(accountId, purchasableItemId),
AccountId = accountId,
PurchasableItemId = purchasableItemId,
CreatedAt = DateTime.UnixEpoch
})
.ToArray();
private static Guid DeterministicWishlistId(long accountId, int itemId)
{
byte[] bytes = System.Security.Cryptography.MD5.HashData(
Encoding.UTF8.GetBytes($"wishlist:{accountId}:{itemId}"));
return new Guid(bytes);
}
[HttpPost("/api/offlineinvite/v1/send")]
[HttpPost("/api/offlineinvite/send")]
[HttpPost("/api/offlineinvite/{targetPlayerId:long}")]
[HttpPost("/api/offlineinvite/v1/{targetPlayerId:long}")]
[HttpPost("/api/offlineinvite/send/{targetPlayerId:long}")]
[HttpPost("/api/offlineinvite/{targetPlayerId:long}/send")]
[HttpPost("/api/offlineinvite/v1/send/{targetPlayerId:long}")]
[HttpPost("/api/offlineinvite/{targetPlayerId:long}/{requestedRoomInstanceId:long}")]
[HttpPost("/api/offlineinvite/v1/{targetPlayerId:long}/{requestedRoomInstanceId:long}")]
[HttpPost("/api/offlineinvite/v1/send/{targetPlayerId:long}/{requestedRoomInstanceId:long}")]
[HttpPut("/api/offlineinvite/{targetPlayerId:long}")]
[HttpPut("/api/offlineinvite/v1/{targetPlayerId:long}")]
[RequestSizeLimit(64 * 1024)]
public async Task<IActionResult> SendOfflineInvite(
long? targetPlayerId = null,
long? requestedRoomInstanceId = null,
long? roomInstanceId = null)
{
long? inviterPlayerId = AuthStuff.GetPlayerId(Request);
if (!inviterPlayerId.HasValue)
return Unauthorized();
HashSet<long> targetPlayerIds = await ReadLongValuesAsync(
"id", "playerId", "PlayerId", "accountId", "AccountId",
"targetPlayerId", "TargetPlayerId", "targetAccountId", "TargetAccountId",
"inviteePlayerId", "InviteePlayerId", "inviteeAccountId", "InviteeAccountId",
"playerIds", "PlayerIds", "accountIds", "AccountIds");
if (targetPlayerId.HasValue && targetPlayerId.Value > 0)
targetPlayerIds.Add(targetPlayerId.Value);
targetPlayerIds.Remove(inviterPlayerId.Value);
HashSet<long> claimedInstanceIds = await ReadLongValuesAsync(
"roomInstanceId", "RoomInstanceId",
"requestedRoomInstanceId", "RequestedRoomInstanceId",
"targetRoomInstanceId", "TargetRoomInstanceId");
if (requestedRoomInstanceId is > 0)
claimedInstanceIds.Add(requestedRoomInstanceId.Value);
if (roomInstanceId is > 0)
claimedInstanceIds.Add(roomInstanceId.Value);
targetPlayerIds.ExceptWith(claimedInstanceIds);
Heartbeat? heartbeat = PlayerDB.GetPlayerHeartbeat(inviterPlayerId.Value);
RoomInstance? room = heartbeat?.roomInstance;
if (room == null)
return BadRequest(new { success = false, error = "inviter_not_in_room" });
if (!Sessions.IsConfirmedParticipant(
inviterPlayerId.Value,
room.roomInstanceId))
{
return StatusCode(409, new
{
success = false,
error = "room_instance_membership_not_confirmed"
});
}
if (claimedInstanceIds.Any(value =>
value != room.roomInstanceId))
{
return StatusCode(409, new
{
success = false,
error = "room_invite_source_mismatch",
requestedRoomInstanceIds = claimedInstanceIds,
currentRoomInstanceId = room.roomInstanceId
});
}
if (targetPlayerIds.Count == 0)
return BadRequest(new { success = false, error = "missing_target_player" });
var invited = new List<long>();
var missing = new List<long>();
var blocked = new List<long>();
foreach (long receiverPlayerId in targetPlayerIds.Take(100))
{
if (PlayerDB.Players.FindById(receiverPlayerId)?.Player == null)
{
missing.Add(receiverPlayerId);
continue;
}
var relationship = RelationshipDB.GetClientRelationship(
receiverPlayerId,
inviterPlayerId.Value);
if (relationship?.Ignored is 1 or 3)
{
blocked.Add(receiverPlayerId);
continue;
}
await NotiController.NotifyRoomInviteAsync(
inviterPlayerId.Value,
receiverPlayerId,
room.roomId,
room.roomInstanceId,
room.photonRoomId);
invited.Add(receiverPlayerId);
}
bool success = invited.Count > 0;
Console.WriteLine(
$"[OFFLINE INVITE] inviter={inviterPlayerId.Value} " +
$"targets={string.Join(',', targetPlayerIds)} " +
$"invited={string.Join(',', invited)} " +
$"missing={string.Join(',', missing)} " +
$"blocked={blocked.Count} " +
$"room={room.roomId} instance={room.roomInstanceId} " +
$"requestedInstance={string.Join(',', claimedInstanceIds)} " +
$"path={Request.Path} contentType={Request.ContentType ?? "null"}");
return Ok(new
{
Success = success,
Result = success ? 0 : 1,
Error = success ? string.Empty : "invite_failed",
RoomId = room.roomId,
RoomInstanceId = room.roomInstanceId,
InvitedPlayerIds = invited,
MissingPlayerIds = missing,
BlockedPlayerIds = blocked,
Results = invited.Select(playerId => new
{
PlayerId = playerId,
Error = string.Empty
}).ToArray()
});
}
[HttpPost("/api/platformlogin/v1/link")]
public IActionResult PlatformLoginStub()
{
return Ok(new { success = true });
}
[HttpGet("/api/playerwarnings/v1/mine")]
public IActionResult PlayerWarningsStub()
{
var id = AuthStuff.GetPlayerId(Request);
if (id == null)
return Unauthorized();
return Ok(Array.Empty<object>());
}
[HttpGet("/api/playstationplus/v1/membership")]
public IActionResult PlaystationPlusMembership()
{
return Ok(new
{
isMember = false,
expiresAt = (DateTime?)null
});
}
[HttpPost("/api/playstationplus/v1/expire")]
public IActionResult PlaystationPlusExpire()
{
return Ok();
}
[HttpGet("/api/progressionEvents/v1/all")]
public IActionResult ProgressionEventsStub()
{
return Ok(Array.Empty<object>());
}
[HttpGet("/api/roomEarningsDistributions/v1/mine")]
public IActionResult RoomEarningsDistributionsStub()
{
var id = AuthStuff.GetPlayerId(Request);
if (id == null)
return Unauthorized();
var results = RoomDB.Rooms.Find(room =>
room.CreatorAccountId == id.Value && !room.IsDorm)
.Select(CreateRoomEarningsDistribution)
.ToList();
return Ok(new
{
Results = results,
TotalResults = results.Count
});
}
[HttpGet("/api/roomEarningsDistributions/v1/earningsDistribution/{roomId:long}")]
public IActionResult GetRoomEarningsDistribution(long roomId)
{
var id = AuthStuff.GetPlayerId(Request);
if (id == null)
return Unauthorized();
var room = RoomDB.GetRoom(roomId);
return room == null
? NotFound()
: Ok(CreateRoomEarningsDistribution(room));
}
[HttpPost("/api/rooms/v1/verifyRole")]
public async Task<IActionResult> VerifyRoomRole()
{
var accountId = AuthStuff.GetPlayerId(Request);
if (accountId == null)
return Unauthorized();
if (!Request.HasFormContentType)
return BadRequest(new { success = false, error = "Expected room role form data." });
var form = await Request.ReadFormAsync(HttpContext.RequestAborted);
if (!long.TryParse(form["roomId"].FirstOrDefault(), out long roomId))
return BadRequest(new { success = false, error = "A valid room ID is required." });
var room = RoomDB.GetRoom(roomId);
if (room == null)
return NotFound();
int.TryParse(form["role"].FirstOrDefault(), out int claimedRole);
var assignedRole = room.CreatorAccountId == accountId.Value
? RoomDBClasses.Role.Creator
: room.Roles?.FirstOrDefault(role => role.AccountId == accountId.Value)?.Role
?? RoomDBClasses.Role.None;
string? context = form["context"].FirstOrDefault();
if (claimedRole != (int)assignedRole)
{
Console.WriteLine(
$"[ROOM ROLE VERIFY] room={roomId} account={accountId.Value} " +
$"claimed={claimedRole} assigned={(int)assignedRole} context={context ?? "null"}");
}
return Ok();
}
private static RoomEarningsDistributionResponse CreateRoomEarningsDistribution(
RoomDBClasses.Room room)
{
var mapping = new Dictionary<int, byte>();
if (room.CreatorAccountId is > 0 and <= int.MaxValue)
mapping[(int)room.CreatorAccountId] = 100;
return new RoomEarningsDistributionResponse
{
roomId = room.RoomId,
earningsDistributionMapping = mapping,
earningsDistributionMethod = 0
};
}
private sealed class RoomEarningsDistributionResponse
{
public long roomId { get; set; }
public Dictionary<int, byte> earningsDistributionMapping { get; set; } = new();
public int earningsDistributionMethod { get; set; }
}
[HttpGet("/api/royale/v1/status")]
public IActionResult RoyaleStatusStub()
{
return Ok(ServerConfig.Bracket);
}
[HttpGet("/api/subscriptionseasons/v1/seasons/current")]
public IActionResult SubscriptionSeasonsCurrent()
{
return Ok(new
{
seasonId = 0,
name = "",
startsAt = DateTime.UtcNow,
endsAt = DateTime.UtcNow.AddDays(30)
});
}
[HttpGet("/api/ugcPurchasables/v1/mine")]
public IActionResult UgcPurchasablesStub()
{
var id = AuthStuff.GetPlayerId(Request);
if (id == null)
return Unauthorized();
return Ok(new
{
Results = Array.Empty<object>(),
TotalResults = 0
});
}
[HttpGet("/cdn/{*filePath}")]
public IActionResult GetCDNFile(string filePath)
{
if (string.IsNullOrWhiteSpace(filePath))
return NotFound();
string normalized = filePath.Replace('\\', '/').TrimStart('/');
if (normalized.Contains("..", StringComparison.Ordinal) ||
Path.IsPathRooted(normalized))
{
return BadRequest();
}
if (normalized.StartsWith("room/invention/", StringComparison.OrdinalIgnoreCase))
normalized = normalized[5..];
if (normalized.StartsWith("invention/room/", StringComparison.OrdinalIgnoreCase))
normalized = normalized[10..];
string cdnRoot = Path.GetFullPath(Path.Combine(Program.dataDir, "CDN"));
var candidates = new List<string>
{
Path.Combine(cdnRoot, normalized.Replace('/', Path.DirectorySeparatorChar))
};
string safeName = Path.GetFileName(normalized);
if (!string.IsNullOrWhiteSpace(safeName))
{
candidates.Add(Path.Combine(cdnRoot, "assetbundles", safeName));
candidates.Add(Path.Combine(cdnRoot, "assetbundle", safeName));
candidates.Add(Path.Combine(cdnRoot, "unity", safeName));
candidates.Add(Path.Combine(cdnRoot, "invention", safeName));
candidates.Add(Path.Combine(cdnRoot, "room", safeName));
if (safeName.EndsWith(".inv", StringComparison.OrdinalIgnoreCase))
{
string extensionless = safeName[..^4];
if (!string.IsNullOrWhiteSpace(extensionless))
{
candidates.Add(Path.Combine(cdnRoot, "invention", extensionless));
candidates.Add(Path.Combine(cdnRoot, "room", extensionless));
}
}
else
{
string baseName = Path.GetFileNameWithoutExtension(safeName);
if (string.IsNullOrWhiteSpace(baseName))
baseName = safeName;
string invName = baseName + ".inv";
candidates.Add(Path.Combine(cdnRoot, "invention", invName));
candidates.Add(Path.Combine(cdnRoot, "room", invName));
}
}
string? path = null;
foreach (string candidate in candidates)
{
string resolved;
try
{
resolved = Path.GetFullPath(candidate);
}
catch (Exception exception) when (
exception is ArgumentException or NotSupportedException or PathTooLongException)
{
continue;
}
if (!resolved.StartsWith(cdnRoot + Path.DirectorySeparatorChar,
StringComparison.OrdinalIgnoreCase) ||
!System.IO.File.Exists(resolved))
{
continue;
}
path = resolved;
break;
}
if (path == null)
return NotFound();
var contentType = Path.GetExtension(path).ToLowerInvariant() switch
{
".png" => "image/png",
".jpg" or ".jpeg" => "image/jpeg",
".gif" => "image/gif",
".webp" => "image/webp",
".mp4" or ".m4v" => "video/mp4",
".mov" => "video/quicktime",
".webm" => "video/webm",
".wav" => "audio/wav",
".assetbundle" => "application/octet-stream",
".json" => "application/json",
".txt" => "text/plain",
_ => "application/octet-stream"
};
Response.Headers.CacheControl = "public,max-age=31536000,immutable";
return PhysicalFile(
path,
contentType,
enableRangeProcessing: contentType.StartsWith(
"video/",
StringComparison.OrdinalIgnoreCase));
}
[HttpGet("/purchase/v1/hasspentmoney")]
public IActionResult HasSpentMoney()
{
var id = AuthStuff.GetPlayerId(Request);
if (id == null)
return Unauthorized();
return Ok(new { hasSpentMoney = false });
}
[HttpGet("/purchasecampaign/allcurrent/v2")]
public IActionResult PurchaseCampaignAllCurrent()
{
return Ok(Array.Empty<object>());
}
[HttpGet("/api/apple/musicpromotion")]
public IActionResult AppleMusicPromotionStub()
{
return Ok(new { eligible = false });
}
[HttpGet("/preferences")]
public IActionResult GetPreferences()
{
long? playerId = AuthStuff.GetPlayerId(Request);
if (!playerId.HasValue)
return Unauthorized();
return Ok(new
{
NotificationPreferences = new[]
{
0,
1,
2,
3,
4,
5,
6,
7,
8,
9,
10
}
});
}
[HttpGet("api/storefronts/v1/adcarouselitems")]
public IActionResult GetAdCarouselItems()
{
List<CatalogSku> catalog = GetCatalogSkus()
.Where(IsUsableStoreItem)
.ToList();
List<int> purchasableIds = catalog
.Select(item => item.PurchasableItemId)
.Where(id => id > 0)
.Distinct()
.ToList();
int avatarItems = catalog.Count(IsAvatarCatalogItem);
int equipmentSkins = catalog.Count(IsEquipmentSkin);
int consumables = catalog.Count(IsConsumableCatalogItem);
Console.WriteLine(
$"[ALL-I-GOT STORE] items={purchasableIds.Count} " +
$"avatar={avatarItems} skins={equipmentSkins} consumables={consumables}");
var items = new[]
{
new StorefrontAdCarouselItem
{
AdCarouselItemId = 1,
ImageName = "DefaultPFP.png",
Title = "The All-I-Got Store",
Description =
$"Everything Mocha has: {avatarItems} avatar items, " +
$"{equipmentSkins} skins, and {consumables} consumables. " +
$"100 tokens each.",
PurchasableItemIds = purchasableIds,
PurchaseReminderId = null
}
};
return Ok(items);
}
[HttpGet("/api/rooms/v1/filters")]
public IActionResult GetRoomFilters()
{
string[] pinnedTags = { "rro", "quest", "base" };
string[] popularTags = { "rro", "quest", "base" };
string[] tags =
{
"rro", "quest", "base", "pvp", "game", "hangout",
"art", "music", "sports", "parkour", "story"
};
Console.WriteLine(
$"[ROOM FILTERS] format=object pinned={pinnedTags.Length} " +
$"popular={popularTags.Length} tags={tags.Length}");
return Ok(new
{
PinnedTags = pinnedTags,
PopularTags = popularTags,
Tags = tags
});
}
[HttpGet("/api/relationships/v2/sendfriendrequest")]
[HttpPost("/api/relationships/v2/sendfriendrequest")]
public async Task<IActionResult> SendFriendRequest()
{
long? accountId = AuthStuff.GetPlayerId(Request);
if (!accountId.HasValue)
return Unauthorized();
long targetId = (await ReadLongValuesAsync(
"id", "playerId", "PlayerId", "accountId", "AccountId",
"targetPlayerId", "TargetPlayerId", "targetAccountId", "TargetAccountId"))
.FirstOrDefault();
if (targetId <= 0)
return BadRequest(new { success = false, error = "missing_target_player" });
ClientRelationshipDTO? before =
RelationshipDB.GetClientRelationship(accountId.Value, targetId);
PlayerRelationship? relationship = PlayerDB.SendFriendRequest(
accountId.Value,
targetId);
if (relationship == null)
return BadRequest(new { success = false, error = "invalid_player" });
ClientRelationshipDTO? clientRelationship =
RelationshipDB.GetClientRelationship(accountId.Value, targetId);
if (clientRelationship == null)
{
return StatusCode(500, new
{
success = false,
error = "relationship_not_persisted"
});
}
await NotifyRelationshipMutationAsync(
accountId.Value,
targetId,
before,
clientRelationship);
return Ok(clientRelationship);
}
[HttpGet("/api/relationships/v2/addfriend")]
[HttpPost("/api/relationships/v2/addfriend")]
[HttpGet("/api/relationships/v2/acceptfriendrequest")]
[HttpPost("/api/relationships/v2/acceptfriendrequest")]
public async Task<IActionResult> AcceptFriendRequest()
{
long? accountId = AuthStuff.GetPlayerId(Request);
if (!accountId.HasValue)
return Unauthorized();
long requesterId = (await ReadLongValuesAsync(
"id", "playerId", "PlayerId", "accountId", "AccountId",
"requesterPlayerId", "RequesterPlayerId", "requesterAccountId", "RequesterAccountId"))
.FirstOrDefault();
if (requesterId <= 0)
return BadRequest(new { success = false, error = "missing_requester_player" });
ClientRelationshipDTO? before =
RelationshipDB.GetClientRelationship(accountId.Value, requesterId);
PlayerRelationship? relationship = PlayerDB.AcceptFriendRequest(
accountId.Value,
requesterId);
relationship ??= PlayerDB.SendFriendRequest(accountId.Value, requesterId);
if (relationship == null)
return BadRequest(new { success = false, error = "invalid_player" });
ClientRelationshipDTO? clientRelationship =
RelationshipDB.GetClientRelationship(accountId.Value, requesterId);
if (clientRelationship == null)
{
return StatusCode(500, new
{
success = false,
error = "relationship_not_persisted"
});
}
await NotifyRelationshipMutationAsync(
accountId.Value,
requesterId,
before,
clientRelationship);
return Ok(clientRelationship);
}
[HttpPost("/api/relationships/v3/{id:long}")]
public async Task<IActionResult> AddFriendV3(long id)
{
long? accountId = AuthStuff.GetPlayerId(Request);
if (!accountId.HasValue)
return Unauthorized();
ClientRelationshipDTO? before =
RelationshipDB.GetClientRelationship(accountId.Value, id);
PlayerRelationship? relationship = PlayerDB.SendFriendRequest(
accountId.Value,
id);
if (relationship == null)
return BadRequest(new { success = false, error = "invalid_player" });
ClientRelationshipDTO? clientRelationship =
RelationshipDB.GetClientRelationship(accountId.Value, id);
if (clientRelationship == null)
{
return StatusCode(500, new
{
success = false,
error = "relationship_not_persisted"
});
}
await NotifyRelationshipMutationAsync(
accountId.Value,
id,
before,
clientRelationship);
return Ok(clientRelationship);
}
[HttpDelete("/api/relationships/v3/{id:long}")]
[HttpDelete("/api/relationships/v2/removefriend/{id:long}")]
public async Task<IActionResult> RemoveFriend(long id)
{
long? accountId = AuthStuff.GetPlayerId(Request);
if (!accountId.HasValue)
return Unauthorized();
bool removed = PlayerDB.RemoveFriend(accountId.Value, id);
if (!removed)
return NotFound();
await NotiController.NotifyFriendRemovedAsync(
accountId.Value,
id);
return NoContent();
}
[HttpGet("/api/relationships/v2/removefriend")]
[HttpPost("/api/relationships/v2/removefriend")]
public async Task<IActionResult> RemoveFriendV2([FromQuery] long id)
{
long? accountId = AuthStuff.GetPlayerId(Request);
if (!accountId.HasValue)
return Unauthorized();
if (id <= 0 || id == accountId.Value)
return BadRequest(new { success = false, error = "invalid_player" });
bool removed = PlayerDB.RemoveFriend(accountId.Value, id);
if (removed)
{
await NotiController.NotifyFriendRemovedAsync(
accountId.Value,
id);
}
Console.WriteLine(
$"[RELATIONSHIP REMOVE] source={accountId.Value} target={id} " +
$"removed={removed} route=v2-query live={removed}");
return Ok();
}
private static async Task NotifyRelationshipMutationAsync(
long actorPlayerId,
long otherPlayerId,
ClientRelationshipDTO? before,
ClientRelationshipDTO after)
{
bool unchanged = before != null &&
before.PlayerID == after.PlayerID &&
before.RelationshipType == after.RelationshipType &&
before.Favorited == after.Favorited &&
before.Muted == after.Muted &&
before.Ignored == after.Ignored;
if (unchanged)
return;
RelationshipType finalType =
(RelationshipType)after.RelationshipType;
RelationshipType? previousType = before == null
? null
: (RelationshipType)before.RelationshipType;
if (finalType == RelationshipType.Friend &&
previousType != RelationshipType.Friend)
{
await NotiController.NotifyFriendAcceptedAsync(
actorPlayerId,
otherPlayerId);
return;
}
if (finalType == RelationshipType.OutgoingFriendRequest &&
previousType != RelationshipType.OutgoingFriendRequest)
{
await NotiController.NotifyFriendRequestAsync(
actorPlayerId,
otherPlayerId);
return;
}
await NotiController.NotifyRelationshipFlagsChangedAsync(
actorPlayerId,
otherPlayerId,
"friendship-change");
}
[HttpGet("/api/relationships/mutualfriends")]
public IActionResult GetMutualFriends([FromQuery] long id)
{
var accountId = AuthStuff.GetPlayerId(Request);
if (accountId == null)
return Unauthorized();
var mine = PlayerDB.GetRelationships((long)accountId)
.Where(value => value.RelationshipType == RelationshipType.Friend)
.Select(value => value.PlayerID)
.ToHashSet();
var theirs = PlayerDB.GetRelationships(id)
.Where(value => value.RelationshipType == RelationshipType.Friend)
.Select(value => value.PlayerID);
return Ok(theirs.Where(mine.Contains).ToArray());
}
[HttpPost("/api/PlayerReporting/v3/create")]
[RequestSizeLimit(64 * 1024)]
public async Task<IActionResult> CreatePlayerReportV3()
{
var reporter = AuthStuff.GetCurrentPlayer(Request);
if (reporter?.Player == null)
return Unauthorized();
JsonElement requestData;
try
{
if (Request.HasFormContentType)
{
var form = await Request.ReadFormAsync(
HttpContext.RequestAborted);
var formData = form.ToDictionary(
entry => entry.Key,
entry => entry.Value.ToString(),
StringComparer.OrdinalIgnoreCase);
requestData = JsonSerializer.SerializeToElement(formData);
}
else
{
using var reader = new StreamReader(
Request.Body,
Encoding.UTF8,
detectEncodingFromByteOrderMarks: true,
leaveOpen: true);
string rawBody = await reader.ReadToEndAsync(
HttpContext.RequestAborted);
if (string.IsNullOrWhiteSpace(rawBody))
{
requestData = JsonSerializer.SerializeToElement(
new Dictionary<string, string>());
}
else
{
try
{
using JsonDocument document =
JsonDocument.Parse(rawBody);
requestData = document.RootElement.Clone();
}
catch (JsonException)
{
var parsedData =
Microsoft.AspNetCore.WebUtilities.QueryHelpers
.ParseQuery(rawBody)
.ToDictionary(
entry => entry.Key,
entry => entry.Value.ToString(),
StringComparer.OrdinalIgnoreCase);
if (parsedData.Count == 0)
parsedData["RawBody"] = rawBody;
requestData =
JsonSerializer.SerializeToElement(parsedData);
}
}
}
}
catch (Exception exception)
{
Console.WriteLine(
$"[PLAYER REPORT] Failed reading request: {exception}");
return BadRequest(new
{
success = false,
error = "invalid_report_body"
});
}
long reporterId = reporter.PlayerId;
string? reporterUsername = reporter.Player.Username;
long? reportedPlayerId = FindJsonLong(
requestData,
"PlayerIdReported",
"ReportedPlayerId",
"ReportedAccountId",
"TargetPlayerId",
"TargetAccountId",
"SuspectPlayerId");
var reportedPlayer = reportedPlayerId.HasValue
? PlayerDB.Players.FindById(reportedPlayerId.Value)
: null;
string? reportedUsername =
reportedPlayer?.Player?.Username ??
FindJsonString(
requestData,
"UsernameReported",
"ReportedPlayerUsername",
"ReportedUsername",
"TargetUsername");
int? reportCategory = FindJsonInt(
requestData,
"ReportCategory",
"ReportCategoryId",
"CategoryId",
"ReportType");
string? details = FindJsonString(
requestData,
"Details",
"Description",
"Comment",
"Message",
"AdditionalDetails",
"Notes");
long? roomId = FindJsonLong(
requestData,
"RoomId",
"SourceRoomId");
string? roomInstanceType = FindJsonString(
requestData,
"RoomInstanceType",
"InstanceType",
"RoomType");
Guid reportId = Guid.NewGuid();
var storedReport = new ReportsDB.PlayerReport
{
Id = reportId,
ReporterId = reporterId,
ReporterUsername = reporterUsername,
ReportedPlayerId = reportedPlayerId,
ReportedUsername = reportedUsername,
ReportCategory = reportCategory,
Details = details,
RoomId = roomId,
RoomInstanceType = roomInstanceType
};
ReportsDB.PlayerReports.Insert(storedReport);
DiscordLogger.LogPlayerReport(
reportId,
reporterId,
reporterUsername,
reportedPlayerId,
reportedUsername,
reportCategory,
details,
roomId,
roomInstanceType);
Console.WriteLine(
$"[PLAYER REPORT] Report ID: {reportId}");
Console.WriteLine(
$"[PLAYER REPORT] Reporter: " +
$"{reporterUsername ?? "unknown"} ({reporterId})");
Console.WriteLine(
$"[PLAYER REPORT] Reported: " +
$"{reportedUsername ?? "unknown"} " +
$"({reportedPlayerId?.ToString() ?? "unknown"})");
Console.WriteLine(
$"[PLAYER REPORT] Payload: {requestData.GetRawText()}");
bool reporterIsDeveloper =
reporter.PlayerRoles?.Contains(PlayerRoles.Developer) == true;
bool reporterIsModerator =
reporter.PlayerRoles?.Contains(PlayerRoles.Moderator) == true;
if ((reporterIsDeveloper || reporterIsModerator) &&
TryParseReportBanCommand(
details,
out int banDurationSeconds,
out string banReason))
{
if (!reportedPlayerId.HasValue ||
reportedPlayer?.Player == null)
{
Console.WriteLine(
$"[REPORT BAN] reporter={reporterId} rejected=true " +
"reason=reported_player_not_found");
}
else if (reportedPlayerId.Value == reporterId)
{
Console.WriteLine(
$"[REPORT BAN] reporter={reporterId} rejected=true " +
"reason=self_ban");
}
else
{
bool targetIsDeveloper =
reportedPlayer.PlayerRoles?.Contains(
PlayerRoles.Developer) == true;
if (targetIsDeveloper && !reporterIsDeveloper)
{
Console.WriteLine(
$"[REPORT BAN] reporter={reporterId} " +
$"target={reportedPlayerId.Value} rejected=true " +
"reason=moderator_cannot_ban_developer");
}
else
{
PlayerDB.BanPlayer(
reportedPlayerId.Value,
banDurationSeconds,
banReason,
checked((ulong)reporterId));
storedReport.Status = banDurationSeconds > 0 &&
banDurationSeconds < int.MaxValue
? "TimedOut"
: "Banned";
storedReport.ResolvedByAccountId = reporterId;
storedReport.ResolvedByUsername = reporterUsername;
storedReport.ResolvedAt = DateTime.UtcNow;
storedReport.ResolutionNote = banReason;
storedReport.ActionDurationSeconds = banDurationSeconds;
ReportsDB.PlayerReports.Update(storedReport);
Console.WriteLine(
$"[REPORT BAN] reporter={reporterId} " +
$"target={reportedPlayerId.Value} " +
$"durationSeconds={banDurationSeconds} " +
$"reason={banReason}");
}
}
}
return Ok(new
{
success = true
});
}
[HttpPut("/reports/{reportId:guid}")]
[RequestSizeLimit(16 * 1024 * 1024)]
public async Task<IActionResult> UploadCrashReport(Guid reportId)
{
long? accountId = AuthStuff.GetPlayerId(Request);
if (!accountId.HasValue)
{
return Unauthorized(new
{
success = false,
reportId,
error = "authentication_required"
});
}
string reportsDirectory = Path.Combine(
Program.dataDir,
"Debug",
"CrashReports");
Directory.CreateDirectory(reportsDirectory);
bool isJson =
Request.ContentType?.Contains(
"json",
StringComparison.OrdinalIgnoreCase) == true;
string extension = isJson ? ".json" : ".bin";
string reportPath = Path.Combine(
reportsDirectory,
reportId.ToString("D") + extension);
try
{
await using var reportFile = new FileStream(
reportPath,
FileMode.Create,
FileAccess.Write,
FileShare.Read,
bufferSize: 81920,
useAsync: true);
await Request.Body.CopyToAsync(
reportFile,
HttpContext.RequestAborted);
}
catch (OperationCanceledException)
{
return StatusCode(499, new
{
success = false,
reportId,
error = "crash_report_upload_cancelled"
});
}
catch (Exception exception)
{
Console.WriteLine(
$"[CRASH REPORT] Failed report={reportId}: {exception}");
return StatusCode(
StatusCodes.Status500InternalServerError,
new
{
success = false,
reportId,
error = "crash_report_storage_failed"
});
}
long storedBytes = new FileInfo(reportPath).Length;
Console.WriteLine(
$"[CRASH REPORT] Stored report={reportId} " +
$"account={accountId.Value} bytes={storedBytes} " +
$"contentType={Request.ContentType ?? "unknown"}");
return Ok(new
{
success = true,
id = reportId,
reportId
});
}
[HttpPut("/reports/{reportId:guid}/attachments/{fileName}")]
[RequestSizeLimit(16 * 1024 * 1024)]
public async Task<IActionResult> UploadCrashReportAttachment(
Guid reportId,
string fileName)
{
long? accountId = AuthStuff.GetPlayerId(Request);
if (!accountId.HasValue)
{
return Unauthorized(new
{
success = false,
reportId,
error = "authentication_required"
});
}
string safeFileName = Path.GetFileName(fileName);
if (string.IsNullOrWhiteSpace(safeFileName) ||
safeFileName != fileName)
{
return BadRequest(new
{
success = false,
reportId,
error = "invalid_attachment_name"
});
}
string attachmentsDirectory = Path.Combine(
Program.dataDir,
"Debug",
"CrashReports",
reportId.ToString("D") + "_attachments");
Directory.CreateDirectory(attachmentsDirectory);
string attachmentPath = Path.Combine(
attachmentsDirectory,
safeFileName);
try
{
await using var attachmentFile = new FileStream(
attachmentPath,
FileMode.Create,
FileAccess.Write,
FileShare.Read,
bufferSize: 81920,
useAsync: true);
await Request.Body.CopyToAsync(
attachmentFile,
HttpContext.RequestAborted);
}
catch (OperationCanceledException)
{
return StatusCode(499, new
{
success = false,
reportId,
error = "crash_report_attachment_upload_cancelled"
});
}
catch (Exception exception)
{
Console.WriteLine(
$"[CRASH REPORT] Failed attachment report={reportId} " +
$"file={safeFileName}: {exception}");
return StatusCode(
StatusCodes.Status500InternalServerError,
new
{
success = false,
reportId,
error = "crash_report_attachment_storage_failed"
});
}
long storedBytes = new FileInfo(attachmentPath).Length;
Console.WriteLine(
$"[CRASH REPORT] Stored attachment report={reportId} " +
$"file={safeFileName} account={accountId.Value} " +
$"bytes={storedBytes}");
return Ok(new
{
success = true,
id = reportId,
reportId,
fileName = safeFileName
});
}
private static bool TryParseReportBanCommand(
string? details,
out int durationSeconds,
out string reason)
{
durationSeconds = 0;
reason = string.Empty;
string command = details?.Trim() ?? string.Empty;
int separatorIndex = command.IndexOf(':');
if (separatorIndex <= 1 || separatorIndex == command.Length - 1)
return false;
string duration = command[..separatorIndex];
reason = command[(separatorIndex + 1)..].Trim();
if (duration.Any(char.IsWhiteSpace) ||
reason.Length is < 1 or > 500)
{
return false;
}
char unit = duration[^1];
string quantityText = duration[..^1];
if (quantityText.Length == 0 ||
quantityText.Any(character => !char.IsAsciiDigit(character)) ||
!long.TryParse(quantityText, out long quantity) ||
quantity <= 0)
{
return false;
}
long multiplier = unit switch
{
'm' => 60,
'h' => 60 * 60,
'd' => 24 * 60 * 60,
'w' => 7 * 24 * 60 * 60,
_ => 0
};
if (multiplier == 0 ||
quantity > int.MaxValue / multiplier)
{
return false;
}
durationSeconds = checked((int)(quantity * multiplier));
return true;
}
[HttpGet("/api/messages/v1/favoriteFriendOnlineStatus")]
public IActionResult GetFavoriteFriendOnlineStatus()
{
var accountId = AuthStuff.GetPlayerId(Request);
if (accountId == null)
return Unauthorized();
var statuses = PlayerDB.GetRelationships((long)accountId)
.Where(value =>
value.RelationshipType == RelationshipType.Friend &&
value.Favorited)
.Select(value => new
{
PlayerId = value.PlayerID,
IsOnline = PlayerDB.GetPlayerHeartbeat(value.PlayerID).isOnline
});
return Ok(statuses);
}
[HttpPost("purchase/v1/cleanuppending")]
public IActionResult CleanupPendingPurchases()
{
return Ok(new { cleaned = 0, status = "ok" });
}
[HttpGet("api/playerevents/v1/tagfilters")]
[HttpGet("api/playerevents/v1/filters")]
public IActionResult GetPlayerEventTagFilters()
{
string[] filters = { "social", "competitive", "creative" };
return Ok(new
{
PinnedTags = Array.Empty<string>(),
PopularTags = filters,
Tags = filters
});
}
[HttpGet("api/playerevents/v1/room/{roomId}")]
public IActionResult GetPlayerEventsForRoom(long roomId)
{
return Ok(Array.Empty<object>());
}
[HttpGet("api/inventions/v1/room")]
public IActionResult GetInventionsForRoom(
[FromQuery] long id,
[FromQuery] long roomId = 0,
[FromQuery] int skip = 0,
[FromQuery] int take = 100)
{
long resolvedRoomId = roomId > 0 ? roomId : id;
object[] results = CreatorFeatureDB.SearchInventions(
roomId: resolvedRoomId > 0 ? resolvedRoomId : null,
skip: skip,
take: take)
.Select(CreatorFeatureDB.ToClientInvention)
.ToArray();
return Ok(results);
}
private static readonly string[] ChatMemberIdKeys =
{
"MemberIds", "memberIds",
"Members", "members",
"ThreadMembers", "threadMembers",
"WithMembers", "withMembers",
"AccountIds", "accountIds",
"PlayerAccountIds", "playerAccountIds",
"PlayerIds", "playerIds",
"UserIds", "userIds",
"Ids", "ids",
"RecipientIds", "recipientIds",
"Recipients", "recipients",
"ReceiverIds", "receiverIds",
"TargetIds", "targetIds",
"TargetPlayerIds", "targetPlayerIds",
"OtherPlayerIds", "otherPlayerIds",
"ToAccountIds", "toAccountIds",
"ToPlayerIds", "toPlayerIds",
"WithPlayerIds", "withPlayerIds",
"AccountId", "accountId",
"PlayerAccountId", "playerAccountId",
"PlayerId", "playerId",
"UserId", "userId",
"RecipientId", "recipientId",
"ReceiverId", "receiverId",
"TargetId", "targetId",
"OtherPlayerId", "otherPlayerId",
"Id", "id",
"a"
};
private static readonly string[] ChatMessageKeys =
{
"MessageContents", "messageContents",
"Contents", "contents",
"Message", "message",
"Body", "body",
"Text", "text",
"Content", "content"
};
[HttpGet("thread")]
public IActionResult GetThreads(
[FromQuery] int maxCount = 50,
[FromQuery] int mode = 0)
{
long? accountId = AuthStuff.GetPlayerId(Request);
if (!accountId.HasValue)
return Unauthorized();
object[] threads = ChatDB
.GetThreadsForPlayer(accountId.Value, maxCount)
.Select(thread => BuildChatThreadDto(thread, accountId.Value, messageCount: 50))
.ToArray();
return Ok(threads);
}
[HttpPost("thread/withmembers")]
[RequestSizeLimit(64 * 1024)]
public async Task<IActionResult> GetOrCreateThreadWithMembers()
{
return await CreateOrOpenChatThreadAsync(wrapResult: false);
}
[HttpPost("thread")]
[RequestSizeLimit(64 * 1024)]
public async Task<IActionResult> CreateThread()
{
ParsedChatThreadRequest parsed = await ReadChatThreadRequestAsync();
string? messageContents = await ReadChatMessageBodyAsync();
if (!string.IsNullOrWhiteSpace(messageContents))
{
return await SendOrCreateChatMessageAsync(
parsed,
messageContents);
}
return await CreateOrOpenChatThreadAsync(parsed);
}
[HttpGet("thread/{threadId:long}")]
public IActionResult GetThread(
long threadId,
[FromQuery] int messageCount = 50)
{
long? accountId = AuthStuff.GetPlayerId(Request);
if (!accountId.HasValue)
return Unauthorized();
ChatDB.ChatThread? thread = ChatDB.GetThread(threadId, accountId.Value);
if (thread == null)
return NotFound();
messageCount = Math.Clamp(messageCount, 1, 100);
object dto = BuildChatThreadDto(
thread,
accountId.Value,
messageCount);
Console.WriteLine(
$"[CHAT OPEN] thread={threadId} viewer={accountId.Value} " +
$"messages={ChatDB.GetMessages(threadId, accountId.Value, messageCount).Count}");
return Ok(dto);
}
[HttpGet("thread/{threadId:long}/messages")]
[HttpGet("thread/{threadId:long}/message")]
public IActionResult GetThreadMessages(
long threadId,
[FromQuery] int maxCount = 50,
[FromQuery] long? beforeMessageId = null)
{
long? accountId = AuthStuff.GetPlayerId(Request);
if (!accountId.HasValue)
return Unauthorized();
if (ChatDB.GetThread(threadId, accountId.Value) == null)
return NotFound();
object[] messages = ChatDB
.GetMessages(threadId, accountId.Value, maxCount, beforeMessageId)
.Select(BuildChatMessageDto)
.ToArray();
return Ok(messages);
}
[HttpPost("thread/{threadId:long}/message/{messageId:long}/read")]
[HttpPut("thread/{threadId:long}/message/{messageId:long}/read")]
public IActionResult MarkThreadMessageRead(long threadId, long messageId)
{
long? accountId = AuthStuff.GetPlayerId(Request);
if (!accountId.HasValue)
return Unauthorized();
if (!ChatDB.MarkMessageRead(threadId, messageId, accountId.Value))
return NotFound();
Console.WriteLine(
$"[CHAT READ] thread={threadId} message={messageId} " +
$"viewer={accountId.Value}");
return NoContent();
}
[HttpPost("thread/{threadId:long}/message")]
[HttpPost("thread/{threadId:long}/messages")]
[HttpPost("thread/{threadId:long}")]
[RequestSizeLimit(32 * 1024)]
public async Task<IActionResult> SendThreadMessage(long threadId)
{
long? accountId = AuthStuff.GetPlayerId(Request);
if (!accountId.HasValue)
return Unauthorized();
ChatDB.ChatThread? thread = ChatDB.GetThread(threadId, accountId.Value);
if (thread == null)
{
return NotFound(new
{
chatMessage = (object?)null,
chatResult = 2
});
}
string? rawContents = await ReadChatMessageBodyAsync();
if (string.IsNullOrWhiteSpace(rawContents))
{
return BadRequest(new
{
chatMessage = (object?)null,
chatResult = 1
});
}
string contents = NormalizeChatMessageContents(rawContents);
ChatDB.ChatMessage? message = ChatDB.AddMessage(
threadId,
accountId.Value,
contents);
if (message == null)
{
return BadRequest(new
{
chatMessage = (object?)null,
chatResult = 6
});
}
object messageDto = BuildChatMessageDto(message);
long[] recipients = thread.MemberIds
.Where(id => id != accountId.Value)
.Distinct()
.ToArray();
await Task.WhenAll(recipients.Select(memberId =>
NotiController.NotifyChatMessageReceivedAsync(
memberId,
messageDto)));
Console.WriteLine(
$"[CHAT LIVE] message={message.MessageId} thread={threadId} " +
$"sender={accountId.Value} recipients={string.Join(',', recipients)}");
return Ok(new
{
chatMessage = messageDto,
chatResult = 0
});
}
[HttpDelete("thread/{threadId:long}")]
public IActionResult LeaveOrDeleteThread(long threadId)
{
long? accountId = AuthStuff.GetPlayerId(Request);
if (!accountId.HasValue)
return Unauthorized();
return ChatDB.LeaveThread(threadId, accountId.Value)
? NoContent()
: NotFound();
}
private async Task<IActionResult> CreateOrOpenChatThreadAsync(
ParsedChatThreadRequest? parsedRequest = null,
bool wrapResult = true)
{
long? accountId = AuthStuff.GetPlayerId(Request);
if (!accountId.HasValue)
return Unauthorized();
ParsedChatThreadRequest parsed =
parsedRequest ?? await ReadChatThreadRequestAsync();
HashSet<long> selectedIds = ResolveChatMemberIds(
parsed,
accountId.Value);
LogChatRequest(parsed, selectedIds);
if (selectedIds.Count == 0)
{
return BadRequest(new
{
chatThread = (object?)null,
chatResult = 1
});
}
if (selectedIds.Count > 99)
{
return BadRequest(new
{
chatThread = (object?)null,
chatResult = 8
});
}
ChatDB.ChatThread? existingThread = ChatDB.FindThreadWithMembers(
accountId.Value,
selectedIds);
bool createdNewThread = existingThread == null;
ChatDB.ChatThread thread = existingThread ?? ChatDB.GetOrCreateThread(
accountId.Value,
selectedIds,
parsed.Name,
parsed.Type);
object dto = BuildChatThreadDto(
thread,
accountId.Value,
messageCount: 50);
if (createdNewThread)
{
ChatDB.ChatMessage? starterMessage = ChatDB
.GetMessages(
thread.ThreadId,
accountId.Value,
maxCount: 1)
.FirstOrDefault();
if (starterMessage != null)
{
object starterMessageDto = BuildChatMessageDto(starterMessage);
long[] liveRecipients = thread.MemberIds
.Where(id => id > 0)
.Distinct()
.ToArray();
await Task.WhenAll(liveRecipients.Select(memberId =>
NotiController.NotifyChatMessageReceivedAsync(
memberId,
starterMessageDto)));
Console.WriteLine(
$"[CHAT LIVE] created thread={thread.ThreadId} " +
$"starter={starterMessage.MessageId} " +
$"recipients={string.Join(',', liveRecipients)}");
}
}
Console.WriteLine(
$"[CHAT] opened thread={thread.ThreadId} requester={accountId.Value} " +
$"members={string.Join(',', thread.MemberIds)} new={createdNewThread}");
if (!wrapResult)
{
Console.WriteLine(
$"[CHAT RESPONSE] route=/thread/withmembers format=direct " +
$"thread={thread.ThreadId}");
return Ok(dto);
}
Console.WriteLine(
$"[CHAT RESPONSE] route=/thread format=wrapped " +
$"thread={thread.ThreadId}");
return Ok(new
{
chatThread = dto,
chatResult = 0
});
}
private async Task<IActionResult> SendOrCreateChatMessageAsync(
ParsedChatThreadRequest parsed,
string rawMessageContents)
{
long? accountId = AuthStuff.GetPlayerId(Request);
if (!accountId.HasValue)
return Unauthorized();
HashSet<long> selectedIds = ResolveChatMemberIds(
parsed,
accountId.Value);
LogChatRequest(parsed, selectedIds);
if (selectedIds.Count == 0)
{
return BadRequest(new
{
chatThread = (object?)null,
chatResult = 1
});
}
if (selectedIds.Count > 99)
{
return BadRequest(new
{
chatThread = (object?)null,
chatResult = 8
});
}
ChatDB.ChatThread thread = ChatDB.GetOrCreateThread(
accountId.Value,
selectedIds,
parsed.Name,
parsed.Type);
string contents = NormalizeChatMessageContents(rawMessageContents);
ChatDB.ChatMessage? message = ChatDB.AddMessage(
thread.ThreadId,
accountId.Value,
contents);
if (message == null)
{
return BadRequest(new
{
chatThread = (object?)null,
chatResult = 6
});
}
object messageDto = BuildChatMessageDto(message);
long[] recipients = thread.MemberIds
.Where(id => id != accountId.Value)
.Distinct()
.ToArray();
await Task.WhenAll(recipients.Select(memberId =>
NotiController.NotifyChatMessageReceivedAsync(
memberId,
messageDto)));
object threadDto = BuildChatThreadDto(
thread,
accountId.Value,
messageCount: 50);
Console.WriteLine(
$"[CHAT LIVE] message={message.MessageId} thread={thread.ThreadId} " +
$"sender={accountId.Value} recipients={string.Join(',', recipients)}");
return Ok(new
{
chatResult = 0,
chatThread = threadDto
});
}
private static HashSet<long> ResolveChatMemberIds(
ParsedChatThreadRequest parsed,
long requestingAccountId)
{
parsed.MemberIds.Remove(requestingAccountId);
parsed.CandidateIds.Remove(requestingAccountId);
HashSet<long> validExplicitIds = parsed.MemberIds
.Where(ChatPlayerExists)
.ToHashSet();
HashSet<long> selectedIds = validExplicitIds.Count > 0
? validExplicitIds
: parsed.CandidateIds
.Where(ChatPlayerExists)
.ToHashSet();
selectedIds.Remove(requestingAccountId);
return selectedIds;
}
private void LogChatRequest(
ParsedChatThreadRequest parsed,
IEnumerable<long> selectedIds)
{
Console.WriteLine(
$"[CHAT REQUEST] contentType={Request.ContentType ?? "none"} " +
$"contentLength={(Request.ContentLength?.ToString() ?? "null")} " +
$"explicit={string.Join(',', parsed.MemberIds)} " +
$"candidates={string.Join(',', parsed.CandidateIds)} " +
$"selected={string.Join(',', selectedIds)} " +
$"body={parsed.RawBodyPreview}");
}
private static bool ChatPlayerExists(long playerId) =>
playerId > 0 && PlayerDB.Players.FindById(playerId)?.Player != null;
private object BuildChatThreadDto(
ChatDB.ChatThread thread,
long viewerAccountId,
int messageCount)
{
messageCount = Math.Clamp(messageCount, 1, 100);
object[] messages = ChatDB
.GetMessages(thread.ThreadId, viewerAccountId, messageCount)
.Select(BuildChatMessageDto)
.ToArray();
int[] playerIds = thread.MemberIds
.Where(id => id is > 0 and <= int.MaxValue)
.Select(id => checked((int)id))
.ToArray();
return new
{
chatThreadId = thread.ThreadId,
playerIds,
lastReadMessageId = ChatDB.GetLastReadMessageId(
thread.ThreadId,
viewerAccountId),
messages,
chatThreadName = thread.Name,
snoozedUntil = (DateTime?)null,
isFavorited = false
};
}
private static object BuildChatMessageDto(ChatDB.ChatMessage message)
{
int senderPlayerId = message.SenderAccountId is >= int.MinValue and <= int.MaxValue
? checked((int)message.SenderAccountId)
: 0;
return new
{
chatMessageId = message.MessageId,
chatThreadId = message.ThreadId,
senderPlayerId,
timeSent = message.CreatedAt,
contents = message.Body,
moderationState = 0
};
}
private async Task<ParsedChatThreadRequest> ReadChatThreadRequestAsync()
{
var parsed = new ParsedChatThreadRequest();
foreach (KeyValuePair<string, Microsoft.Extensions.Primitives.StringValues> entry in Request.Query)
{
bool memberKey = IsChatMemberKey(entry.Key);
foreach (string? value in entry.Value)
{
if (memberKey)
AddChatIds(value, parsed.MemberIds);
AddChatIds(value, parsed.CandidateIds);
}
}
parsed.Name =
Request.Query["Name"].FirstOrDefault() ??
Request.Query["name"].FirstOrDefault();
if (int.TryParse(
Request.Query["Type"].FirstOrDefault() ??
Request.Query["type"].FirstOrDefault(),
out int queryType))
{
parsed.Type = queryType;
}
if (Request.HasFormContentType)
{
IFormCollection form = await Request.ReadFormAsync();
foreach (KeyValuePair<string, Microsoft.Extensions.Primitives.StringValues> entry in form)
{
bool memberKey = IsChatMemberKey(entry.Key);
if (memberKey || LooksLikeChatIdData(entry.Key))
{
AddChatIds(
entry.Key,
memberKey ? parsed.MemberIds : parsed.CandidateIds);
}
foreach (string? value in entry.Value)
{
if (memberKey)
AddChatIds(value, parsed.MemberIds);
AddChatIds(value, parsed.CandidateIds);
}
}
parsed.Name ??=
form["Name"].FirstOrDefault() ??
form["name"].FirstOrDefault();
if (int.TryParse(
form["Type"].FirstOrDefault() ??
form["type"].FirstOrDefault(),
out int formType))
{
parsed.Type = formType;
}
return parsed;
}
Request.EnableBuffering();
if (Request.Body.CanSeek)
Request.Body.Position = 0;
string rawBody;
using (var reader = new StreamReader(
Request.Body,
Encoding.UTF8,
detectEncodingFromByteOrderMarks: true,
bufferSize: 4096,
leaveOpen: true))
{
rawBody = await reader.ReadToEndAsync();
}
if (Request.Body.CanSeek)
Request.Body.Position = 0;
parsed.RawBodyPreview = MakeChatBodyPreview(rawBody);
if (string.IsNullOrWhiteSpace(rawBody))
return parsed;
try
{
using JsonDocument document = JsonDocument.Parse(rawBody);
ReadChatThreadJson(
document.RootElement,
parsed,
memberContext: document.RootElement.ValueKind != JsonValueKind.Object);
}
catch (JsonException)
{
foreach (string pair in rawBody.Split(
'&',
StringSplitOptions.RemoveEmptyEntries))
{
string[] parts = pair.Split('=', 2);
string key = Uri.UnescapeDataString(parts[0].Replace('+', ' '));
string value = parts.Length > 1
? Uri.UnescapeDataString(parts[1].Replace('+', ' '))
: string.Empty;
bool memberKey = IsChatMemberKey(key);
if (memberKey)
AddChatIds(value, parsed.MemberIds);
AddChatIds(value, parsed.CandidateIds);
if (string.Equals(key, "name", StringComparison.OrdinalIgnoreCase))
parsed.Name = value;
else if (string.Equals(key, "type", StringComparison.OrdinalIgnoreCase) &&
int.TryParse(value, out int bodyType))
parsed.Type = bodyType;
}
}
AddChatIds(rawBody, parsed.CandidateIds);
return parsed;
}
private static void ReadChatThreadJson(
JsonElement element,
ParsedChatThreadRequest parsed,
bool memberContext)
{
switch (element.ValueKind)
{
case JsonValueKind.Number:
if (element.TryGetInt64(out long number) && number > 0)
{
parsed.CandidateIds.Add(number);
if (memberContext)
parsed.MemberIds.Add(number);
}
return;
case JsonValueKind.String:
{
string? value = element.GetString();
AddChatIds(value, parsed.CandidateIds);
if (memberContext)
AddChatIds(value, parsed.MemberIds);
if (!string.IsNullOrWhiteSpace(value) &&
(value.TrimStart().StartsWith('{') ||
value.TrimStart().StartsWith('[')))
{
try
{
using JsonDocument nested = JsonDocument.Parse(value);
ReadChatThreadJson(nested.RootElement, parsed, memberContext);
}
catch (JsonException)
{
}
}
return;
}
case JsonValueKind.Array:
foreach (JsonElement item in element.EnumerateArray())
ReadChatThreadJson(item, parsed, memberContext: true);
return;
case JsonValueKind.Object:
foreach (JsonProperty property in element.EnumerateObject())
{
if (string.Equals(property.Name, "name", StringComparison.OrdinalIgnoreCase) &&
property.Value.ValueKind == JsonValueKind.String)
{
parsed.Name = property.Value.GetString();
continue;
}
if (string.Equals(property.Name, "type", StringComparison.OrdinalIgnoreCase) &&
property.Value.TryGetInt32(out int type))
{
parsed.Type = type;
continue;
}
ReadChatThreadJson(
property.Value,
parsed,
memberContext || IsChatMemberKey(property.Name));
}
return;
}
}
private static bool IsChatMemberKey(string? key)
{
if (string.IsNullOrWhiteSpace(key))
return false;
if (ChatMemberIdKeys.Contains(key, StringComparer.OrdinalIgnoreCase))
return true;
string normalized = new string(key
.Where(char.IsLetterOrDigit)
.Select(char.ToLowerInvariant)
.ToArray());
return normalized.Contains("memberid") ||
normalized.Contains("playerid") ||
normalized.Contains("accountid") ||
normalized.Contains("userid") ||
normalized.Contains("recipientid") ||
normalized.Contains("receiverid") ||
normalized.Contains("targetid") ||
normalized is "members" or "players" or "accounts" or
"recipients" or "receivers" or "ids";
}
private static bool LooksLikeChatIdData(string? value)
{
if (string.IsNullOrWhiteSpace(value))
return false;
string trimmed = value.Trim();
return char.IsDigit(trimmed[0]) ||
trimmed[0] is '[' or '{' or '"';
}
private static string MakeChatBodyPreview(string? body)
{
if (string.IsNullOrWhiteSpace(body))
return "<empty>";
string oneLine = body
.Replace("\r", " ")
.Replace("\n", " ")
.Trim();
return oneLine.Length <= 500
? oneLine
: oneLine[..500] + "...";
}
private async Task<string?> ReadChatMessageBodyAsync()
{
foreach (string key in ChatMessageKeys)
{
string? value = Request.Query[key].FirstOrDefault();
if (!string.IsNullOrWhiteSpace(value))
return value;
}
if (Request.HasFormContentType)
{
IFormCollection form = await Request.ReadFormAsync();
foreach (string key in ChatMessageKeys)
{
string? value = form[key].FirstOrDefault();
if (!string.IsNullOrWhiteSpace(value))
return value;
}
return null;
}
Request.EnableBuffering();
if (Request.Body.CanSeek)
Request.Body.Position = 0;
string rawBody;
using (var reader = new StreamReader(
Request.Body,
Encoding.UTF8,
detectEncodingFromByteOrderMarks: true,
bufferSize: 4096,
leaveOpen: true))
{
rawBody = await reader.ReadToEndAsync();
}
if (Request.Body.CanSeek)
Request.Body.Position = 0;
if (string.IsNullOrWhiteSpace(rawBody))
return null;
try
{
using JsonDocument document = JsonDocument.Parse(rawBody);
string? found = FindChatContents(
document.RootElement,
ChatMessageKeys,
allowBareString: true);
if (!string.IsNullOrWhiteSpace(found))
return found;
if (document.RootElement.ValueKind == JsonValueKind.Object &&
(TryGetJsonPropertyIgnoreCase(
document.RootElement,
"Data",
out _) ||
TryGetJsonPropertyIgnoreCase(
document.RootElement,
"Type",
out _)))
{
return document.RootElement.GetRawText();
}
}
catch (JsonException)
{
foreach (string pair in rawBody.Split(
'&',
StringSplitOptions.RemoveEmptyEntries))
{
string[] parts = pair.Split('=', 2);
string key = Uri.UnescapeDataString(
parts[0].Replace('+', ' '));
string value = parts.Length > 1
? Uri.UnescapeDataString(parts[1].Replace('+', ' '))
: string.Empty;
if (ChatMessageKeys.Contains(
key,
StringComparer.OrdinalIgnoreCase) &&
!string.IsNullOrWhiteSpace(value))
{
return value;
}
}
return rawBody.Trim().Trim('"');
}
return null;
}
private static string? FindChatContents(
JsonElement element,
IReadOnlyCollection<string> keys,
bool allowBareString)
{
if (element.ValueKind == JsonValueKind.String)
{
return allowBareString
? element.GetString()
: null;
}
if (element.ValueKind == JsonValueKind.Object)
{
foreach (JsonProperty property in element.EnumerateObject())
{
if (!keys.Contains(
property.Name,
StringComparer.OrdinalIgnoreCase))
{
continue;
}
return property.Value.ValueKind == JsonValueKind.String
? property.Value.GetString()
: property.Value.GetRawText();
}
foreach (JsonProperty property in element.EnumerateObject())
{
string? nested = FindChatContents(
property.Value,
keys,
allowBareString: false);
if (!string.IsNullOrWhiteSpace(nested))
return nested;
}
}
if (element.ValueKind == JsonValueKind.Array)
{
foreach (JsonElement item in element.EnumerateArray())
{
string? nested = FindChatContents(
item,
keys,
allowBareString: false);
if (!string.IsNullOrWhiteSpace(nested))
return nested;
}
}
return null;
}
private static bool TryGetJsonPropertyIgnoreCase(
JsonElement element,
string name,
out JsonElement value)
{
if (element.ValueKind == JsonValueKind.Object)
{
foreach (JsonProperty property in element.EnumerateObject())
{
if (string.Equals(
property.Name,
name,
StringComparison.OrdinalIgnoreCase))
{
value = property.Value;
return true;
}
}
}
value = default;
return false;
}
private static string NormalizeChatMessageContents(string rawContents)
{
string value = rawContents.Trim();
try
{
using JsonDocument document = JsonDocument.Parse(value);
if (document.RootElement.ValueKind == JsonValueKind.String)
{
value = document.RootElement.GetString() ?? string.Empty;
}
else if (document.RootElement.ValueKind is
JsonValueKind.Object or JsonValueKind.Array)
{
return document.RootElement.GetRawText();
}
}
catch (JsonException)
{
}
return JsonSerializer.Serialize(new
{
Data = value,
Type = 0,
Version = 1,
Blocks = new[]
{
new
{
Placeholder = value,
Type = "Text"
}
}
});
}
private static void AddChatIds(string? value, ISet<long> output)
{
if (string.IsNullOrWhiteSpace(value))
return;
foreach (System.Text.RegularExpressions.Match match in
System.Text.RegularExpressions.Regex.Matches(
value,
@"(?<![0-9])-?[0-9]+"))
{
if (long.TryParse(match.Value, out long id) && id > 0)
output.Add(id);
}
}
private sealed class ParsedChatThreadRequest
{
public HashSet<long> MemberIds { get; } = new();
public HashSet<long> CandidateIds { get; } = new();
public string? Name { get; set; }
public int Type { get; set; }
public string RawBodyPreview { get; set; } = "<not-read>";
}
[HttpGet("api/inventions/v1/featured")]
public IActionResult GetFeaturedInventions(
[FromQuery] int skip = 0,
[FromQuery] int take = 100)
{
return Ok(CreatorFeatureDB.SearchInventions(skip: skip, take: take)
.Select(CreatorFeatureDB.ToClientInvention)
.ToArray());
}
[HttpPost("/api/inventions/v6/save")]
[HttpPost("/api/inventions/v5/save")]
[HttpPost("/api/inventions/v4/save")]
[HttpPost("/api/inventions/v3/save")]
[HttpPost("/api/inventions/v2/save")]
[HttpPost("/api/inventions/v1/save")]
[HttpPut("/api/inventions/v6/save")]
[HttpPut("/api/inventions/v5/save")]
[HttpPut("/api/inventions/v4/save")]
[HttpPut("/api/inventions/v3/save")]
[HttpPut("/api/inventions/v2/save")]
[HttpPut("/api/inventions/v1/save")]
[HttpGet("/api/inventions/v3/update")]
[HttpGet("/api/inventions/v2/update")]
[HttpGet("/api/inventions/v1/update")]
[HttpPost("/api/inventions/v3/update")]
[HttpPost("/api/inventions/v2/update")]
[HttpPost("/api/inventions/v1/update")]
[HttpPut("/api/inventions/v3/update")]
[HttpPut("/api/inventions/v2/update")]
[HttpPut("/api/inventions/v1/update")]
[HttpPost("/api/inventions/v6")]
[HttpPut("/api/inventions/v6")]
[HttpPost("/api/inventions/v5")]
[HttpPut("/api/inventions/v5")]
[HttpPost("/api/inventions/v4")]
[HttpPut("/api/inventions/v4")]
[HttpPost("/api/inventions/v3")]
[HttpPut("/api/inventions/v3")]
[HttpPost("/api/inventions/v2")]
[HttpPut("/api/inventions/v2")]
[HttpPost("/api/inventions/v1")]
[HttpPut("/api/inventions/v1")]
[RequestSizeLimit(10 * 1024 * 1024)]
[RequestFormLimits(MultipartBodyLengthLimit = 10 * 1024 * 1024)]
public async Task<IActionResult> SaveInvention()
{
long? accountId = AuthStuff.GetPlayerId(Request);
if (!accountId.HasValue)
return Unauthorized();
JsonElement request;
var createdFiles = new List<string>();
try
{
if (Request.HasFormContentType)
{
IFormCollection form = await Request.ReadFormAsync(
HttpContext.RequestAborted);
var payload = new JsonObject();
foreach (KeyValuePair<string, Microsoft.Extensions.Primitives.StringValues> field
in form)
{
if (field.Value.Count > 0)
payload[field.Key] = field.Value[field.Value.Count - 1];
}
IFormFile? previewFile = form.Files.FirstOrDefault(
IsInventionPreviewFile);
IFormFile? dataFile = form.Files
.Where(file =>
!ReferenceEquals(file, previewFile) &&
!IsInventionPreviewFile(file))
.OrderByDescending(IsInventionDataFile)
.ThenByDescending(file => file.Length)
.FirstOrDefault();
if (previewFile == null && dataFile == null && form.Files.Count > 0)
{
IFormFile first = form.Files[0];
if (IsInventionPreviewFile(first))
previewFile = first;
else
dataFile = first;
}
if (previewFile != null && previewFile.Length > 0)
{
string imageName = await SaveInventionPreviewAsync(
previewFile,
createdFiles,
HttpContext.RequestAborted);
payload["ImageName"] = imageName;
payload["Image"] = imageName;
payload["PreviewImageName"] = imageName;
}
if (dataFile != null && dataFile.Length > 0)
{
string dataBlob = await SaveInventionDataAsync(
dataFile,
createdFiles,
HttpContext.RequestAborted);
payload["DataBlob"] = dataBlob;
payload["DataBlobName"] = Path.GetFileName(dataBlob);
payload["Filename"] = Path.GetFileName(dataBlob);
}
request = JsonSerializer.SerializeToElement(payload);
}
else if ((Request.ContentLength ?? 0) > 0)
{
using JsonDocument document = await JsonDocument.ParseAsync(
Request.Body,
cancellationToken: HttpContext.RequestAborted);
request = document.RootElement.Clone();
}
else
{
using JsonDocument emptyDocument = JsonDocument.Parse("{}");
request = emptyDocument.RootElement.Clone();
}
request = NormalizeInventionRequest(request, createdFiles);
request = MergeInventionQuery(request, Request.Query);
}
catch (JsonException)
{
DeleteNewInventionFiles(createdFiles);
return BadRequest(new { Success = false, Error = "invalid_invention_payload" });
}
catch (InvalidDataException exception)
{
DeleteNewInventionFiles(createdFiles);
return BadRequest(new { Success = false, Error = exception.Message });
}
catch (IOException exception)
{
DeleteNewInventionFiles(createdFiles);
Console.WriteLine($"[INVENTIONS] upload failed: {exception.Message}");
return StatusCode(
StatusCodes.Status500InternalServerError,
new { Success = false, Error = "invention_file_save_failed" });
}
try
{
CreatorFeatureDB.InventionRecord record =
CreatorFeatureDB.SaveInvention(accountId.Value, request);
Console.WriteLine(
$"[INVENTIONS] saved={record.InventionId} " +
$"creator={accountId.Value} room={record.RoomId} " +
$"version={record.Version}");
bool hasValidObjects = CreatorFeatureDB.HasValidInventionBlob(record);
object clientValue = CreatorFeatureDB.ToClientInvention(record);
if (!hasValidObjects)
{
Console.WriteLine(
$"[INVENTIONS] missing-object-blob id={record.InventionId} " +
$"creator={record.CreatorAccountId} dataBlob={record.DataBlob}");
}
object? clientVersion = CreatorFeatureDB.ToClientInventionVersion(record);
return Ok(new
{
Result = hasValidObjects ? 0 : 8,
InventionResponse = clientValue,
InventionVersionResponse = clientVersion,
InventionId = record.InventionId,
Id = record.InventionId,
Version = record.Version,
Success = hasValidObjects,
CanSpawn = hasValidObjects,
Error = hasValidObjects ? null : "invention_object_blob_missing",
Value = clientValue,
Invention = clientValue,
CurrentVersion = clientVersion
});
}
catch (UnauthorizedAccessException exception)
{
DeleteNewInventionFiles(createdFiles);
return StatusCode(
StatusCodes.Status403Forbidden,
new { Success = false, Error = exception.Message });
}
catch (ArgumentException exception)
{
DeleteNewInventionFiles(createdFiles);
return BadRequest(new { Success = false, Error = exception.Message });
}
}
private static JsonElement MergeInventionQuery(
JsonElement request,
IQueryCollection query)
{
if (query.Count == 0)
return request;
JsonObject payload = request.ValueKind == JsonValueKind.Object
? JsonNode.Parse(request.GetRawText())?.AsObject() ?? new JsonObject()
: new JsonObject { ["Value"] = request.GetRawText() };
foreach (KeyValuePair<string, Microsoft.Extensions.Primitives.StringValues> field
in query)
{
if (payload.ContainsKey(field.Key) && payload[field.Key] != null)
continue;
if (field.Value.Count == 1)
payload[field.Key] = field.Value[0];
else if (field.Value.Count > 1)
payload[field.Key] = new JsonArray(
field.Value.Select(value => JsonValue.Create(value)).ToArray());
}
MergeNestedInventionMetadata(payload);
return JsonSerializer.SerializeToElement(payload);
}
private static JsonElement NormalizeInventionRequest(
JsonElement request,
ICollection<string> createdFiles)
{
JsonObject payload = request.ValueKind == JsonValueKind.Object
? JsonNode.Parse(request.GetRawText())?.AsObject() ?? new JsonObject()
: new JsonObject { ["Value"] = request.GetRawText() };
MergeNestedInventionMetadata(payload);
PromoteUploadedInventionBlob(payload, createdFiles);
return JsonSerializer.SerializeToElement(payload);
}
private static void MergeNestedInventionMetadata(JsonObject payload)
{
foreach (string key in new[]
{
"invention", "Invention", "metadata", "Metadata",
"inventionData", "InventionData",
"inventionMetadata", "InventionMetadata",
"request", "Request", "payload", "Payload",
"data", "Data", "value", "Value", "json", "Json"
})
{
if (!payload.TryGetPropertyValue(key, out JsonNode? node) || node == null)
continue;
JsonObject? nested = node as JsonObject;
if (nested == null && node is JsonValue value &&
value.TryGetValue<string>(out string? raw) &&
!string.IsNullOrWhiteSpace(raw))
{
try
{
nested = JsonNode.Parse(raw) as JsonObject;
}
catch (JsonException)
{
}
}
if (nested == null)
continue;
foreach (KeyValuePair<string, JsonNode?> property in nested.ToArray())
{
if (!payload.ContainsKey(property.Key) || payload[property.Key] == null)
payload[property.Key] = property.Value?.DeepClone();
}
}
}
private static void PromoteUploadedInventionBlob(
JsonObject payload,
ICollection<string> createdFiles)
{
string? raw = ReadJsonNodeString(
payload,
"DataBlob", "dataBlob", "DataBlobName", "dataBlobName",
"Filename", "filename", "FileName", "fileName",
"ObjectDataFilename", "objectDataFilename",
"ObjectDataBlob", "objectDataBlob",
"BlobName", "blobName");
if (string.IsNullOrWhiteSpace(raw))
return;
string normalized = raw.Replace('\\', '/').Trim();
if (normalized.StartsWith("/cdn/", StringComparison.OrdinalIgnoreCase))
normalized = normalized[5..];
normalized = normalized.TrimStart('/');
if (normalized.Contains("..", StringComparison.Ordinal))
throw new InvalidDataException("The invention blob path is invalid.");
string safeName = Path.GetFileName(normalized);
if (safeName.Length == 0 || safeName.Length > 180)
throw new InvalidDataException("The invention blob name is invalid.");
string baseName = Path.GetFileNameWithoutExtension(safeName);
if (string.IsNullOrWhiteSpace(baseName))
baseName = safeName;
string canonicalName = safeName.EndsWith(
".inv",
StringComparison.OrdinalIgnoreCase)
? safeName
: baseName + ".inv";
string inventionDirectory = Path.Combine(Program.dataDir, "CDN", "invention");
Directory.CreateDirectory(inventionDirectory);
string inventionPath = Path.Combine(inventionDirectory, canonicalName);
string existingInventionPath = Path.Combine(inventionDirectory, safeName);
string uploadedRoomPath = Path.Combine(Program.dataDir, "CDN", "room", safeName);
string uploadedCanonicalRoomPath = Path.Combine(
Program.dataDir,
"CDN",
"room",
canonicalName);
string? sourcePath = new[]
{
existingInventionPath,
uploadedRoomPath,
uploadedCanonicalRoomPath
}
.FirstOrDefault(System.IO.File.Exists);
if (!System.IO.File.Exists(inventionPath) && sourcePath != null)
{
System.IO.File.Copy(sourcePath, inventionPath, overwrite: false);
createdFiles.Add(inventionPath);
}
string storedPath = "invention/" + canonicalName;
payload["DataBlob"] = storedPath;
payload["dataBlob"] = storedPath;
payload["DataBlobName"] = canonicalName;
payload["Filename"] = canonicalName;
payload["ObjectDataFilename"] = canonicalName;
payload["ObjectDataBlob"] = canonicalName;
}
private static string? ReadJsonNodeString(
JsonObject payload,
params string[] keys)
{
foreach (string key in keys)
{
if (!payload.TryGetPropertyValue(key, out JsonNode? node) || node == null)
continue;
if (node is JsonValue value && value.TryGetValue<string>(out string? text))
return text;
string raw = node.ToJsonString().Trim();
if (raw.Length > 1 && raw[0] == '"' && raw[^1] == '"')
{
try
{
return JsonSerializer.Deserialize<string>(raw);
}
catch (JsonException)
{
}
}
return raw;
}
return null;
}
private static bool IsInventionDataFile(IFormFile file)
{
string fieldName = file.Name ?? string.Empty;
if (fieldName.Contains("object", StringComparison.OrdinalIgnoreCase) ||
fieldName.Contains("blob", StringComparison.OrdinalIgnoreCase) ||
fieldName.Contains("data", StringComparison.OrdinalIgnoreCase) ||
fieldName.Equals("file", StringComparison.OrdinalIgnoreCase) ||
fieldName.Contains("invention", StringComparison.OrdinalIgnoreCase))
{
return true;
}
string extension = Path.GetExtension(file.FileName);
return !extension.Equals(".png", StringComparison.OrdinalIgnoreCase) &&
!extension.Equals(".jpg", StringComparison.OrdinalIgnoreCase) &&
!extension.Equals(".jpeg", StringComparison.OrdinalIgnoreCase) &&
!extension.Equals(".webp", StringComparison.OrdinalIgnoreCase) &&
!extension.Equals(".gif", StringComparison.OrdinalIgnoreCase) &&
!extension.Equals(".bmp", StringComparison.OrdinalIgnoreCase);
}
private static bool IsInventionPreviewFile(IFormFile file)
{
string fieldName = file.Name ?? string.Empty;
if (fieldName.Contains("preview", StringComparison.OrdinalIgnoreCase) ||
fieldName.Contains("thumbnail", StringComparison.OrdinalIgnoreCase) ||
fieldName.Contains("image", StringComparison.OrdinalIgnoreCase) ||
fieldName.Contains("photo", StringComparison.OrdinalIgnoreCase) ||
fieldName.Contains("icon", StringComparison.OrdinalIgnoreCase))
{
return true;
}
if (file.ContentType?.StartsWith(
"image/",
StringComparison.OrdinalIgnoreCase) == true)
{
return true;
}
string extension = Path.GetExtension(file.FileName);
return extension.Equals(".png", StringComparison.OrdinalIgnoreCase) ||
extension.Equals(".jpg", StringComparison.OrdinalIgnoreCase) ||
extension.Equals(".jpeg", StringComparison.OrdinalIgnoreCase) ||
extension.Equals(".webp", StringComparison.OrdinalIgnoreCase) ||
extension.Equals(".gif", StringComparison.OrdinalIgnoreCase) ||
extension.Equals(".bmp", StringComparison.OrdinalIgnoreCase);
}
private static async Task<string> SaveInventionPreviewAsync(
IFormFile file,
ICollection<string> createdFiles,
CancellationToken cancellationToken)
{
const long maxPreviewBytes = 8L * 1024L * 1024L;
if (file.Length <= 0 || file.Length > maxPreviewBytes)
throw new InvalidDataException("Invention previews must be 8 MB or smaller.");
byte[] bytes;
await using (var buffer = new MemoryStream())
{
await file.CopyToAsync(buffer, cancellationToken);
bytes = buffer.ToArray();
}
SixLabors.ImageSharp.ImageInfo? info = Image.Identify(bytes);
SixLabors.ImageSharp.Formats.IImageFormat? format = Image.DetectFormat(bytes);
if (info == null || format == null ||
info.Width <= 0 || info.Height <= 0 ||
info.Width > 8192 || info.Height > 8192 ||
(long)info.Width * info.Height > 40_000_000)
{
throw new InvalidDataException("The invention preview is not a safe image.");
}
string extension = format.FileExtensions
.Select(value => value.Trim().TrimStart('.').ToLowerInvariant())
.FirstOrDefault(value => value.Length is > 0 and <= 8 &&
value.All(char.IsLetterOrDigit)) ?? "png";
string filename = $"{Guid.NewGuid():N}.{extension}";
string relativePath = $"Inventions/{filename}";
string directory = Path.Combine(Program.dataDir, "Images", "Inventions");
Directory.CreateDirectory(directory);
string path = Path.Combine(directory, filename);
await System.IO.File.WriteAllBytesAsync(path, bytes, cancellationToken);
createdFiles.Add(path);
return relativePath;
}
private static async Task<string> SaveInventionDataAsync(
IFormFile file,
ICollection<string> createdFiles,
CancellationToken cancellationToken)
{
const long maxDataBytes = 9L * 1024L * 1024L;
if (file.Length <= 0 || file.Length > maxDataBytes)
throw new InvalidDataException("Invention data must be 9 MB or smaller.");
string filename = $"{Guid.NewGuid():N}.inv";
string directory = Path.Combine(Program.dataDir, "CDN", "invention");
Directory.CreateDirectory(directory);
string path = Path.Combine(directory, filename);
await using (var output = new FileStream(
path,
FileMode.CreateNew,
FileAccess.Write,
FileShare.None,
81_920,
FileOptions.Asynchronous | FileOptions.SequentialScan))
{
await file.CopyToAsync(output, cancellationToken);
}
createdFiles.Add(path);
return $"invention/{filename}";
}
private static void DeleteNewInventionFiles(IEnumerable<string> paths)
{
foreach (string path in paths)
{
try
{
if (System.IO.File.Exists(path))
System.IO.File.Delete(path);
}
catch (IOException)
{
}
catch (UnauthorizedAccessException)
{
}
}
}
[HttpGet("api/customAvatarItems/v1/hot")]
public IActionResult GetHotCustomAvatarItems()
{
var items = new[]
{
new { itemId = 101, name = "Fire Hoodie", tags = new[] { "hot", "new" } },
new { itemId = 102, name = "Drip Sneakers", tags = new[] { "hot" } },
new { itemId = 103, name = "Sus Hat", tags = new[] { "trending" } }
};
return Ok(items);
}
[HttpGet("/config/categories")]
public IActionResult GetConfigCategories()
{
string path = Path.Combine(Program.dataDir, "APIS", "GameConfigs.json");
if (!System.IO.File.Exists(path))
return Ok(new { Categories = Array.Empty<object>() });
string json = System.IO.File.ReadAllText(path);
var entries = JsonSerializer.Deserialize<List<Dictionary<string, object>>>(json, new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
if (entries == null)
return Ok(new { Categories = Array.Empty<object>() });
var categoryEntry = entries.FirstOrDefault(entry => entry.TryGetValue("Key", out var key) && key?.ToString() == "Growth.RoomCategoriesJson");
if (categoryEntry == null || !categoryEntry.TryGetValue("Value", out var value) || value == null)
return Ok(new { Categories = Array.Empty<object>() });
try
{
JsonElement categories = JsonSerializer.Deserialize<JsonElement>(
value.ToString() ?? "[]");
return Ok(new { Categories = categories });
}
catch
{
return Ok(new { Categories = Array.Empty<object>() });
}
}
[HttpGet("/api/PlayerCheer/v1/images")]
public IActionResult GetPlayerCheersForImages([FromQuery] List<string> imageIds)
{
var id = AuthStuff.GetPlayerId(Request);
if (id == null)
return Unauthorized();
return Ok(new Dictionary<string, object>());
}
[HttpPost("/api/PlayerCheer/v1/create")]
[RequestSizeLimit(64 * 1024)]
public async Task<IActionResult> CreatePlayerCheer()
{
var id = AuthStuff.GetPlayerId(Request);
if (id == null)
return Unauthorized();
var values = new Dictionary<string, string>(
StringComparer.OrdinalIgnoreCase);
foreach (var queryValue in Request.Query)
values[queryValue.Key] = queryValue.Value.ToString();
if (Request.HasFormContentType)
{
var form = await Request.ReadFormAsync();
foreach (var formValue in form)
values[formValue.Key] = formValue.Value.ToString();
}
else
{
string body;
using (var reader = new StreamReader(Request.Body))
body = await reader.ReadToEndAsync();
if (string.IsNullOrWhiteSpace(body))
body = string.Empty;
try
{
if (!string.IsNullOrWhiteSpace(body))
{
using var document = JsonDocument.Parse(body);
AddJsonValues(document.RootElement, values);
}
}
catch (JsonException)
{
foreach (var bodyValue in
Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(
"?" + body))
{
values[bodyValue.Key] = bodyValue.Value.ToString();
}
}
}
foreach (string embeddedValue in values.Values.ToArray())
{
if (!embeddedValue.TrimStart().StartsWith('{'))
continue;
try
{
using var embedded = JsonDocument.Parse(embeddedValue);
AddJsonValues(embedded.RootElement, values);
}
catch (JsonException)
{
}
}
string? targetText = GetValue(values,
"ReceiverAccountId", "ReceiverPlayerId", "ReceiverId",
"CheerReceiverAccountId", "CheerReceiverPlayerId",
"CheeredAccountId", "CheeredPlayerId", "PlayerToCheerId",
"TargetAccountId", "TargetPlayerId", "ToAccountId",
"ToPlayerId", "PlayerId", "PlayerID", "AccountId", "id");
string? categoryText = GetValue(values,
"CheerCategory", "CheerCategoryId", "CheerType",
"Category", "CategoryId", "cheer");
string? anonymousText = GetValue(values,
"Anonymous", "anonymous", "IsAnonymous", "isAnonymous",
"SendAnonymously", "sendAnonymously");
if (!long.TryParse(targetText, out long receiverId))
{
var targetValue = values.FirstOrDefault(value =>
(value.Key.Contains("player", StringComparison.OrdinalIgnoreCase) ||
value.Key.Contains("account", StringComparison.OrdinalIgnoreCase) ||
value.Key.Contains("receiver", StringComparison.OrdinalIgnoreCase) ||
value.Key.Contains("target", StringComparison.OrdinalIgnoreCase)) &&
long.TryParse(value.Value, out long candidate) &&
candidate != (long)id);
if (!long.TryParse(targetValue.Value, out receiverId))
return BadRequest(new { success = false, error = "invalid_player" });
}
if (!TryParseCheerCategory(categoryText, out CheerCategory category))
category = CheerCategory.General;
bool anonymous = ParseLooseBoolean(anonymousText);
var cheer = PlayerDB.GivePlayerCheer(
(long)id,
receiverId,
category,
out NotificationDB.ClientNotification? notification,
anonymous);
if (cheer != null && notification != null)
{
await NotiController.NotifyCheerAsync(
receiverId,
notification,
(int)category);
}
Console.WriteLine(
$"[PLAYER CHEER] sender={id.Value} receiver={receiverId} " +
$"category={category} anonymous={anonymous} " +
$"created={cheer != null}");
return cheer == null
? BadRequest(new
{
success = false,
message = "Unable to send cheer. Check the player and your available cheers.",
error = "invalid_player_or_no_cheer_credit"
})
: Ok(new
{
success = true,
message = "Cheer sent.",
cheerId = cheer.CheerId,
notificationId = cheer.NotificationId,
remainingCheerCredits = PlayerDB
.GetReputationBulk(new List<long> { id.Value })
.FirstOrDefault()?.CheerCredit ?? 0
});
}
[HttpGet("/api/PlayerCheer/v1/pending")]
public IActionResult GetPendingPlayerCheers()
{
long? playerId = AuthStuff.GetPlayerId(Request);
if (!playerId.HasValue)
return Unauthorized();
return Ok(PlayerDB.GetPendingPlayerCheers(playerId.Value)
.Select(cheer => new
{
cheerId = cheer.CheerId,
notificationId = cheer.NotificationId,
fromPlayerId = cheer.Anonymous
? 0
: cheer.GiverPlayerId,
receiverPlayerId = cheer.ReceiverPlayerId,
cheerCategory = (int)cheer.CheerCategory,
anonymous = cheer.Anonymous,
status = (int)cheer.Status,
createdAt = cheer.CreatedAt
}));
}
[HttpPost("/api/PlayerCheer/v1/accept")]
[RequestSizeLimit(16 * 1024)]
public Task<IActionResult> AcceptPlayerCheer() =>
ResolvePlayerCheerAsync(CheerStatus.Accepted);
[HttpPost("/api/PlayerCheer/v1/dismiss")]
[RequestSizeLimit(16 * 1024)]
public Task<IActionResult> DismissPlayerCheer() =>
ResolvePlayerCheerAsync(CheerStatus.Dismissed);
private async Task<IActionResult> ResolvePlayerCheerAsync(
CheerStatus status)
{
long? playerId = AuthStuff.GetPlayerId(Request);
if (!playerId.HasValue)
return Unauthorized();
HashSet<long> ids = await ReadLongValuesAsync(
"CheerIds", "cheerIds", "CheerId", "cheerId",
"NotificationIds", "notificationIds",
"NotificationId", "notificationId",
"MessageIds", "messageIds", "MessageId", "messageId",
"Id", "id");
if (ids.Count == 0)
{
return BadRequest(new
{
success = false,
error = "missing_cheer_id"
});
}
List<PlayerCheerRecord> matching =
PlayerDB.GetPendingPlayerCheers(playerId.Value)
.Where(cheer =>
ids.Contains(cheer.CheerId) ||
ids.Contains(cheer.NotificationId))
.ToList();
int resolved = PlayerDB.ResolvePlayerCheers(
playerId.Value,
ids,
status);
int deleted = NotificationDB.DeleteMessages(
playerId.Value,
matching.Select(cheer => cheer.NotificationId));
return Ok(new
{
success = resolved > 0,
resolved,
deleted,
status = status.ToString()
});
}
[HttpPost("/api/PlayerCheer/v1/SetSelectedCheer")]
[RequestSizeLimit(16 * 1024)]
public async Task<IActionResult> SetSelectedCheer()
{
var player = AuthStuff.GetCurrentPlayer(Request);
if (player?.Player == null)
return Unauthorized();
string? categoryText = Request.Query["CheerCategory"].FirstOrDefault();
if (Request.HasFormContentType)
{
var form = await Request.ReadFormAsync();
categoryText ??= form["CheerCategory"].FirstOrDefault();
}
if (!int.TryParse(categoryText, out int rawCategory) ||
!Enum.IsDefined(typeof(CheerCategory), rawCategory))
return BadRequest();
bool isDeveloper = player.PlayerRoles?.Contains(
PlayerDBClasses.PlayerRoles.Developer) == true;
if (!isDeveloper &&
rawCategory == (int)CheerCategory.RecRoomDeveloper)
return StatusCode(403);
player.Player.Reputation ??= new Reputation();
player.Player.Reputation.IsCheerful = true;
player.Player.Reputation.SelectedCheer = (CheerCategory)rawCategory;
PlayerDB.Players.Update(player);
return NoContent();
}
private static string? GetValue(
Dictionary<string, string> values,
params string[] keys)
{
foreach (string key in keys)
{
if (values.TryGetValue(key, out string? value) &&
!string.IsNullOrWhiteSpace(value))
return value;
}
return null;
}
private static void AddJsonValues(
JsonElement element,
Dictionary<string, string> values)
{
if (element.ValueKind == JsonValueKind.Object)
{
foreach (var property in element.EnumerateObject())
{
if (property.Value.ValueKind is JsonValueKind.Object or
JsonValueKind.Array)
{
AddJsonValues(property.Value, values);
}
else
{
values[property.Name] = property.Value.ToString();
}
}
return;
}
if (element.ValueKind == JsonValueKind.Array)
{
foreach (var item in element.EnumerateArray())
AddJsonValues(item, values);
}
}
private static bool TryParseCheerCategory(
string? value,
out CheerCategory category)
{
category = CheerCategory.General;
if (string.IsNullOrWhiteSpace(value))
return false;
if (int.TryParse(value, out int rawCategory) &&
Enum.IsDefined(typeof(CheerCategory), rawCategory))
{
category = (CheerCategory)rawCategory;
return category is CheerCategory.General or
CheerCategory.Helpful or
CheerCategory.Sportmanship or
CheerCategory.GreatHost or
CheerCategory.Creative;
}
string normalized = value.Replace(" ", string.Empty)
.Replace("Sportsmanship", "Sportmanship",
StringComparison.OrdinalIgnoreCase)
.Replace("GoodSport", "Sportmanship",
StringComparison.OrdinalIgnoreCase);
return Enum.TryParse(normalized, true, out category) &&
category is CheerCategory.General or
CheerCategory.Helpful or
CheerCategory.Sportmanship or
CheerCategory.GreatHost or
CheerCategory.Creative;
}
private static bool ParseLooseBoolean(string? value)
{
if (bool.TryParse(value, out bool parsed))
return parsed;
return string.Equals(value, "1", StringComparison.Ordinal) ||
string.Equals(value, "yes", StringComparison.OrdinalIgnoreCase) ||
string.Equals(value, "on", StringComparison.OrdinalIgnoreCase);
}
[HttpGet("/api/Leaderboard/v1/room/{roomId}")]
public IActionResult GetRoomLeaderboard(long roomId)
{
return Ok(new
{
RoomId = roomId,
Results = Array.Empty<object>(),
TotalResults = 0
});
}
[HttpGet("/api/catalog/v1/all")]
public IActionResult GetCatalogAll(
[FromQuery] bool onlyAvailableSkus = false)
{
return Ok(GetCatalogSkus());
}
[HttpPost("/api/storefronts/v1/purchase")]
[HttpPost("/api/storefronts/v1/purchase/{skuId:long}")]
[HttpPost("/api/storefronts/v2/purchase")]
[HttpPost("/api/storefronts/v2/purchase/{skuId:long}")]
[HttpPost("/api/storefronts/v2/buyItem")]
[HttpPost("/api/storefronts/v3/purchase")]
[HttpPost("/api/storefronts/v3/purchase/{skuId:long}")]
[HttpPost("/api/storefronts/v4/purchase")]
[HttpPost("/api/storefronts/v4/purchase/{skuId:long}")]
[HttpPost("/api/catalog/v1/purchase")]
[HttpPost("/api/catalog/v1/purchase/{skuId:long}")]
[HttpPost("/purchase/v1/purchase")]
[RequestSizeLimit(128 * 1024)]
public async Task<IActionResult> PurchaseCatalogItem()
{
long? accountId = AuthStuff.GetPlayerId(Request);
if (accountId == null)
return Unauthorized();
ParsedCatalogPurchaseRequest purchaseRequest =
await ReadCatalogPurchaseRequestAsync();
purchaseRequest.Message = purchaseRequest.Message.Trim();
if (purchaseRequest.Message.Length > 200)
{
return BadRequest(new
{
Success = false,
Error = "gift_message_too_long"
});
}
long? requestedSkuId = purchaseRequest.SkuId;
Console.WriteLine(
$"[STORE PURCHASE] buyer={accountId.Value} " +
$"sku={requestedSkuId?.ToString() ?? "null"} " +
$"recipient={purchaseRequest.RecipientPlayerId?.ToString() ?? "none"} " +
$"giftContext={purchaseRequest.GiftContext}");
if (!requestedSkuId.HasValue)
return BadRequest(new { Success = false, Error = "missing_sku" });
CatalogSku? sku = GetCatalogSkus().FirstOrDefault(value =>
value.SkuId == requestedSkuId.Value ||
value.PurchasableItemId == requestedSkuId.Value ||
(value.AvatarItemId > 0 && value.AvatarItemId == requestedSkuId.Value) ||
(IsConsumableCatalogItem(value) && value.ItemId == requestedSkuId.Value));
if (sku == null)
return NotFound(new { Success = false, Error = "sku_not_found" });
long recipientId = purchaseRequest.RecipientPlayerId.GetValueOrDefault();
bool isGift = recipientId > 0 && recipientId != accountId.Value;
if (isGift)
{
if (recipientId > int.MaxValue ||
PlayerDB.Players.FindById(recipientId)?.Player == null)
{
return NotFound(new
{
Success = false,
Error = "gift_recipient_not_found"
});
}
bool gifted = TryPurchaseCatalogSkuAsGift(
accountId.Value,
recipientId,
sku,
purchaseRequest.Message,
purchaseRequest.GiftContext,
out int giftBalance,
out bool recipientAlreadyOwned,
out GiftPackage? giftPackage);
if (!gifted)
{
return BadRequest(new
{
Success = false,
Error = "insufficient_funds",
Balance = giftBalance,
Price = StoreItemPrice
});
}
if (giftPackage != null)
{
await NotiController.NotifyGiftAsync(
accountId.Value,
recipientId,
giftPackage);
}
Console.WriteLine(
$"[STORE GIFT] from={accountId.Value} to={recipientId} " +
$"sku={sku.SkuId} item={sku.FriendlyName} " +
$"package={giftPackage?.GiftPackageId ?? 0} " +
$"alreadyOwned={recipientAlreadyOwned}");
return Ok(new
{
Success = true,
Result = 0,
PurchaseResult = 0,
WasGift = true,
RecipientPlayerId = recipientId,
GiftPackageId = giftPackage?.GiftPackageId ?? 0,
AlreadyOwned = recipientAlreadyOwned,
Balance = giftBalance,
CurrencyType = (int)PlayerDBClasses.CurrencyType.RecCenterTokens,
BalanceType = (int)PlayerDBClasses.BalanceType.NonPurchasedDefault,
NewBalance = giftBalance,
Price = StoreItemPrice,
SkuId = sku.SkuId,
PurchasableItemId = sku.PurchasableItemId,
ItemId = sku.ItemId,
AvatarItemId = sku.AvatarItemId,
ConsumableItemDesc = sku.ConsumableItemDesc,
BalanceUpdates = PlayerDB.GetAllCurrencyBalances(accountId.Value)
.Select(entry => new
{
Result = 0,
PurchaseResult = 0,
Balance = entry.CurrencyType == PlayerDBClasses.CurrencyType.RecCenterTokens
? giftBalance
: entry.Balance,
CurrencyType = (int)entry.CurrencyType,
Data = entry.CurrencyType == PlayerDBClasses.CurrencyType.RecCenterTokens
? new object[]
{
new
{
SkuId = sku.SkuId,
ItemId = sku.ItemId,
AvatarItemId = sku.AvatarItemId,
PurchasableItemId = sku.PurchasableItemId,
ConsumableItemDesc = sku.ConsumableItemDesc,
AvatarItemDesc = sku.AvatarItemDesc,
FriendlyName = sku.FriendlyName,
Quantity = 1,
Amount = 1,
CurrencyType = (int)PlayerDBClasses.CurrencyType.RecCenterTokens,
Result = 0
}
}
: Array.Empty<object>()
})
.ToArray()
});
}
bool purchased = TryPurchaseCatalogSku(
accountId.Value,
sku,
out int newBalance,
out bool alreadyOwned);
if (!purchased)
{
return BadRequest(new
{
Success = false,
Error = "insufficient_funds",
Balance = newBalance,
Price = StoreItemPrice
});
}
return Ok(new
{
Success = true,
Result = 0,
PurchaseResult = 0,
WasGift = false,
AlreadyOwned = alreadyOwned,
Balance = newBalance,
CurrencyType = (int)PlayerDBClasses.CurrencyType.RecCenterTokens,
BalanceType = (int)PlayerDBClasses.BalanceType.NonPurchasedDefault,
NewBalance = newBalance,
Price = StoreItemPrice,
SkuId = sku.SkuId,
PurchasableItemId = sku.PurchasableItemId,
ItemId = sku.ItemId,
AvatarItemId = sku.AvatarItemId,
ConsumableItemDesc = sku.ConsumableItemDesc,
BalanceUpdates = PlayerDB.GetAllCurrencyBalances(accountId.Value)
.Select(entry => new
{
Result = 0,
PurchaseResult = 0,
Balance = entry.CurrencyType == PlayerDBClasses.CurrencyType.RecCenterTokens
? newBalance
: entry.Balance,
CurrencyType = (int)entry.CurrencyType,
Data = entry.CurrencyType == PlayerDBClasses.CurrencyType.RecCenterTokens
? new object[]
{
new
{
SkuId = sku.SkuId,
ItemId = sku.ItemId,
AvatarItemId = sku.AvatarItemId,
PurchasableItemId = sku.PurchasableItemId,
ConsumableItemDesc = sku.ConsumableItemDesc,
AvatarItemDesc = sku.AvatarItemDesc,
FriendlyName = sku.FriendlyName,
Quantity = 1,
Amount = 1,
CurrencyType = (int)PlayerDBClasses.CurrencyType.RecCenterTokens,
Result = 0
}
}
: Array.Empty<object>()
})
.ToArray()
});
}
private sealed class ParsedCatalogPurchaseRequest
{
public long? SkuId { get; set; }
public long? RecipientPlayerId { get; set; }
public string Message { get; set; } = string.Empty;
public int GiftContext { get; set; }
}
private async Task<ParsedCatalogPurchaseRequest>
ReadCatalogPurchaseRequestAsync()
{
string[] skuNames =
{
"skuId", "sku", "id", "itemId",
"avatarItemId", "purchasableItemId"
};
string[] recipientNames =
{
"giftRecipientPlayerId", "giftRecipientAccountId",
"recipientPlayerId", "recipientAccountId", "recipientId",
"receiverPlayerId", "receiverAccountId",
"toPlayerId", "toAccountId",
"targetPlayerId", "targetAccountId",
"giftToPlayerId", "giftToAccountId", "giftPlayerId"
};
string[] messageNames =
{
"giftMessage", "message", "note"
};
string[] contextNames =
{
"giftContext", "context"
};
var result = new ParsedCatalogPurchaseRequest();
if (Request.RouteValues.TryGetValue("skuId", out object? routeValue) &&
long.TryParse(routeValue?.ToString(), out long routeSkuId))
{
result.SkuId = routeSkuId;
}
result.SkuId ??= ReadLongFromValues(Request.Query, skuNames);
result.RecipientPlayerId ??=
ReadLongFromValues(Request.Query, recipientNames);
result.Message = ReadStringFromValues(Request.Query, messageNames) ??
result.Message;
result.GiftContext =
(int)(ReadLongFromValues(Request.Query, contextNames) ??
result.GiftContext);
if (Request.HasFormContentType)
{
var form = await Request.ReadFormAsync();
result.SkuId ??= ReadLongFromValues(form, skuNames);
result.RecipientPlayerId ??=
ReadLongFromValues(form, recipientNames);
result.Message = ReadStringFromValues(form, messageNames) ??
result.Message;
result.GiftContext =
(int)(ReadLongFromValues(form, contextNames) ??
result.GiftContext);
}
else if (Request.Body.CanRead &&
(Request.ContentLength.GetValueOrDefault() > 0 ||
Request.Headers.TransferEncoding.Count > 0))
{
try
{
using JsonDocument document =
await JsonDocument.ParseAsync(Request.Body);
JsonElement root = document.RootElement;
result.SkuId ??= FindLongByNames(root, skuNames);
result.RecipientPlayerId ??=
FindLongByNames(root, recipientNames);
result.RecipientPlayerId ??=
FindGiftRecipientFromNestedObject(root);
result.Message = FindStringByNames(root, messageNames) ??
result.Message;
long? giftContext = FindLongByNames(root, contextNames);
if (giftContext.HasValue)
result.GiftContext = checked((int)giftContext.Value);
}
catch (JsonException exception)
{
Console.WriteLine(
$"[STORE PURCHASE] invalid request JSON: {exception.Message}");
}
}
return result;
}
private static long? ReadLongFromValues(
IEnumerable<KeyValuePair<string, Microsoft.Extensions.Primitives.StringValues>> values,
IEnumerable<string> names)
{
var nameSet = names.ToHashSet(StringComparer.OrdinalIgnoreCase);
foreach (var pair in values)
{
if (nameSet.Contains(pair.Key) &&
long.TryParse(pair.Value.FirstOrDefault(), out long value))
{
return value;
}
}
return null;
}
private static string? ReadStringFromValues(
IEnumerable<KeyValuePair<string, Microsoft.Extensions.Primitives.StringValues>> values,
IEnumerable<string> names)
{
var nameSet = names.ToHashSet(StringComparer.OrdinalIgnoreCase);
foreach (var pair in values)
{
if (!nameSet.Contains(pair.Key))
continue;
string? value = pair.Value.FirstOrDefault();
if (!string.IsNullOrWhiteSpace(value))
return value;
}
return null;
}
private static long? FindLongByNames(
JsonElement element,
IEnumerable<string> names)
{
var nameSet = names.ToHashSet(StringComparer.OrdinalIgnoreCase);
return FindLongByNames(element, nameSet);
}
private static long? FindLongByNames(
JsonElement element,
HashSet<string> names)
{
if (element.ValueKind == JsonValueKind.Object)
{
foreach (JsonProperty property in element.EnumerateObject())
{
if (names.Contains(property.Name))
{
if (property.Value.ValueKind == JsonValueKind.Number &&
property.Value.TryGetInt64(out long numeric))
{
return numeric;
}
if (property.Value.ValueKind == JsonValueKind.String &&
long.TryParse(property.Value.GetString(), out long text))
{
return text;
}
}
long? nested = FindLongByNames(property.Value, names);
if (nested.HasValue)
return nested;
}
}
else if (element.ValueKind == JsonValueKind.Array)
{
foreach (JsonElement child in element.EnumerateArray())
{
long? nested = FindLongByNames(child, names);
if (nested.HasValue)
return nested;
}
}
return null;
}
private static long? FindGiftRecipientFromNestedObject(
JsonElement element,
bool recipientContext = false)
{
if (element.ValueKind == JsonValueKind.Object)
{
foreach (JsonProperty property in element.EnumerateObject())
{
bool nextRecipientContext = recipientContext ||
property.Name.Contains("recipient", StringComparison.OrdinalIgnoreCase) ||
property.Name.Contains("receiver", StringComparison.OrdinalIgnoreCase) ||
property.Name.Contains("giftTo", StringComparison.OrdinalIgnoreCase) ||
string.Equals(property.Name, "to", StringComparison.OrdinalIgnoreCase) ||
string.Equals(property.Name, "target", StringComparison.OrdinalIgnoreCase);
if (recipientContext &&
(string.Equals(property.Name, "PlayerId", StringComparison.OrdinalIgnoreCase) ||
string.Equals(property.Name, "AccountId", StringComparison.OrdinalIgnoreCase)))
{
if (property.Value.ValueKind == JsonValueKind.Number &&
property.Value.TryGetInt64(out long numeric))
{
return numeric;
}
if (property.Value.ValueKind == JsonValueKind.String &&
long.TryParse(property.Value.GetString(), out long text))
{
return text;
}
}
long? nested = FindGiftRecipientFromNestedObject(
property.Value,
nextRecipientContext);
if (nested.HasValue)
return nested;
}
}
else if (element.ValueKind == JsonValueKind.Array)
{
foreach (JsonElement child in element.EnumerateArray())
{
long? nested = FindGiftRecipientFromNestedObject(
child,
recipientContext);
if (nested.HasValue)
return nested;
}
}
return null;
}
private static string? FindStringByNames(
JsonElement element,
IEnumerable<string> names)
{
var nameSet = names.ToHashSet(StringComparer.OrdinalIgnoreCase);
return FindStringByNames(element, nameSet);
}
private static string? FindStringByNames(
JsonElement element,
HashSet<string> names)
{
if (element.ValueKind == JsonValueKind.Object)
{
foreach (JsonProperty property in element.EnumerateObject())
{
if (names.Contains(property.Name) &&
property.Value.ValueKind == JsonValueKind.String)
{
return property.Value.GetString();
}
string? nested = FindStringByNames(property.Value, names);
if (!string.IsNullOrWhiteSpace(nested))
return nested;
}
}
else if (element.ValueKind == JsonValueKind.Array)
{
foreach (JsonElement child in element.EnumerateArray())
{
string? nested = FindStringByNames(child, names);
if (!string.IsNullOrWhiteSpace(nested))
return nested;
}
}
return null;
}
private bool TryPurchaseCatalogSkuAsGift(
long senderAccountId,
long recipientAccountId,
CatalogSku sku,
string? message,
int giftContext,
out int newBalance,
out bool alreadyOwned,
out GiftPackage? giftPackage)
{
newBalance = PlayerDB.GetCurrencyBalance(
senderAccountId,
PlayerDBClasses.CurrencyType.RecCenterTokens);
giftPackage = null;
alreadyOwned = IsAvatarCatalogItem(sku)
? PlayerDB.OwnsAvatarItem(recipientAccountId, sku.AvatarItemDesc)
: IsEquipmentSkin(sku)
? OwnsEquipmentItem(
recipientAccountId,
sku.EquipmentPrefabName,
sku.EquipmentModificationGuid)
: false;
if (alreadyOwned)
return true;
if (!PlayerDB.TrySpendCurrency(
senderAccountId,
PlayerDBClasses.CurrencyType.RecCenterTokens,
StoreItemPrice,
out newBalance))
{
return false;
}
var pendingGift = new GiftPackage
{
FromPlayerId = checked((int)senderAccountId),
Message = message ?? string.Empty,
AvatarItemDesc = sku.AvatarItemDesc,
ConsumableItemDesc = sku.ConsumableItemDesc,
EquipmentPrefabName = sku.EquipmentPrefabName,
EquipmentModificationGuid = sku.EquipmentModificationGuid,
CurrencyType = (int)PlayerDBClasses.CurrencyType.RecCenterTokens,
GiftContext = giftContext == 0
? (int)PlayerDBClasses.GiftContext.Store_RecCenter
: giftContext,
Rarity = sku.Rarity,
Platform = -1,
PlatformMask = sku.PlatformMask,
BalanceType = (int)BalanceType.NonPurchasedNotUsableInP2P,
IsQuery = sku.GiftDrop.IsQuery,
Unique = !IsConsumableCatalogItem(sku)
};
giftPackage = PlayerDB.QueueGiftPackage(
recipientAccountId,
pendingGift);
if (giftPackage != null)
return true;
int? refunded = PlayerDB.SetCurrencyBalance(
senderAccountId,
PlayerDBClasses.CurrencyType.RecCenterTokens,
StoreItemPrice,
add: true);
if (refunded.HasValue)
newBalance = refunded.Value;
return false;
}
private static List<CatalogSku> GetCatalogSkus()
{
string avatarPath = Path.Combine(Program.dataDir, "APIS", "Items", "AvatarItems.json");
string equipmentPath = Path.Combine(Program.dataDir, "APIS", "Items", "Equipment.json");
string consumablesPath = Path.Combine(Program.dataDir, "APIS", "Items", "Consumables.json");
bool hasAvatarCatalog = System.IO.File.Exists(avatarPath);
bool hasEquipmentCatalog = System.IO.File.Exists(equipmentPath);
bool hasConsumableCatalog = System.IO.File.Exists(consumablesPath);
if (!hasAvatarCatalog && !hasEquipmentCatalog && !hasConsumableCatalog)
return new List<CatalogSku>();
DateTime avatarTimestamp = hasAvatarCatalog ? System.IO.File.GetLastWriteTimeUtc(avatarPath) : DateTime.MinValue;
DateTime equipmentTimestamp = hasEquipmentCatalog ? System.IO.File.GetLastWriteTimeUtc(equipmentPath) : DateTime.MinValue;
DateTime consumableTimestamp = hasConsumableCatalog ? System.IO.File.GetLastWriteTimeUtc(consumablesPath) : DateTime.MinValue;
lock (CatalogLock)
{
if (CatalogCache.Count > 0 &&
avatarTimestamp == CatalogCacheTimestampUtc &&
equipmentTimestamp == EquipmentCatalogCacheTimestampUtc &&
consumableTimestamp == ConsumableCatalogCacheTimestampUtc)
return CatalogCache;
var catalog = new List<CatalogSku>();
var jsonOptions = new JsonSerializerOptions { PropertyNameCaseInsensitive = true };
if (hasAvatarCatalog)
{
var avatarItems = JsonSerializer.Deserialize<List<CatalogSourceItem>>(System.IO.File.ReadAllText(avatarPath), jsonOptions) ?? new();
catalog.AddRange(avatarItems.Select(CreateAvatarCatalogSku));
}
if (hasEquipmentCatalog)
{
var equipmentItems = JsonSerializer.Deserialize<List<PlayerDBClasses.EquipmentItem>>(System.IO.File.ReadAllText(equipmentPath), jsonOptions) ?? new();
catalog.AddRange(equipmentItems
.Where(item => !string.IsNullOrWhiteSpace(item.PrefabName) && !string.IsNullOrWhiteSpace(item.ModificationGuid))
.Select(CreateEquipmentCatalogSku));
}
if (hasConsumableCatalog)
{
using JsonDocument document = JsonDocument.Parse(System.IO.File.ReadAllText(consumablesPath));
IEnumerable<JsonElement> consumables = document.RootElement.ValueKind == JsonValueKind.Array
? document.RootElement.EnumerateArray().Select(item => item.Clone())
: Enumerable.Empty<JsonElement>();
catalog.AddRange(consumables.Select(CreateConsumableCatalogSku));
}
CatalogCache = catalog.Where(IsUsableStoreItem)
.GroupBy(GetCatalogIdentity, StringComparer.OrdinalIgnoreCase)
.Select(group => group.First()).ToList();
CatalogCacheTimestampUtc = avatarTimestamp;
EquipmentCatalogCacheTimestampUtc = equipmentTimestamp;
ConsumableCatalogCacheTimestampUtc = consumableTimestamp;
return CatalogCache;
}
}
private static CatalogSku CreateAvatarCatalogSku(
CatalogSourceItem item,
int index)
{
int purchasableItemId = 1_000_000 + index;
string friendlyName = string.IsNullOrWhiteSpace(item.FriendlyName)
? $"Avatar Item {item.AvatarItemId}"
: item.FriendlyName.Trim();
CatalogGiftDrop giftDrop = CreateCatalogGiftDrop(item, index);
return new CatalogSku
{
Id = purchasableItemId,
SkuId = purchasableItemId,
PurchasableItemId = purchasableItemId,
Type = 0,
IsFeatured = true,
ItemId = item.AvatarItemId,
AvatarItemId = item.AvatarItemId,
AvatarItemType = item.AvatarItemType,
ItemType = item.AvatarItemType,
AvatarItemDesc = item.AvatarItemDesc ?? string.Empty,
FriendlyName = friendlyName,
Tooltip = item.Tooltip ?? string.Empty,
ThumbnailImage = item.ThumbnailImage ?? string.Empty,
Rarity = item.Rarity,
PlatformMask = item.PlatformMask,
IsBaseAvatarItem = item.IsBaseAvatarItem,
HasRealName = !string.IsNullOrWhiteSpace(item.FriendlyName),
CreatedAt = item.CreatedAt,
Prices = CreateCatalogPrices(),
GiftDrop = giftDrop,
GiftDrops = new List<CatalogGiftDrop> { giftDrop }
};
}
private static CatalogSku CreateEquipmentCatalogSku(
PlayerDBClasses.EquipmentItem item,
int index)
{
int purchasableItemId = 2_000_000 + index;
string friendlyName = GetEquipmentFriendlyName(item, index);
CatalogGiftDrop giftDrop = CreateCatalogGiftDrop(item, index, friendlyName);
return new CatalogSku
{
Id = purchasableItemId,
SkuId = purchasableItemId,
PurchasableItemId = purchasableItemId,
Type = 0,
IsFeatured = true,
ItemId = purchasableItemId,
AvatarItemId = 0,
AvatarItemType = 0,
ItemType = 0,
AvatarItemDesc = string.Empty,
EquipmentPrefabName = item.PrefabName.Trim(),
EquipmentModificationGuid = item.ModificationGuid.Trim(),
FriendlyName = friendlyName,
Tooltip = item.Tooltip ?? string.Empty,
ThumbnailImage = item.ThumbnailImage ?? string.Empty,
Rarity = item.Rarity,
PlatformMask = item.PlatformMask,
IsBaseAvatarItem = false,
HasRealName = !string.IsNullOrWhiteSpace(item.FriendlyName),
Prices = CreateCatalogPrices(),
GiftDrop = giftDrop,
GiftDrops = new List<CatalogGiftDrop> { giftDrop }
};
}
private static CatalogSku CreateConsumableCatalogSku(JsonElement item, int index)
{
int purchasableItemId = 3_000_000 + index;
string desc = GetJsonString(item, "ConsumableItemDesc", "ConsumableDesc", "ItemDesc", "DescriptionId");
if (string.IsNullOrWhiteSpace(desc))
desc = GetJsonString(item, "FriendlyName", "Name");
string friendlyName = GetJsonString(item, "FriendlyName", "Name");
bool hasRealName = !string.IsNullOrWhiteSpace(friendlyName);
if (string.IsNullOrWhiteSpace(friendlyName))
friendlyName = $"Consumable {index + 1}";
bool isHairDye = IsHairDyeFriendlyName(friendlyName);
int rarity = GetJsonInt(item, "Rarity");
int itemType = GetJsonInt(item, "ConsumableItemType", "ItemType", "Type");
if (isHairDye)
itemType = 1;
var giftDrop = new CatalogGiftDrop
{
GiftDropId = purchasableItemId,
FriendlyName = friendlyName,
Tooltip = GetJsonString(item, "Tooltip", "Description"),
ConsumableItemDesc = desc,
ItemType = itemType,
ConsumableItemType = isHairDye ? 6 : itemType,
Rarity = rarity,
IsQuery = false,
Unique = false,
Level = rarity,
Context = 100010,
ThumbnailImage = GetJsonString(item, "ThumbnailImage", "Thumbnail", "ImageName")
};
return new CatalogSku
{
Id = purchasableItemId,
SkuId = purchasableItemId,
PurchasableItemId = purchasableItemId,
Type = 0,
IsFeatured = true,
ItemId = GetJsonLong(
item,
"ConsumableItemId",
"ItemId",
"Id",
defaultValue: purchasableItemId),
ItemType = itemType,
ConsumableItemType = isHairDye ? 6 : itemType,
ConsumableItemDesc = desc,
FriendlyName = friendlyName,
Tooltip = GetJsonString(item, "Tooltip", "Description"),
ThumbnailImage = giftDrop.ThumbnailImage,
Rarity = rarity,
PlatformMask = GetJsonInt(item, "PlatformMask", defaultValue: -1),
HasRealName = hasRealName,
IsOwned = isHairDye,
Owned = isHairDye,
Purchased = isHairDye,
Prices = CreateCatalogPrices(),
GiftDrop = giftDrop,
GiftDrops = new List<CatalogGiftDrop> { giftDrop }
};
}
private static string GetJsonString(JsonElement item, params string[] names)
{
foreach (JsonProperty property in item.EnumerateObject())
if (names.Any(name => string.Equals(name, property.Name, StringComparison.OrdinalIgnoreCase)))
return property.Value.ValueKind == JsonValueKind.String ? property.Value.GetString() ?? string.Empty : property.Value.ToString();
return string.Empty;
}
private static int GetJsonInt(JsonElement item, string name, int defaultValue = 0) => GetJsonInt(item, new[] { name }, defaultValue);
private static int GetJsonInt(JsonElement item, string name1, string name2, string name3) => GetJsonInt(item, new[] { name1, name2, name3 }, 0);
private static int GetJsonInt(JsonElement item, string[] names, int defaultValue)
{
foreach (JsonProperty property in item.EnumerateObject())
if (names.Any(name => string.Equals(name, property.Name, StringComparison.OrdinalIgnoreCase)))
{
if (property.Value.TryGetInt32(out int value)) return value;
if (int.TryParse(property.Value.ToString(), out value)) return value;
}
return defaultValue;
}
private static long GetJsonLong(JsonElement item, string name1, string name2, string name3, long defaultValue)
{
foreach (JsonProperty property in item.EnumerateObject())
if (property.Name.Equals(name1, StringComparison.OrdinalIgnoreCase) || property.Name.Equals(name2, StringComparison.OrdinalIgnoreCase) || property.Name.Equals(name3, StringComparison.OrdinalIgnoreCase))
{
if (property.Value.TryGetInt64(out long value)) return value;
if (long.TryParse(property.Value.ToString(), out value)) return value;
}
return defaultValue;
}
private static List<CatalogPrice> CreateCatalogPrices() =>
new()
{
new CatalogPrice
{
CurrencyType = (int)PlayerDBClasses.CurrencyType.RecCenterTokens,
Price = StoreItemPrice
}
};
private static string GetEquipmentFriendlyName(
PlayerDBClasses.EquipmentItem item,
int index)
{
if (!string.IsNullOrWhiteSpace(item.FriendlyName))
return item.FriendlyName.Trim();
string prefabName = item.PrefabName?.Trim().Trim('[', ']') ?? string.Empty;
if (string.IsNullOrWhiteSpace(prefabName))
return $"Equipment Skin {index + 1}";
return prefabName.EndsWith("Skin", StringComparison.OrdinalIgnoreCase)
? prefabName
: $"{prefabName} Skin";
}
private async Task<long?> ReadPurchaseSkuIdAsync()
{
string[] names =
{
"skuId",
"sku",
"id",
"itemId",
"avatarItemId",
"purchasableItemId"
};
if (Request.RouteValues.TryGetValue("skuId", out object? routeValue) &&
long.TryParse(routeValue?.ToString(), out long routeSkuId))
return routeSkuId;
foreach (string name in names)
{
if (long.TryParse(Request.Query[name].FirstOrDefault(), out long queryValue))
return queryValue;
}
if (Request.HasFormContentType)
{
var form = await Request.ReadFormAsync();
foreach (string name in names)
{
if (long.TryParse(form[name].FirstOrDefault(), out long formValue))
return formValue;
}
}
else if (Request.Body.CanRead &&
(Request.ContentLength.GetValueOrDefault() > 0 ||
Request.Headers.TransferEncoding.Count > 0))
{
try
{
using JsonDocument document = await JsonDocument.ParseAsync(Request.Body);
return FindSkuId(document.RootElement, names);
}
catch (JsonException)
{
return null;
}
}
return null;
}
private static long? FindSkuId(JsonElement element, string[] names)
{
if (element.ValueKind == JsonValueKind.Object)
{
foreach (JsonProperty property in element.EnumerateObject())
{
if (names.Any(name => string.Equals(
name,
property.Name,
StringComparison.OrdinalIgnoreCase)))
{
if (property.Value.ValueKind == JsonValueKind.Number &&
property.Value.TryGetInt64(out long number))
return number;
if (property.Value.ValueKind == JsonValueKind.String &&
long.TryParse(property.Value.GetString(), out long textNumber))
return textNumber;
}
long? nested = FindSkuId(property.Value, names);
if (nested.HasValue)
return nested;
}
}
else if (element.ValueKind == JsonValueKind.Array)
{
foreach (JsonElement child in element.EnumerateArray())
{
long? nested = FindSkuId(child, names);
if (nested.HasValue)
return nested;
}
}
return null;
}
private sealed class CatalogSourceItem
{
public string? AvatarItemDesc { get; set; }
public int AvatarItemType { get; set; }
public int PlatformMask { get; set; } = -1;
public string? FriendlyName { get; set; }
public string? Tooltip { get; set; }
public int Rarity { get; set; }
public long AvatarItemId { get; set; }
public bool IsBaseAvatarItem { get; set; }
public DateTime CreatedAt { get; set; }
public string? ThumbnailImage { get; set; }
}
private static CatalogGiftDrop CreateCatalogGiftDrop(
CatalogSourceItem item,
int index)
{
return new CatalogGiftDrop
{
GiftDropId = 1_000_000 + index,
FriendlyName = string.IsNullOrWhiteSpace(item.FriendlyName)
? $"Avatar Item {item.AvatarItemId}"
: item.FriendlyName.Trim(),
Tooltip = item.Tooltip ?? string.Empty,
AvatarItemDesc = item.AvatarItemDesc ?? string.Empty,
ConsumableItemDesc = string.Empty,
EquipmentPrefabName = string.Empty,
EquipmentModificationGuid = string.Empty,
Rarity = item.Rarity,
IsQuery = false,
Unique = true,
Level = item.Rarity,
Context = 100010,
ThumbnailImage = item.ThumbnailImage ?? string.Empty
};
}
private static CatalogGiftDrop CreateCatalogGiftDrop(
PlayerDBClasses.EquipmentItem item,
int index,
string friendlyName)
{
return new CatalogGiftDrop
{
GiftDropId = 2_000_000 + index,
FriendlyName = friendlyName,
Tooltip = item.Tooltip ?? string.Empty,
AvatarItemDesc = string.Empty,
ConsumableItemDesc = string.Empty,
EquipmentPrefabName = item.PrefabName?.Trim() ?? string.Empty,
EquipmentModificationGuid = item.ModificationGuid?.Trim() ?? string.Empty,
Rarity = item.Rarity,
IsQuery = false,
Unique = true,
Level = item.Rarity,
Context = 100010,
ThumbnailImage = item.ThumbnailImage ?? string.Empty
};
}
private static List<CatalogSku> GetWatchStoreSkusWithHairDyes()
{
List<CatalogSku> storeItems = GetDailyStoreSkus(3);
var identities = storeItems
.Select(GetCatalogIdentity)
.ToHashSet(StringComparer.OrdinalIgnoreCase);
IEnumerable<CatalogSku> hairDyes = GetCatalogSkus()
.Where(IsHairDyeCatalogItem)
.GroupBy(GetCatalogIdentity, StringComparer.OrdinalIgnoreCase)
.Select(group => group.First())
.OrderBy(item => item.FriendlyName, StringComparer.OrdinalIgnoreCase);
foreach (CatalogSku hairDye in hairDyes)
{
if (identities.Add(GetCatalogIdentity(hairDye)))
storeItems.Add(hairDye);
}
return storeItems;
}
private static bool IsHairDyeFriendlyName(string? friendlyName) =>
!string.IsNullOrWhiteSpace(friendlyName) &&
friendlyName.Contains("Hair Dye", StringComparison.OrdinalIgnoreCase);
private static bool IsHairDyeCatalogItem(CatalogSku? item) =>
item != null &&
IsConsumableCatalogItem(item) &&
IsHairDyeFriendlyName(item.FriendlyName);
private static List<CatalogSku> GetDailyStoreSkus(int storeId)
{
List<CatalogSku> catalog = GetCatalogSkus();
return catalog
.Where(IsUsableStoreItem)
.GroupBy(GetCatalogIdentity, StringComparer.OrdinalIgnoreCase)
.Select(group => group.First())
.OrderBy(item => item.FriendlyName, StringComparer.OrdinalIgnoreCase)
.ToList();
}
private static bool IsEquipmentSkin(CatalogSku? item) =>
item != null &&
!string.IsNullOrWhiteSpace(item.EquipmentPrefabName) &&
!string.IsNullOrWhiteSpace(item.EquipmentModificationGuid);
private static bool IsAvatarCatalogItem(CatalogSku? item) =>
item != null &&
!string.IsNullOrWhiteSpace(item.AvatarItemDesc);
private static bool IsConsumableCatalogItem(CatalogSku? item) =>
item != null && !string.IsNullOrWhiteSpace(item.ConsumableItemDesc);
private static bool IsExcludedAvatarItemName(string? friendlyName) =>
!string.IsNullOrWhiteSpace(friendlyName) &&
(friendlyName.Contains("Custom", StringComparison.OrdinalIgnoreCase) ||
friendlyName.Contains("Owner", StringComparison.OrdinalIgnoreCase) ||
friendlyName.Contains("Shirt", StringComparison.OrdinalIgnoreCase));
private static bool IsUnfinishedCatalogItem(CatalogSku item) =>
item.Rarity <= 0 && !item.HasRealName;
private static bool IsUsableStoreItem(CatalogSku? item) =>
item != null && !string.IsNullOrWhiteSpace(item.FriendlyName) &&
(IsAvatarCatalogItem(item) || IsEquipmentSkin(item) || IsConsumableCatalogItem(item)) &&
!(IsAvatarCatalogItem(item) && IsExcludedAvatarItemName(item.FriendlyName)) &&
!IsUnfinishedCatalogItem(item);
private static string GetCatalogIdentity(CatalogSku item) =>
IsEquipmentSkin(item)
? $"equipment|{item.EquipmentPrefabName}|{item.EquipmentModificationGuid}"
: IsConsumableCatalogItem(item)
? $"consumable|{item.ConsumableItemDesc}"
: $"avatar|{item.AvatarItemDesc}";
private static bool TryPurchaseCatalogSku(
long accountId,
CatalogSku sku,
out int newBalance,
out bool alreadyOwned)
{
if (IsConsumableCatalogItem(sku))
{
alreadyOwned = false;
if (!PlayerDB.TrySpendCurrency(
accountId,
PlayerDBClasses.CurrencyType.RecCenterTokens,
StoreItemPrice,
out newBalance))
{
return false;
}
PlayerInventoryStore.AddConsumable(
accountId,
sku.ConsumableItemDesc,
sku.ItemId,
sku.FriendlyName,
amount: 1);
return true;
}
if (!IsEquipmentSkin(sku))
{
return PlayerDB.TryPurchaseAvatarItem(
accountId,
sku.AvatarItemDesc,
StoreItemPrice,
out newBalance,
out alreadyOwned);
}
lock (EquipmentInventoryLock)
{
newBalance = PlayerDB.GetCurrencyBalance(
accountId,
PlayerDBClasses.CurrencyType.RecCenterTokens);
alreadyOwned = OwnsEquipmentItem(
accountId,
sku.EquipmentPrefabName,
sku.EquipmentModificationGuid);
if (alreadyOwned)
return true;
if (!PlayerDB.TrySpendCurrency(
accountId,
PlayerDBClasses.CurrencyType.RecCenterTokens,
StoreItemPrice,
out newBalance))
{
return false;
}
try
{
if (GrantPurchasedEquipment(accountId, sku))
return true;
}
catch (IOException)
{
}
catch (UnauthorizedAccessException)
{
}
int? refundedBalance = PlayerDB.SetCurrencyBalance(
accountId,
PlayerDBClasses.CurrencyType.RecCenterTokens,
StoreItemPrice,
add: true);
newBalance = refundedBalance ?? newBalance;
return false;
}
}
private static ulong GetDailyStoreSortKey(
CatalogSku item,
DateTime storeDate,
int storeId,
long rotationNonce)
{
string value = $"{storeDate:yyyyMMdd}|{storeId}|{rotationNonce}|{GetCatalogIdentity(item)}|{item.SkuId}";
unchecked
{
const ulong offset = 14695981039346656037UL;
const ulong prime = 1099511628211UL;
ulong hash = offset;
foreach (char character in value)
{
hash ^= character;
hash *= prime;
}
return hash;
}
}
public static StorefrontAdminInfo GetStorefrontAdminInfo()
{
(long rotationNonce, List<long> customSkuIds) = GetStorefrontSelectionState();
Dictionary<long, CatalogSku> catalog = GetCatalogSkus()
.ToDictionary(item => item.SkuId);
List<StorefrontAdminItem> customItems = customSkuIds
.Select(skuId => catalog.GetValueOrDefault(skuId))
.Where(IsUsableStoreItem)
.Cast<CatalogSku>()
.Select(ToStorefrontAdminItem)
.ToList();
return new StorefrontAdminInfo
{
RotationNonce = rotationNonce,
CustomItems = customItems,
NextRefresh = DateTime.Today.AddDays(1).ToUniversalTime()
};
}
public static List<StorefrontAdminItem> GetWebsiteStorefrontItems(
int storeId = 3) =>
GetCatalogSkus()
.Where(IsUsableStoreItem)
.OrderBy(item => IsAvatarCatalogItem(item) ? 0 : IsEquipmentSkin(item) ? 1 : 2)
.ThenBy(item => item.FriendlyName, StringComparer.OrdinalIgnoreCase)
.Select(ToStorefrontAdminItem)
.ToList();
public static bool TryPurchaseWebsiteStoreItem(
long accountId,
long skuId,
out StorefrontAdminItem? purchasedItem,
out int newBalance,
out bool alreadyOwned,
out string error)
{
CatalogSku? sku = GetCatalogSkus().FirstOrDefault(value =>
value.SkuId == skuId ||
value.PurchasableItemId == skuId ||
(value.AvatarItemId > 0 && value.AvatarItemId == skuId) ||
(IsConsumableCatalogItem(value) && value.ItemId == skuId));
if (sku == null)
{
purchasedItem = null;
newBalance = PlayerDB.GetCurrencyBalance(
accountId,
PlayerDBClasses.CurrencyType.RecCenterTokens);
alreadyOwned = false;
error = "That shop item could not be found.";
return false;
}
bool purchased = TryPurchaseCatalogSku(
accountId,
sku,
out newBalance,
out alreadyOwned);
purchasedItem = ToStorefrontAdminItem(sku);
error = purchased
? string.Empty
: "You do not have enough tokens for that item.";
return purchased;
}
public static (List<StorefrontAdminItem> Items, int Total) SearchStorefrontCatalog(
string? search,
string? type = null,
int skip = 0,
int take = 30)
{
string term = search?.Trim() ?? string.Empty;
string requestedType = type?.Trim().ToLowerInvariant() ?? string.Empty;
IEnumerable<StorefrontAdminItem> query = GetCatalogSkus()
.Where(IsUsableStoreItem)
.Where(item => string.IsNullOrWhiteSpace(term) ||
item.FriendlyName.Contains(term, StringComparison.OrdinalIgnoreCase) ||
item.AvatarItemDesc.Contains(term, StringComparison.OrdinalIgnoreCase) ||
item.ConsumableItemDesc.Contains(term, StringComparison.OrdinalIgnoreCase) ||
item.EquipmentPrefabName.Contains(term, StringComparison.OrdinalIgnoreCase) ||
item.EquipmentModificationGuid.Contains(term, StringComparison.OrdinalIgnoreCase) ||
item.AvatarItemId.ToString().Contains(term, StringComparison.OrdinalIgnoreCase) ||
item.SkuId.ToString().Contains(term, StringComparison.OrdinalIgnoreCase))
.Select(ToStorefrontAdminItem)
.Where(item => requestedType switch
{
"avatar" => !string.IsNullOrWhiteSpace(item.AvatarItemDesc),
"equipment" => !string.IsNullOrWhiteSpace(item.EquipmentPrefabName) &&
!string.IsNullOrWhiteSpace(item.EquipmentModificationGuid),
"consumable" => !string.IsNullOrWhiteSpace(item.ConsumableItemDesc),
_ => true
})
.OrderByDescending(item => item.Rarity)
.ThenBy(item => item.FriendlyName, StringComparer.OrdinalIgnoreCase);
int total = query.Count();
List<StorefrontAdminItem> items = query
.Skip(Math.Max(0, skip))
.Take(Math.Clamp(take, 1, 50))
.ToList();
return (items, total);
}
public static StorefrontAdminItem? GetStorefrontCatalogItem(long skuId) =>
GetCatalogSkus()
.Where(IsUsableStoreItem)
.Where(item => item.SkuId == skuId)
.Select(ToStorefrontAdminItem)
.FirstOrDefault();
public static bool IsWebsiteStoreItemOwned(
long accountId,
StorefrontAdminItem item)
{
if (!string.IsNullOrWhiteSpace(item.EquipmentPrefabName) &&
!string.IsNullOrWhiteSpace(item.EquipmentModificationGuid))
{
return OwnsEquipmentItem(
accountId,
item.EquipmentPrefabName,
item.EquipmentModificationGuid);
}
if (!string.IsNullOrWhiteSpace(item.ConsumableItemDesc))
{
return PlayerInventoryStore.GetConsumableQuantity(
accountId,
item.ConsumableItemDesc) > 0;
}
return PlayerDB.OwnsAvatarItem(accountId, item.AvatarItemDesc);
}
public static bool TryAddCustomStoreItem(long skuId, out string error)
{
CatalogSku? item = GetCatalogSkus()
.FirstOrDefault(candidate => candidate.SkuId == skuId);
if (!IsUsableStoreItem(item))
{
error = "That catalog item could not be found.";
return false;
}
lock (StorefrontAdminLock)
{
StorefrontAdminState state = LoadStorefrontAdminState();
if (state.CustomSkuIds.Contains(skuId))
{
error = "That item is already pinned in the shop.";
return false;
}
if (state.CustomSkuIds.Count >= 10)
{
error = "The shop already has the maximum of 10 custom items.";
return false;
}
state.CustomSkuIds.Add(skuId);
SaveStorefrontAdminState(state);
}
error = string.Empty;
return true;
}
public static bool RemoveCustomStoreItem(long skuId)
{
lock (StorefrontAdminLock)
{
StorefrontAdminState state = LoadStorefrontAdminState();
if (!state.CustomSkuIds.Remove(skuId))
return false;
SaveStorefrontAdminState(state);
return true;
}
}
public static long RefreshStorefrontRotation()
{
lock (StorefrontAdminLock)
{
StorefrontAdminState state = LoadStorefrontAdminState();
state.RotationNonce = state.RotationNonce == long.MaxValue
? 1
: state.RotationNonce + 1;
SaveStorefrontAdminState(state);
return state.RotationNonce;
}
}
private static StorefrontAdminItem ToStorefrontAdminItem(CatalogSku item) =>
new()
{
SkuId = item.SkuId,
AvatarItemId = item.AvatarItemId,
AvatarItemType = item.AvatarItemType,
FriendlyName = item.FriendlyName,
AvatarItemDesc = item.AvatarItemDesc,
ConsumableItemDesc = item.ConsumableItemDesc,
EquipmentPrefabName = item.EquipmentPrefabName,
EquipmentModificationGuid = item.EquipmentModificationGuid,
ThumbnailImage = item.ThumbnailImage,
Rarity = item.Rarity,
Price = StoreItemPrice
};
private static (long rotationNonce, List<long> customSkuIds)
GetStorefrontSelectionState()
{
lock (StorefrontAdminLock)
{
StorefrontAdminState state = LoadStorefrontAdminState();
return (state.RotationNonce, state.CustomSkuIds.ToList());
}
}
private static StorefrontAdminState LoadStorefrontAdminState()
{
if (StorefrontAdminCache != null)
return StorefrontAdminCache;
string path = GetStorefrontAdminPath();
try
{
if (System.IO.File.Exists(path))
{
StorefrontAdminCache = JsonSerializer.Deserialize<StorefrontAdminState>(
System.IO.File.ReadAllText(path),
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
}
}
catch (JsonException)
{
StorefrontAdminCache = null;
}
StorefrontAdminCache ??= new StorefrontAdminState();
StorefrontAdminCache.CustomSkuIds ??= new List<long>();
return StorefrontAdminCache;
}
private static void SaveStorefrontAdminState(StorefrontAdminState state)
{
string path = GetStorefrontAdminPath();
string? directory = Path.GetDirectoryName(path);
if (!string.IsNullOrWhiteSpace(directory))
Directory.CreateDirectory(directory);
System.IO.File.WriteAllText(
path,
JsonSerializer.Serialize(state, new JsonSerializerOptions { WriteIndented = true }));
}
private static string GetStorefrontAdminPath() =>
Path.Combine(Program.dataDir, "StorefrontAdmin.json");
private sealed class StorefrontAdminState
{
public long RotationNonce { get; set; }
public List<long> CustomSkuIds { get; set; } = new();
}
public sealed class StorefrontAdminInfo
{
public long RotationNonce { get; set; }
public DateTime NextRefresh { get; set; }
public List<StorefrontAdminItem> CustomItems { get; set; } = new();
}
public sealed class StorefrontAdminItem
{
public long SkuId { get; set; }
public long AvatarItemId { get; set; }
public int AvatarItemType { get; set; }
public string FriendlyName { get; set; } = string.Empty;
public string AvatarItemDesc { get; set; } = string.Empty;
public string ConsumableItemDesc { get; set; } = string.Empty;
public string EquipmentPrefabName { get; set; } = string.Empty;
public string EquipmentModificationGuid { get; set; } = string.Empty;
public string ThumbnailImage { get; set; } = string.Empty;
public int Rarity { get; set; }
public int Price { get; set; }
}
public sealed class CatalogStorefront
{
public int StorefrontType { get; set; }
public DateTime NextUpdate { get; set; }
public List<CatalogSku> StoreItems { get; set; } = new();
}
public sealed class StorefrontAdCarouselItem
{
public int AdCarouselItemId { get; set; }
public string ImageName { get; set; } = string.Empty;
public string Title { get; set; } = string.Empty;
public string Description { get; set; } = string.Empty;
public List<int> PurchasableItemIds { get; set; } = new();
public int? PurchaseReminderId { get; set; }
}
public sealed class CatalogPrice
{
public int CurrencyType { get; set; }
public int Price { get; set; }
}
public sealed class CatalogGiftDrop
{
public int GiftDropId { get; set; }
public string FriendlyName { get; set; } = string.Empty;
public string Tooltip { get; set; } = string.Empty;
public string AvatarItemDesc { get; set; } = string.Empty;
public string ConsumableItemDesc { get; set; } = string.Empty;
public string EquipmentPrefabName { get; set; } = string.Empty;
public string EquipmentModificationGuid { get; set; } = string.Empty;
public int ItemType { get; set; }
public int ConsumableItemType { get; set; }
public int Rarity { get; set; }
public bool IsQuery { get; set; }
public bool Unique { get; set; }
public int Level { get; set; }
public int Context { get; set; }
public string ThumbnailImage { get; set; } = string.Empty;
}
public sealed class CatalogSku
{
public long Id { get; set; }
public long SkuId { get; set; }
public int PurchasableItemId { get; set; }
public int Type { get; set; }
public bool IsFeatured { get; set; }
public long ItemId { get; set; }
public long AvatarItemId { get; set; }
public int ItemType { get; set; }
public int ConsumableItemType { get; set; }
public int AvatarItemType { get; set; }
public string AvatarItemDesc { get; set; } = string.Empty;
public string ConsumableItemDesc { get; set; } = string.Empty;
public string EquipmentPrefabName { get; set; } = string.Empty;
public string EquipmentModificationGuid { get; set; } = string.Empty;
public string FriendlyName { get; set; } = string.Empty;
public string Tooltip { get; set; } = string.Empty;
public string ThumbnailImage { get; set; } = string.Empty;
public int Rarity { get; set; }
public int PlatformMask { get; set; } = -1;
public int CurrencyType { get; set; } = (int)PlayerDBClasses.CurrencyType.RecCenterTokens;
public int Price { get; set; } = StoreItemPrice;
public int OriginalPrice { get; set; } = StoreItemPrice;
public int DiscountedPrice { get; set; } = StoreItemPrice;
public bool IsAvailable { get; set; } = true;
public bool IsPurchasable { get; set; } = true;
public bool IsEnabled { get; set; } = true;
public bool IsOwned { get; set; }
public bool Owned { get; set; }
public bool Purchased { get; set; }
public bool IsBaseAvatarItem { get; set; }
public bool HasRealName { get; set; } = true;
public DateTime CreatedAt { get; set; }
public List<CatalogPrice> Prices { get; set; } = new();
public CatalogGiftDrop GiftDrop { get; set; } = new();
public List<CatalogGiftDrop> GiftDrops { get; set; } = new();
}
[HttpGet("/api/customAvatarItems/v1/featured")]
public IActionResult GetFeaturedCustomAvatarItems()
{
return Ok(Array.Empty<object>());
}
[HttpGet("/api/inventions/v1/{inventionId:long}/version")]
[HttpGet("/api/inventions/v2/{inventionId:long}/version")]
public IActionResult GetInventionVersionByPath(long inventionId)
{
CreatorFeatureDB.InventionRecord? record =
CreatorFeatureDB.GetInvention(inventionId);
if (record == null)
return NotFound(new { Success = false, Error = "invention_not_found" });
object? version = CreatorFeatureDB.ToClientInventionVersion(record);
return version == null
? StatusCode(
StatusCodes.Status409Conflict,
new { Success = false, Error = "invention_object_blob_missing" })
: Ok(version);
}
[HttpGet("/api/inventions/v1/version")]
public IActionResult GetInventionVersion(
[FromQuery] long inventionId,
[FromQuery] long id = 0,
[FromQuery] int version = 0)
{
long resolvedId = inventionId > 0 ? inventionId : id;
CreatorFeatureDB.InventionRecord? record =
CreatorFeatureDB.GetInvention(resolvedId);
if (record == null)
return NotFound();
object? currentVersion = CreatorFeatureDB.ToClientInventionVersion(record);
return currentVersion == null
? StatusCode(
StatusCodes.Status409Conflict,
new { Success = false, Error = "invention_object_blob_missing" })
: Ok(currentVersion);
}
[HttpGet("/api/storefronts/v1/toptoday")]
public IActionResult GetTopStorefrontsToday()
{
return Ok(GetCatalogSkus());
}
[HttpGet("/api/inventions/v1/toptoday")]
public IActionResult GetTopInventionsToday(
[FromQuery] int skip = 0,
[FromQuery] int take = 100)
{
return Ok(CreatorFeatureDB.SearchInventions(skip: skip, take: take)
.Select(CreatorFeatureDB.ToClientInvention)
.ToArray());
}
[HttpGet("/subscription/top/creators/today")]
public IActionResult GetTopSubscriptionCreatorsToday()
{
return Ok(Array.Empty<object>());
}
private static object ToSavedImageDto(RecNetDB.SavedImage savedImage)
{
long imageId = ImageController.GetOrCreateSavedImageId(savedImage);
return new
{
Id = imageId,
ImageId = imageId,
SavedImageId = imageId,
AccountId = savedImage.AccountId,
RoomId = savedImage.RoomId,
PlayerEventId = savedImage.PlayerEventId,
Url = $"{ServerConfig.BaseURL.TrimEnd('/')}/imageserver/{savedImage.PhotoPath}",
LookupName = savedImage.LookupName,
Accessibility = savedImage.Accessibility,
CreatedAt = savedImage.CreatedAt
};
}
[HttpGet("/api/images/v5/player/{accountId:long}")]
public IActionResult GetPlayerImagesV5(
long accountId,
[FromQuery] int sort = 0)
{
var images = RecNetDB.SavedImages.Find(value => value.AccountId == accountId)
.OrderByDescending(value => value.CreatedAt)
.Select(ToSavedImageDto)
.ToList();
return Ok(images);
}
[HttpGet("/api/images/v1/listsaved")]
public IActionResult ListSavedImages()
{
long? accountId = AuthStuff.GetPlayerId(Request);
if (accountId == null)
return Unauthorized();
var images = RecNetDB.SavedImages.Find(value => value.AccountId == accountId.Value)
.OrderByDescending(value => value.CreatedAt)
.Select(ToSavedImageDto)
.ToList();
return Ok(images);
}
public class SavedImageBulkRequest
{
public List<string>? PhotoPaths { get; set; }
}
[HttpPost("/api/images/v5/bulk")]
public IActionResult GetSavedImagesBulk([FromBody] SavedImageBulkRequest request)
{
if (AuthStuff.GetPlayerId(Request) == null)
return Unauthorized();
var paths = (request.PhotoPaths ?? new List<string>()).ToHashSet(StringComparer.OrdinalIgnoreCase);
var images = RecNetDB.SavedImages.Find(value => paths.Contains(value.PhotoPath))
.Select(ToSavedImageDto)
.ToList();
return Ok(images);
}
public class DeleteSavedImageRequest
{
public string? PhotoPath { get; set; }
}
[HttpPost("/api/images/v1/deletesaved")]
[HttpDelete("/api/images/v1/deletesaved")]
public IActionResult DeleteSavedImage([FromBody] DeleteSavedImageRequest request)
{
long? accountId = AuthStuff.GetPlayerId(Request);
if (accountId == null)
return Unauthorized();
string path = request.PhotoPath ?? string.Empty;
var image = RecNetDB.SavedImages.FindById(path);
if (image == null || image.AccountId != accountId.Value)
return NotFound(new { error = "Saved image not found." });
RecNetDB.SavedImages.Delete(path);
return Ok(new { success = true });
}
[HttpPost("/api/images/v1/sendlink")]
public IActionResult SendSavedImageLink([FromBody] DeleteSavedImageRequest request)
{
long? accountId = AuthStuff.GetPlayerId(Request);
if (accountId == null)
return Unauthorized();
string path = request.PhotoPath ?? string.Empty;
var image = RecNetDB.SavedImages.FindById(path);
if (image == null)
return NotFound(new { error = "Saved image not found." });
return Ok(new { success = true, url = $"{ServerConfig.BaseURL.TrimEnd('/')}/imageserver/{image.PhotoPath}" });
}
[HttpGet("/api/images/v1/slideshow")]
public IActionResult GetImageSlideshow()
{
string imageRoot = Path.GetFullPath(
Path.Combine(Program.dataDir, "Images"));
string requiredPrefix = imageRoot
.TrimEnd(
Path.DirectorySeparatorChar,
Path.AltDirectorySeparatorChar) +
Path.DirectorySeparatorChar;
bool TryResolveSavedImage(
RecNetDB.SavedImage savedImage,
out string relativePath,
out string fullPath)
{
relativePath = savedImage.PhotoPath
.Replace('\\', '/')
.TrimStart('/');
fullPath = string.Empty;
if (string.IsNullOrWhiteSpace(relativePath) ||
relativePath.Split('/').Any(segment => segment is "." or ".."))
{
return false;
}
try
{
fullPath = Path.GetFullPath(
Path.Combine(imageRoot, relativePath));
return fullPath.StartsWith(
requiredPrefix,
StringComparison.OrdinalIgnoreCase) &&
System.IO.File.Exists(fullPath);
}
catch
{
return false;
}
}
var images = RecNetDB.SavedImages.FindAll()
.Where(savedImage =>
savedImage.SavedImageType == 1 &&
savedImage.Accessibility == 1 &&
savedImage.AccountId is > 0 and <= int.MaxValue)
.Select(savedImage =>
{
if (!TryResolveSavedImage(
savedImage,
out string relativePath,
out _))
{
return null;
}
var player = PlayerDB.Players.FindById(
savedImage.AccountId);
if (player?.Player == null)
return null;
var room = savedImage.RoomId is > 0
? RoomDB.Rooms.FindById(savedImage.RoomId.Value)
: null;
long imageId =
ImageController.GetOrCreateSavedImageId(savedImage);
return new SlideshowImageResponse
{
Id = imageId,
ImageId = imageId,
SavedImageId = imageId,
ImageName = relativePath,
Username =
player.Player.Username ??
player.Player.DisplayName ??
$"Player{player.PlayerId}",
PlayerName =
player.Player.Username ??
player.Player.DisplayName ??
$"Player{player.PlayerId}",
PlayerId = checked((int)player.PlayerId),
RoomName = room?.Name ?? "Rec Room",
RoomId = savedImage.RoomId
};
})
.Where(image => image != null)
.Cast<SlideshowImageResponse>()
.GroupBy(
image => image.ImageName,
StringComparer.OrdinalIgnoreCase)
.Select(group => group.First())
.ToList();
if (images.Count == 0)
{
var fallbackPlayer =
(AuthStuff.GetPlayerId(Request) is long callerId
? PlayerDB.Players.FindById(callerId)
: null) ??
PlayerDB.Players.FindAll()
.FirstOrDefault(player => player.Player != null);
int fallbackPlayerId =
fallbackPlayer?.PlayerId is > 0 and <= int.MaxValue
? checked((int)fallbackPlayer.PlayerId)
: 1;
string fallbackPlayerName =
fallbackPlayer?.Player?.Username ??
fallbackPlayer?.Player?.DisplayName ??
"Mocha";
string rroRoot = Path.Combine(imageRoot, "RROs");
if (Directory.Exists(rroRoot))
{
images.AddRange(Directory
.EnumerateFiles(rroRoot, "*", SearchOption.AllDirectories)
.Where(file => new[]
{
".png", ".jpg", ".jpeg", ".webp", ".gif", ".bmp"
}.Contains(
Path.GetExtension(file),
StringComparer.OrdinalIgnoreCase))
.Select(file =>
{
long imageId = Math.Max(
System.IO.File.GetCreationTimeUtc(file).Ticks,
1);
return new SlideshowImageResponse
{
Id = imageId,
ImageId = imageId,
SavedImageId = imageId,
ImageName = Path
.GetRelativePath(imageRoot, file)
.Replace('\\', '/'),
Username = fallbackPlayerName,
PlayerName = fallbackPlayerName,
PlayerId = fallbackPlayerId,
RoomName = Path.GetFileNameWithoutExtension(file),
RoomId = null
};
}));
}
}
for (int index = images.Count - 1; index > 0; index--)
{
int swapIndex = Random.Shared.Next(index + 1);
(images[index], images[swapIndex]) =
(images[swapIndex], images[index]);
}
DateTime validTill = DateTime.UtcNow.AddMinutes(5);
Console.WriteLine(
$"[IMAGE SLIDESHOW] images={images.Count} " +
$"order=random loop=wrap validTill={validTill:O}");
return Ok(new
{
ValidTill = validTill,
Images = images
});
}
private sealed class SlideshowImageResponse
{
public long Id { get; init; }
public long ImageId { get; init; }
public long SavedImageId { get; init; }
public string ImageName { get; init; } = string.Empty;
public string Username { get; init; } = string.Empty;
public string PlayerName { get; init; } = string.Empty;
public int PlayerId { get; init; }
public string RoomName { get; init; } = string.Empty;
public long? RoomId { get; init; }
}
private IActionResult GetLoadingScreenData()
{
string path = Path.Combine(Program.dataDir, "loadingscreen.json");
if (System.IO.File.Exists(path))
{
try
{
string rewritten = LoadingScreenImageService.RewriteConfiguration(
System.IO.File.ReadAllText(path));
return Content(rewritten, "application/json");
}
catch (Exception ex) when (ex is IOException or JsonException)
{
Console.WriteLine($"[loading screen config] {ex.Message}");
}
}
return Ok(new[]
{
new
{
ImageName = "RROs/DormRoom.jpg",
Message = "Welcome to Mocha! Meet up with friends and explore community rooms.",
PlatformMask = -1,
RoomNames = Array.Empty<string>(),
Title = "Welcome to Mocha"
},
new
{
ImageName = "RROs/RecCenter.jpg",
Message = "Open the People menu to find, friend, and subscribe to other players.",
PlatformMask = -1,
RoomNames = Array.Empty<string>(),
Title = "Play together"
}
});
}
[HttpPost("/api/objectives/v1/cleargroup")]
public IActionResult ClearObjectiveGroup()
{
return Ok(new
{
success = true
});
}
[HttpGet("/cdn/sigs/{buildId}")]
public IActionResult GetBuildSignature(string buildId)
{
Response.Headers.CacheControl = "public, max-age=3600";
return Content("p1", "text/plain");
}
[HttpPost("/api/screensharereports")]
public IActionResult CreateScreenShareReport([FromBody] JsonElement request)
{
var id = AuthStuff.GetPlayerId(Request);
if (id == null)
return Unauthorized();
DiscordLogger.Log($"🖥️ Screen share report submitted by player ID `{id}`");
return Ok(new { success = true });
}
[HttpGet("/api/config/v1/azurespeech")]
[HttpGet("/api/config/v1/backtrace")]
public IActionResult DisabledFeatureConfigStub()
{
return Ok(new { enabled = false });
}
[HttpGet("/api/keepsakes/globalconfig")]
[HttpGet("/api/keepsakes/config")]
[HttpGet("/api/keepsakes/v1/globalconfig")]
[HttpGet("/api/keepsakes/v1/config")]
public IActionResult KeepsakesGlobalConfig()
{
return Ok(new
{
Enabled = true,
MaxKeepsakesPerRoom = 0,
CollectionEnabled = true
});
}
[HttpGet("/api/keepsakes/rooms/{roomId}")]
[HttpGet("/api/keepsakes/room/{roomId}")]
[HttpGet("/api/keepsakes/v1/rooms/{roomId}")]
[HttpGet("/api/keepsakes/v1/room/{roomId}")]
public IActionResult KeepsakesForRoom(long roomId)
{
return Ok(new
{
RoomId = roomId,
KeepsakeInstances = Array.Empty<object>(),
CollectionRecords = Array.Empty<object>(),
CollectedKeepsakeIds = Array.Empty<long>()
});
}
private IActionResult ReadAnnouncementList()
{
string path = Path.Combine(Program.dataDir, "announcements.json");
if (!System.IO.File.Exists(path))
return Ok(Array.Empty<object>());
string json = System.IO.File.ReadAllText(path);
if (string.IsNullOrWhiteSpace(json))
return Ok(Array.Empty<object>());
try
{
using JsonDocument document = JsonDocument.Parse(json);
if (document.RootElement.ValueKind != JsonValueKind.Array)
{
Console.WriteLine("[Announcements] announcements.json must contain a JSON array.");
return Ok(Array.Empty<object>());
}
Response.Headers.CacheControl = "no-store, no-cache, must-revalidate";
Response.Headers.Pragma = "no-cache";
Response.Headers.Expires = "0";
Console.WriteLine($"[Announcements] Returning: {json}");
return Content(json, "application/json");
}
catch (JsonException exception)
{
Console.WriteLine($"[Announcements] Invalid JSON: {exception.Message}");
return Ok(Array.Empty<object>());
}
}
[HttpGet("/announcements/v2/mine/unread")]
public IActionResult GetUnreadAnnouncements()
{
return Ok(Array.Empty<object>());
}
[HttpGet("/announcements/v2/subscription/mine/unread")]
public IActionResult GetUnreadSubscriptionAnnouncements()
{
return Ok(Array.Empty<object>());
}
[HttpGet("/api/announcement/v1/get")]
public IActionResult GetAnnouncements()
{
return ReadAnnouncementList();
}
[HttpPost("/announcements/v2/mine/read")]
[HttpPost("/announcements/v2/mine/markread")]
[HttpPost("/announcements/v2/subscription/mine/read")]
public IActionResult MarkAnnouncementsRead()
{
long? playerId = AuthStuff.GetPlayerId(Request);
if (!playerId.HasValue)
return Unauthorized();
return Ok(new { Success = true });
}
[HttpPost("/api/announcement/v1/refresh")]
public async Task<IActionResult> RefreshAnnouncements()
{
long? playerId = AuthStuff.GetPlayerId(Request);
if (!playerId.HasValue)
return Unauthorized();
await NotiController.NotifyAnnouncementsUpdatedAsync(playerId.Value);
return Ok(new { Success = true });
}
[HttpPost("/api/relationships/v1/bulkignoreplatformusers")]
public IActionResult BulkIgnorePlatformUsers()
{
var id = AuthStuff.GetPlayerId(Request);
if (id == null)
return Unauthorized();
return Ok(new { success = true });
}
[HttpGet("/api/relationships/v1/favorite")]
public IActionResult GetFavoriteRelationship([FromQuery] long id)
{
long? accountId = AuthStuff.GetPlayerId(Request);
if (!accountId.HasValue)
return Unauthorized();
ClientRelationshipDTO? relationship =
RelationshipDB.GetClientRelationship(accountId.Value, id);
return Ok(relationship != null &&
(relationship.Favorited & 1) != 0);
}
[HttpGet("/thread/club/{clubId:long}")]
public IActionResult GetClubThreads(
long clubId,
[FromQuery] int maxCount = 10,
[FromQuery] int mode = 0)
{
long? accountId = AuthStuff.GetPlayerId(Request);
if (!accountId.HasValue)
return Unauthorized();
return Ok(Array.Empty<object>());
}
[HttpPost("/pageview/consume")]
public IActionResult ConsumePageView() => NoContent();
[HttpPost("/api/ugcPurchasables/v1/items/bulk")]
public IActionResult UgcPurchasablesBulk()
{
var id = AuthStuff.GetPlayerId(Request);
if (id == null)
return Unauthorized();
return Ok(Array.Empty<object>());
}
[HttpPost("/api/PlayerReporting/v1/deviceId")]
public IActionResult PlayerReportingDeviceId()
{
var id = AuthStuff.GetPlayerId(Request);
return Ok(new { accountId = id });
}
[HttpGet("/api/testcasemanagement/v1/cases")]
public IActionResult TestCaseManagementStub()
{
return Ok(ServerConfig.Bracket);
}
[HttpGet("/api/customAvatarItems/v1/design")]
public IActionResult GetCustomAvatarItemDesign()
{
long? rawPlayerId = AuthStuff.GetPlayerId(Request);
if (!rawPlayerId.HasValue)
return Unauthorized();
long playerId = rawPlayerId.Value;
string directory = Path.Combine(
Program.dataDir,
"CustomAvatarItems",
playerId.ToString()
);
string designPath = Path.Combine(directory, "design.json");
Console.WriteLine(
$"[CUSTOM AVATAR ITEMS] Player ({playerId}) requested their design."
);
if (!System.IO.File.Exists(designPath))
{
return Ok(new
{
customAvatarItemId = 0,
creatorPlayerId = playerId,
design = new { }
});
}
try
{
string json = System.IO.File.ReadAllText(designPath);
return Content(
json,
"application/json",
Encoding.UTF8
);
}
catch (Exception ex)
{
Console.WriteLine(
$"[CUSTOM AVATAR ITEMS] Failed reading design: {ex}"
);
return StatusCode(500, new
{
success = false,
error = "Failed to load custom avatar item design."
});
}
}
[HttpPost("/api/customAvatarItems/v1")]
[RequestSizeLimit(12 * 1024 * 1024)]
public async Task<IActionResult> CreateCustomAvatarItem()
{
long? rawPlayerId = AuthStuff.GetPlayerId(Request);
if (!rawPlayerId.HasValue)
return Unauthorized();
long playerId = rawPlayerId.Value;
string metadataJson = string.Empty;
IFormFile? thumbnailImage = null;
if (Request.HasFormContentType)
{
IFormCollection form;
try
{
form = await Request.ReadFormAsync(HttpContext.RequestAborted);
}
catch (InvalidDataException ex)
{
Console.WriteLine(
$"[CUSTOM AVATAR ITEMS] Invalid multipart request from player ({playerId}): {ex.Message}");
return BadRequest(new { success = false, error = "Invalid multipart form." });
}
metadataJson = new[]
{
"metadata",
"Metadata",
"data",
"Data",
"json",
"Json"
}
.Select(name => form[name].FirstOrDefault())
.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value))
?? string.Empty;
thumbnailImage = form.Files.GetFile("thumbnailImage")
?? form.Files.GetFile("ThumbnailImage")
?? form.Files.FirstOrDefault();
}
else
{
using var reader = new StreamReader(
Request.Body,
Encoding.UTF8,
detectEncodingFromByteOrderMarks: true,
leaveOpen: true);
metadataJson = await reader.ReadToEndAsync();
}
Console.WriteLine(
$"[CUSTOM AVATAR ITEMS] Player ({playerId}) metadata: {metadataJson}");
if (string.IsNullOrWhiteSpace(metadataJson))
{
return BadRequest(new
{
success = false,
error = "Custom avatar item metadata was empty."
});
}
JsonNode? receivedJson;
try
{
receivedJson = JsonNode.Parse(metadataJson);
}
catch (JsonException ex)
{
Console.WriteLine(
$"[CUSTOM AVATAR ITEMS] Invalid metadata JSON from player ({playerId}): {ex.Message}");
return BadRequest(new
{
success = false,
error = "Custom avatar item metadata contained invalid JSON."
});
}
if (receivedJson is not JsonObject item)
{
return BadRequest(new
{
success = false,
error = "Custom avatar item metadata must be a JSON object."
});
}
long customAvatarItemId = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
while (LoadCustomAvatarItems(playerId).Any(existing =>
ReadCustomAvatarItemId(existing) == customAvatarItemId))
{
customAvatarItemId++;
}
string createdAt = DateTime.UtcNow.ToString("O");
item["CustomAvatarItemId"] = customAvatarItemId;
item["customAvatarItemId"] = customAvatarItemId;
item["Id"] ??= customAvatarItemId;
item["CreatorPlayerId"] = playerId;
item["creatorPlayerId"] = playerId;
item["CreatorAccountId"] ??= playerId;
item["CreatedAt"] ??= createdAt;
item["createdAt"] ??= createdAt;
string directory = Path.Combine(
Program.dataDir,
"CustomAvatarItems",
playerId.ToString());
string designPath = Path.Combine(directory, "design.json");
string itemsPath = Path.Combine(directory, "items.json");
try
{
Directory.CreateDirectory(directory);
if (thumbnailImage is { Length: > 0 })
{
string imageDirectory = Path.Combine(
Program.dataDir,
"Images",
"CustomAvatarItems",
playerId.ToString());
Directory.CreateDirectory(imageDirectory);
string extension = string.Equals(
thumbnailImage.ContentType,
"image/jpeg",
StringComparison.OrdinalIgnoreCase)
? ".jpg"
: ".png";
string imageName = customAvatarItemId + extension;
string imagePath = Path.Combine(imageDirectory, imageName);
await using (FileStream stream = new(
imagePath,
FileMode.Create,
FileAccess.Write,
FileShare.None))
{
await thumbnailImage.CopyToAsync(
stream,
HttpContext.RequestAborted);
}
string relativeImagePath =
$"CustomAvatarItems/{playerId}/{imageName}";
item["ThumbnailImage"] = relativeImagePath;
item["thumbnailImage"] = relativeImagePath;
}
JsonNode designToSave = item["Design"]?.DeepClone()
?? item["design"]?.DeepClone()
?? item.DeepClone();
await System.IO.File.WriteAllTextAsync(
designPath,
designToSave.ToJsonString(new JsonSerializerOptions
{
WriteIndented = true
}),
HttpContext.RequestAborted);
JsonArray items = new();
if (System.IO.File.Exists(itemsPath))
{
try
{
items = JsonNode.Parse(
await System.IO.File.ReadAllTextAsync(
itemsPath,
HttpContext.RequestAborted)) as JsonArray
?? new JsonArray();
}
catch (Exception ex)
{
Console.WriteLine(
$"[CUSTOM AVATAR ITEMS] Existing items file was invalid: {ex.Message}");
}
}
items.Add(item.DeepClone());
await System.IO.File.WriteAllTextAsync(
itemsPath,
items.ToJsonString(new JsonSerializerOptions
{
WriteIndented = true
}),
HttpContext.RequestAborted);
Console.WriteLine(
$"[CUSTOM AVATAR ITEMS] Created item ({customAvatarItemId}) for player ({playerId}).");
return Content(
item.ToJsonString(),
"application/json",
Encoding.UTF8);
}
catch (Exception ex)
{
Console.WriteLine(
$"[CUSTOM AVATAR ITEMS] Failed saving item: {ex}");
return StatusCode(500, new
{
success = false,
error = "Failed to save custom avatar item."
});
}
}
private static List<JsonObject> LoadCustomAvatarItems(long playerId)
{
string itemsPath = Path.Combine(
Program.dataDir,
"CustomAvatarItems",
playerId.ToString(),
"items.json");
if (!System.IO.File.Exists(itemsPath))
return new List<JsonObject>();
try
{
JsonNode? root = JsonNode.Parse(System.IO.File.ReadAllText(itemsPath));
if (root is not JsonArray array)
return new List<JsonObject>();
return array
.OfType<JsonObject>()
.Select(value => value.DeepClone().AsObject())
.OrderByDescending(ReadCustomAvatarItemId)
.ToList();
}
catch (Exception ex)
{
Console.WriteLine(
$"[CUSTOM AVATAR ITEMS] Could not read items for player ({playerId}): {ex.Message}");
return new List<JsonObject>();
}
}
private static long ReadCustomAvatarItemId(JsonObject item)
{
foreach (string key in new[] { "CustomAvatarItemId", "customAvatarItemId", "Id", "id" })
{
if (TryReadLong(item[key], out long id))
return id;
}
return 0;
}
[HttpGet("/reminder/currentTokenBundles/v2")]
public IActionResult GetCurrentTokenBundles()
{
DateTime expiresAt = DateTime.UtcNow.AddYears(10);
var bundles = new List<TokenBundleResponse>
{
CreateTokenBundle(
id: 9_000_001,
productId: "mocha.tokens.2500",
baseTokens: 2_500,
bonusTokens: 0,
displayPrice: "$1.99",
expiresAt
),
CreateTokenBundle(
id: 9_000_002,
productId: "mocha.tokens.5500",
baseTokens: 5_000,
bonusTokens: 500,
displayPrice: "$3.99",
expiresAt
),
CreateTokenBundle(
id: 9_000_003,
productId: "mocha.tokens.12000",
baseTokens: 10_000,
bonusTokens: 2_000,
displayPrice: "$7.99",
expiresAt
),
CreateTokenBundle(
id: 9_000_004,
productId: "mocha.tokens.35000",
baseTokens: 25_000,
bonusTokens: 10_000,
displayPrice: "$19.99",
expiresAt
),
CreateTokenBundle(
id: 9_000_005,
productId: "mocha.tokens.70000",
baseTokens: 50_000,
bonusTokens: 20_000,
displayPrice: "$34.99",
expiresAt
)
};
return Ok(bundles);
}
[HttpPost("/api/relationships/v1/ignore")]
[HttpPost("/api/relationships/v2/ignore")]
[HttpPost("/api/relationships/v1/block")]
[HttpPost("/api/relationships/v2/block")]
[HttpPut("/api/relationships/v1/ignore")]
[HttpPut("/api/relationships/v2/ignore")]
[HttpPut("/api/relationships/v1/block")]
[HttpPut("/api/relationships/v2/block")]
public Task<IActionResult> IgnorePlayer()
{
return SetRelationshipFlag(
RelationshipFlag.Ignored,
forcedEnabled: null);
}
[HttpPost("/api/relationships/v1/mute")]
[HttpPost("/api/relationships/v2/mute")]
[HttpPut("/api/relationships/v1/mute")]
[HttpPut("/api/relationships/v2/mute")]
public Task<IActionResult> MutePlayer()
{
return SetRelationshipFlag(
RelationshipFlag.Muted,
forcedEnabled: null);
}
[HttpPost("/api/relationships/v1/unignore")]
[HttpPost("/api/relationships/v2/unignore")]
[HttpPost("/api/relationships/v1/unblock")]
[HttpPost("/api/relationships/v2/unblock")]
[HttpDelete("/api/relationships/v1/ignore")]
[HttpDelete("/api/relationships/v2/ignore")]
[HttpDelete("/api/relationships/v1/block")]
[HttpDelete("/api/relationships/v2/block")]
public Task<IActionResult> UnignorePlayer()
{
return SetRelationshipFlag(
RelationshipFlag.Ignored,
forcedEnabled: false);
}
[HttpPost("/api/relationships/v1/unmute")]
[HttpPost("/api/relationships/v2/unmute")]
[HttpDelete("/api/relationships/v1/mute")]
[HttpDelete("/api/relationships/v2/mute")]
public Task<IActionResult> UnmutePlayer()
{
return SetRelationshipFlag(
RelationshipFlag.Muted,
forcedEnabled: false);
}
[HttpPost("/api/relationships/v1/favorite")]
[HttpPost("/api/relationships/v2/favorite")]
[HttpPut("/api/relationships/v1/favorite")]
[HttpPut("/api/relationships/v2/favorite")]
public Task<IActionResult> FavoritePlayer()
{
return SetRelationshipFlag(
RelationshipFlag.Favorited,
forcedEnabled: null);
}
[HttpPost("/api/relationships/v1/unfavorite")]
[HttpPost("/api/relationships/v2/unfavorite")]
[HttpDelete("/api/relationships/v1/favorite")]
[HttpDelete("/api/relationships/v2/favorite")]
public Task<IActionResult> UnfavoritePlayer()
{
return SetRelationshipFlag(
RelationshipFlag.Favorited,
forcedEnabled: false);
}
private enum RelationshipFlag
{
Ignored,
Muted,
Favorited
}
private async Task<IActionResult> SetRelationshipFlag(
RelationshipFlag flag,
bool? forcedEnabled)
{
long? sourceId = AuthStuff.GetPlayerId(Request);
if (sourceId == null)
return Unauthorized();
var action = await ReadRelationshipAction(
flag switch
{
RelationshipFlag.Ignored =>
new[] { "Ignored", "IsIgnored", "Ignore", "Blocked", "IsBlocked", "Block" },
RelationshipFlag.Muted =>
new[] { "Muted", "IsMuted", "Mute" },
RelationshipFlag.Favorited =>
new[] { "Favorited", "IsFavorited", "Favorite" },
_ => Array.Empty<string>()
});
if (action == null)
{
return BadRequest(new
{
success = false,
error = "missing_target_player"
});
}
bool enabled = forcedEnabled ?? action.Value.Enabled;
var relationship = PlayerDB.SetRelationshipFlags(
sourceId.Value,
action.Value.TargetId,
ignored: flag == RelationshipFlag.Ignored ? enabled : null,
muted: flag == RelationshipFlag.Muted ? enabled : null,
favorited: flag == RelationshipFlag.Favorited ? enabled : null);
if (relationship == null)
{
return BadRequest(new
{
success = false,
error = "relationship_update_failed"
});
}
ClientRelationshipDTO? clientRelationship =
RelationshipDB.GetClientRelationship(
sourceId.Value,
action.Value.TargetId);
if (clientRelationship == null)
{
return StatusCode(500, new
{
success = false,
error = "relationship_not_persisted"
});
}
string actionName = flag switch
{
RelationshipFlag.Ignored =>
enabled ? "block" : "unblock",
RelationshipFlag.Muted =>
enabled ? "mute" : "unmute",
RelationshipFlag.Favorited =>
enabled ? "favorite" : "unfavorite",
_ => "relationship-change"
};
await NotiController.NotifyRelationshipFlagsChangedAsync(
sourceId.Value,
action.Value.TargetId,
actionName);
Console.WriteLine(
$"[RELATIONSHIP] source={sourceId.Value} " +
$"target={action.Value.TargetId} action={actionName} " +
$"ignored={clientRelationship.Ignored} " +
$"muted={clientRelationship.Muted} " +
$"favorited={clientRelationship.Favorited} live=true");
return Ok(clientRelationship);
}
private async Task<(long TargetId, bool Enabled)?>
ReadRelationshipAction(string[] enabledKeys)
{
var values = new Dictionary<string, string>(
StringComparer.OrdinalIgnoreCase);
foreach (var entry in Request.Query)
values[entry.Key] = entry.Value.ToString();
if (Request.HasFormContentType)
{
var form = await Request.ReadFormAsync();
foreach (var entry in form)
values[entry.Key] = entry.Value.ToString();
}
else
{
using var reader = new StreamReader(Request.Body);
string body = await reader.ReadToEndAsync();
if (!string.IsNullOrWhiteSpace(body))
{
try
{
using var document = JsonDocument.Parse(body);
AddJsonValues(document.RootElement, values);
}
catch (JsonException)
{
foreach (var entry in
Microsoft.AspNetCore.WebUtilities.QueryHelpers
.ParseQuery("?" + body))
{
values[entry.Key] = entry.Value.ToString();
}
if (long.TryParse(
body.Trim().Trim('"', '\''),
out long rawTargetId))
{
values["accountId"] =
rawTargetId.ToString();
}
}
}
}
string? targetText = GetValue(
values,
"TargetAccountId",
"TargetPlayerId",
"AccountId",
"PlayerId",
"PlayerID",
"targetId",
"id");
if (!long.TryParse(targetText, out long targetId) ||
targetId <= 0)
{
return null;
}
bool enabled = true;
string? enabledText = GetValue(
values,
enabledKeys
.Concat(new[]
{
"Enabled",
"Value",
"State"
})
.ToArray());
if (bool.TryParse(enabledText, out bool parsedBool))
{
enabled = parsedBool;
}
else if (int.TryParse(enabledText, out int parsedInt))
{
enabled = parsedInt != 0;
}
return (targetId, enabled);
}
private async Task<HashSet<long>> ReadLongValuesAsync(params string[] keys)
{
var result = new HashSet<long>();
var keySet = keys.ToHashSet(StringComparer.OrdinalIgnoreCase);
foreach (var entry in Request.Query)
{
if (keySet.Contains(entry.Key))
AddLongValuesFromText(entry.Value.ToString(), result);
}
if (Request.HasFormContentType)
{
var form = await Request.ReadFormAsync(HttpContext.RequestAborted);
foreach (var entry in form)
{
if (keySet.Contains(entry.Key))
AddLongValuesFromText(entry.Value.ToString(), result);
}
}
else
{
Request.EnableBuffering();
if (Request.Body.CanSeek)
Request.Body.Position = 0;
using var reader = new StreamReader(
Request.Body,
Encoding.UTF8,
detectEncodingFromByteOrderMarks: false,
leaveOpen: true);
string body = await reader.ReadToEndAsync();
if (Request.Body.CanSeek)
Request.Body.Position = 0;
if (!string.IsNullOrWhiteSpace(body))
{
try
{
using JsonDocument document = JsonDocument.Parse(body);
CollectLongValues(
document.RootElement,
keySet,
result,
collectAll: document.RootElement.ValueKind == JsonValueKind.Array);
}
catch (JsonException)
{
foreach (var entry in Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery("?" + body))
{
if (keySet.Contains(entry.Key))
AddLongValuesFromText(entry.Value.ToString(), result);
}
AddLongValuesFromText(body, result);
}
}
}
return result;
}
private static void CollectLongValues(
JsonElement element,
HashSet<string> keys,
HashSet<long> result,
bool collectAll)
{
switch (element.ValueKind)
{
case JsonValueKind.Object:
foreach (JsonProperty property in element.EnumerateObject())
{
bool propertyMatches = keys.Contains(property.Name);
CollectLongValues(
property.Value,
keys,
result,
collectAll || propertyMatches);
}
break;
case JsonValueKind.Array:
foreach (JsonElement item in element.EnumerateArray())
CollectLongValues(item, keys, result, collectAll);
break;
case JsonValueKind.Number when collectAll:
if (element.TryGetInt64(out long numericValue) && numericValue > 0)
result.Add(numericValue);
break;
case JsonValueKind.String when collectAll:
AddLongValuesFromText(element.GetString(), result);
break;
}
}
private static void AddLongValuesFromText(
string? text,
HashSet<long> result)
{
if (string.IsNullOrWhiteSpace(text))
return;
string cleaned = text.Replace("[", " ").Replace("]", " ")
.Replace("{", " ").Replace("}", " ").Replace("\"", " ")
.Replace("'", " ");
foreach (string part in cleaned.Split(
new[] { ',', ';', '|', ' ', '\t', '\r', '\n', '=', ':' },
StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
{
if (long.TryParse(part, out long value) && value > 0)
result.Add(value);
}
}
private static TokenBundleResponse CreateTokenBundle(
int id,
string productId,
int baseTokens,
int bonusTokens,
string displayPrice,
DateTime expiresAt)
{
int totalTokens = baseTokens + bonusTokens;
return new TokenBundleResponse
{
Id = id,
TokenBundleId = id,
BundleId = id,
SkuId = id,
PurchasableItemId = id,
ProductId = productId,
PlatformProductId = productId,
Sku = productId,
Name = $"{totalTokens:N0} Tokens",
DisplayName = $"{totalTokens:N0} Tokens",
TokenAmount = baseTokens,
BaseTokenAmount = baseTokens,
BonusTokenAmount = bonusTokens,
BonusTokens = bonusTokens,
Tokens = totalTokens,
TotalTokens = totalTokens,
Price = displayPrice,
DisplayPrice = displayPrice,
CurrencyCode = "CAD",
ExpirationDate = expiresAt,
ExpiresAt = expiresAt,
EndDate = expiresAt,
IsActive = true,
IsAvailable = true,
GiftDrops = Array.Empty<object>(),
BonusGiftDrops = Array.Empty<object>()
};
}
public sealed class TokenBundleResponse
{
public int Id { get; set; }
public int TokenBundleId { get; set; }
public int BundleId { get; set; }
public int SkuId { get; set; }
public int PurchasableItemId { get; set; }
public string ProductId { get; set; } = "";
public string PlatformProductId { get; set; } = "";
public string Sku { get; set; } = "";
public string Name { get; set; } = "";
public string DisplayName { get; set; } = "";
public int TokenAmount { get; set; }
public int BaseTokenAmount { get; set; }
public int BonusTokenAmount { get; set; }
public int BonusTokens { get; set; }
public int Tokens { get; set; }
public int TotalTokens { get; set; }
public string Price { get; set; } = "";
public string DisplayPrice { get; set; } = "";
public string CurrencyCode { get; set; } = "";
public DateTime ExpirationDate { get; set; }
public DateTime ExpiresAt { get; set; }
public DateTime EndDate { get; set; }
public bool IsActive { get; set; }
public bool IsAvailable { get; set; }
public object[] GiftDrops { get; set; } = Array.Empty<object>();
public object[] BonusGiftDrops { get; set; } = Array.Empty<object>();
}
public class SanitizeRequest
{
public string Value { get; set; } = string.Empty;
}
}
}