Import december-2020-12-18 from private repo (scrubbed 2026-05-27)

Targets Rec Room 2020.12.18 client.

Re-import after adding:
- DorkNet.ClientMod/dorknet-clientmod.json.template — config schema
  the launcher's manifest-driven patcher renders into UserData/.
- tools/build-patcher-zip.ps1 — produces the dorknet-clientpatch-{key}.zip
  Release asset the launcher fetches.
- .github/workflows/release.yml — auto-publishes both Release assets
  on v1-december-* tag push.

Scrubbed via maintainer-side scrub-for-public.ps1: same drop rules as
the prior import, plus build-patcher-zip.ps1 added to the tools/
allow-list so it survives the scrub.
This commit is contained in:
Alexa
2026-05-27 01:04:12 +03:00
commit 4edf25b2d5
375 changed files with 201291 additions and 0 deletions
@@ -0,0 +1,267 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using DorkNet.Models.Auth;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.API.Account;
/// <summary>
/// api.rec.net/api/account/* — same endpoints as accounts.rec.net but routed
/// through the API service. The Accounts class in dump.cs doesn't expose its
/// base URL constant so the game may use either host; we cover both.
/// </summary>
[ApiController]
public class AccountApiController(
PlayerService playerService,
AuthService authService,
DorkNetDbContext db,
NotificationService notifications,
ServerSettingsService settings,
ILogger<AccountApiController> logger) : ControllerBase
{
[HttpPost("api/account/v1/create")]
[HttpPost("api/account/v1")]
[HttpPost("api/account/v2/create")]
[HttpPost("api/createaccount")]
public async Task<IActionResult> CreateAccount(
[FromForm] int platform,
[FromForm] string? platformId,
[FromForm] string? deviceId,
[FromForm] string? Username,
[FromForm] string? DisplayName)
{
if (await settings.AreSignupsDisabledAsync())
{
logger.LogInformation(
"[api-account] api/account/v1/create refused — signups disabled (device={Device} platform={Platform})",
deviceId, platform);
return Ok(new CreateAccountResponse
{
Success = false,
Error = "Account creation is currently disabled by the server admin.",
});
}
var player = await playerService.GetOrCreateByDeviceAsync(
deviceId: deviceId,
platform: platform,
platformId: platformId,
displayName: DisplayName ?? Username);
return Ok(new CreateAccountResponse
{
Success = true,
Error = string.Empty,
Account = new RecNetAccount
{
AccountId = (int)player.Id,
RawUsername = player.Username,
Username = player.Username,
DisplayName = player.DisplayName ?? player.Username,
ProfileImage = string.Empty,
TreatAsJunior = false,
HasBirthday = true,
Platforms = 1,
},
});
}
[HttpGet("api/account/v1/me")]
[HttpGet("api/account/v2/me")]
public async Task<IActionResult> GetMe()
{
var player = await GetCurrentPlayerAsync();
if (player is null) return Unauthorized();
return Ok(new RecNetSelfAccount
{
AccountId = (int)player.Id,
RawUsername = player.Username,
Username = player.Username,
DisplayName = player.DisplayName ?? player.Username,
ProfileImage = player.ProfileImageName ?? string.Empty,
TreatAsJunior = false,
HasBirthday = true,
Platforms = 1,
Email = player.Email ?? string.Empty,
Phone = string.Empty,
Birthday = new DateTime(2000, 1, 1),
JuniorState = 0,
ParentAccountId = null,
});
}
/// <summary>
/// Resolves the player tied to the bearer token on the request. Returns
/// null when no/invalid bearer is supplied — caller decides whether
/// that's a 401 or a softer fallback. Anonymous-account creation was
/// previously the fallback path; deleted in Phase 1c because every
/// caller has gone through `/account/create` (deviceId-bound) before
/// reaching us.
/// </summary>
private async Task<PlayerEntity?> GetCurrentPlayerAsync()
{
var auth = Request.Headers.Authorization.ToString();
if (!auth.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
return null;
var token = auth["Bearer ".Length..].Trim();
var id = authService.ValidateToken(token);
if (id is not long playerId) return null;
return await playerService.GetByIdAsync(playerId);
}
// Modification endpoints — POSTs land on api.* host with form body
// carrying the new value. AuthAccountController on auth.* handles
// the same shape with real persistence; we mirror that here so
// either host works.
[HttpPost("api/account/v1/displayname")]
public async Task<IActionResult> SetDisplayName([FromForm(Name = "displayName")] string? displayName,
[FromForm(Name = "value")] string? value)
{
var p = await GetCurrentPlayerAsync();
if (p is null) return Unauthorized();
var name = (displayName ?? value ?? string.Empty).Trim();
if (name.Length == 0) return BadRequest(new RecNetResult { Success = false, Error = "empty" });
await playerService.UpdateDisplayNameAsync(p.Id, name);
return Ok(new RecNetResult { Success = true, Error = string.Empty });
}
[HttpPost("api/account/v1/bio")]
public async Task<IActionResult> SetBio([FromForm(Name = "bio")] string? bio,
[FromForm(Name = "value")] string? value)
{
var p = await GetCurrentPlayerAsync();
if (p is null) return Unauthorized();
await playerService.UpdateBioAsync(p.Id, (bio ?? value ?? string.Empty));
return Ok(new RecNetResult { Success = true, Error = string.Empty });
}
[HttpPost("api/account/v1/username")]
public async Task<IActionResult> SetUsername([FromForm(Name = "username")] string? username,
[FromForm(Name = "value")] string? value)
{
var p = await GetCurrentPlayerAsync();
if (p is null) return Unauthorized();
var newName = (username ?? value ?? string.Empty).Trim();
if (newName.Length == 0) return BadRequest(new RecNetResult { Success = false, Error = "empty" });
var taken = await db.Players.AnyAsync(x => x.Username == newName && x.Id != p.Id);
if (taken) return Ok(new RecNetResult { Success = false, Error = "username_taken" });
p.Username = newName;
await db.SaveChangesAsync();
return Ok(new RecNetResult { Success = true, Error = string.Empty });
}
[HttpPost("api/account/v1/birthday")]
public async Task<IActionResult> SetBirthday([FromForm(Name = "birthday")] string? birthday,
[FromForm(Name = "value")] string? value)
{
var p = await GetCurrentPlayerAsync();
if (p is null) return Unauthorized();
var raw = birthday ?? value ?? string.Empty;
if (DateTime.TryParse(raw, out var parsed))
{
p.Birthday = DateTime.SpecifyKind(parsed, DateTimeKind.Utc);
await db.SaveChangesAsync();
}
return Ok(new RecNetResult { Success = true, Error = string.Empty });
}
[HttpPost("api/account/v1/email")]
public async Task<IActionResult> SetEmail([FromForm(Name = "email")] string? email,
[FromForm(Name = "value")] string? value)
{
var p = await GetCurrentPlayerAsync();
if (p is null) return Unauthorized();
var addr = (email ?? value ?? string.Empty).Trim();
if (addr.Length > 0) p.Email = addr;
await db.SaveChangesAsync();
return Ok(new RecNetResult { Success = true, Error = string.Empty });
}
[HttpPost("api/account/v1/phone")]
[HttpPost("api/account/v1/confirmphone")]
public async Task<IActionResult> SetPhone([FromForm(Name = "phone")] string? phone,
[FromForm(Name = "value")] string? value)
{
var p = await GetCurrentPlayerAsync();
if (p is null) return Unauthorized();
var num = (phone ?? value ?? string.Empty).Trim();
if (num.Length > 0) p.Phone = num;
await db.SaveChangesAsync();
return Ok(new RecNetResult { Success = true, Error = string.Empty });
}
[HttpPost("api/account/v1/profileimage")]
[HttpPost("api/account/me/profileimage")]
[HttpPost("account/me/profileimage")]
[HttpPost("profileimage")]
[HttpPut("api/account/v1/profileimage")]
[HttpPut("api/account/me/profileimage")]
[HttpPut("account/me/profileimage")]
[HttpPut("profileimage")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
public async Task<IActionResult> SetProfileImage(
[FromForm(Name = "imageName")] string? imageName,
[FromForm(Name = "fileName")] string? fileName,
[FromForm(Name = "image")] string? imageAlt)
{
var player = await GetCurrentPlayerAsync();
if (player is null) return Unauthorized();
var name = imageName ?? fileName ?? imageAlt
?? Request.Query["imageName"].ToString()
?? Request.Query["fileName"].ToString();
player.ProfileImageName = string.IsNullOrWhiteSpace(name) ? null : name.Trim();
await db.SaveChangesAsync();
logger.LogInformation(
"[api-account] profile image set: player={PlayerId} -> {Name}",
player.Id,
player.ProfileImageName ?? "<cleared>");
await NotifyAccountImageChangedAsync(player);
return Ok(new RecNetResult { Success = true, Error = string.Empty });
}
private Task NotifyAccountImageChangedAsync(PlayerEntity player)
{
var account = new RecNetAccount
{
AccountId = (int)player.Id,
RawUsername = player.Username,
Username = player.Username,
DisplayName = player.DisplayName ?? player.Username,
ProfileImage = player.ProfileImageName ?? string.Empty,
TreatAsJunior = player.IsJunior,
HasBirthday = true,
Platforms = 1,
};
var selfAccount = new RecNetSelfAccount
{
AccountId = account.AccountId,
RawUsername = account.RawUsername,
Username = account.Username,
DisplayName = account.DisplayName,
ProfileImage = account.ProfileImage,
TreatAsJunior = account.TreatAsJunior,
HasBirthday = account.HasBirthday,
Platforms = account.Platforms,
Email = player.Email ?? string.Empty,
Phone = player.Phone ?? string.Empty,
Birthday = player.Birthday,
JuniorState = player.IsJunior ? 1 : 0,
ParentAccountId = null,
};
return Task.WhenAll(
notifications.NotifyTypedAsync(player.Id, "AccountUpdate", account),
notifications.NotifyTypedAsync(player.Id, "SelfAccountUpdate", selfAccount));
}
[HttpGet("api/account/v1/namegen")]
[HttpGet("api/account/v1/namegeneration")]
public IActionResult NameGen() => Ok(new
{
Nouns = new[] { "Player", "Hero", "Star", "Fox", "Wolf" },
Adjectives = new[] { "Brave", "Swift", "Clever", "Bold", "Bright" },
});
}
@@ -0,0 +1,16 @@
using Microsoft.AspNetCore.Mvc;
namespace DorkNet.Server.Controllers.API.Activities;
/// <summary>
/// api.{rec.net,localhost}/api/activities/* — per-activity content
/// (charades words, party-game prompts, etc.). The 2020 watch
/// fetches these as a static list at room-join time; we ship an
/// empty list so the activity's bundled defaults kick in.
/// </summary>
[ApiController]
public class ActivitiesController : ControllerBase
{
[HttpGet("api/activities/charades/v1/words")]
public IActionResult CharadesWords() => Ok(Array.Empty<object>());
}
@@ -0,0 +1,45 @@
using Microsoft.AspNetCore.Mvc;
using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.API.Announcements;
/// <summary>
/// api.{rec.net,localhost}/api/announcement/v{1,2}/get — fetches the
/// current server announcement (shown on the watch's home tab). The
/// payload is sourced from <see cref="CommunityBoardService"/> so the
/// admin SPA's "edit announcement" form is the single place to
/// update what players see.
///
/// Wire shape per <c>RecNet/AnnouncementDTO.cs</c> — AnnouncementId,
/// AnnouncementType, LinkType, Platform are all required via
/// <c>Util.GetKey</c> (throws on miss); Title/Body/ImageName/LinkName/
/// LinkUri/CreatedAt are GetKeyOrDefault.
/// </summary>
[ApiController]
public class AnnouncementsController(CommunityBoardService communityBoard) : ControllerBase
{
[HttpGet("api/announcement/v1/get")]
[HttpGet("api/announcement/v2/get")]
public async Task<IActionResult> Current()
{
var board = await communityBoard.GetAsync();
var ann = board.CurrentAnnouncement;
if (ann is null) return Ok(Array.Empty<object>());
return Ok(new[]
{
new
{
AnnouncementId = 1L,
AnnouncementType = 0,
Title = "Server Announcement",
Body = ann.Message ?? string.Empty,
ImageName = "",
LinkType = 0,
LinkName = "",
LinkUri = ann.MoreInfoUrl ?? string.Empty,
Platform = 0,
CreatedAt = DateTime.UtcNow,
}
});
}
}
@@ -0,0 +1,43 @@
using Microsoft.AspNetCore.Mvc;
using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.API.Auth;
/// <summary>
/// Photon custom-auth webhook (api.rec.net).
///
/// Configure in Photon dashboard:
/// Authentication → Custom → URL = https://api.rec.net/api/auth/v1/photon
/// Query params sent by client: token=&lt;our_jwt&gt;
///
/// Photon ResultCode: 1 = success, 2 = fail, 3 = param error
/// </summary>
[ApiController]
public class PhotonAuthController(AuthService authService, PlayerService playerService) : ControllerBase
{
[HttpGet("api/auth/v1/photon")]
[HttpPost("api/auth/v1/photon")]
public async Task<IActionResult> Authenticate([FromQuery] string? token, [FromQuery] string? Token)
{
var jwt = token ?? Token;
if (string.IsNullOrWhiteSpace(jwt))
return Ok(new { ResultCode = 3, Message = "Missing token parameter" });
var playerId = authService.ValidateToken(jwt);
if (playerId is null)
return Ok(new { ResultCode = 2, Message = "Invalid or expired token" });
var player = await playerService.GetByIdAsync(playerId.Value);
if (player is null)
return Ok(new { ResultCode = 2, Message = "Player not found" });
return Ok(new
{
ResultCode = 1,
Message = "ok",
UserId = player.Id.ToString(),
Nickname = player.DisplayName ?? player.Username,
});
}
}
@@ -0,0 +1,308 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using DorkNet.Models.Notification;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.API.Avatar.V2;
/// <summary>
/// api.rec.net/api/avatar/v2/gifts — wardrobe gift inbox. Mirrors
/// the client's <c>RecNet.Avatars.DowloadGiftPackages</c> +
/// <c>LocalRequestGiftPackage</c> + <c>LocalConsumeGiftPackage</c>
/// flow. Wire DTO matches <c>RecNet.Avatars+GiftPackage</c>
/// (<c>Cpp2IL_CS/.../RecNet/Avatars.cs:192-555</c>) field-for-field
/// so the watch's deserialiser doesn't crash.
///
/// Server semantics:
/// - <c>GET v2/gifts</c> returns unconsumed gifts for caller.
/// - <c>POST v2/gifts/generate</c> rolls a random unlocked-avatar
/// item and stamps a <see cref="GiftPackageEntity"/> for caller —
/// used by drop tiles and "free daily" prompts.
/// - <c>POST v2/gifts/consume/{id}</c> marks consumed and appends
/// the <see cref="GiftPackageEntity.AvatarItemDescOrHairDyeDesc"/>
/// to the player's <see cref="AvatarEntity.InventoryJson"/> so it
/// shows up in their backpack on next load.
/// </summary>
[ApiController]
[Authorize]
public class AvatarGiftsController(
DorkNetDbContext db,
NotificationService notifications,
LevelService level) : ControllerBase
{
[HttpGet("api/avatar/v2/gifts")]
[HttpGet("api/avatar/v3/gifts")]
[HttpGet("api/avatar/v4/gifts")]
public async Task<IActionResult> List()
{
var pid = this.RequireCurrentPlayerId();
var rows = await db.GiftPackages
.Where(g => g.RecipientPlayerId == pid && !g.Consumed)
.OrderByDescending(g => g.CreatedAt)
.ToListAsync();
return Ok(rows.Select(ToWire));
}
public sealed record GenerateGiftRequest(int? GiftContext);
/// <summary>POST <c>api/avatar/v2/gifts/generate</c> — server picks
/// a random unlocked-avatar item and queues a gift package. The
/// 2020 client posts to this when the user clicks a free-gift
/// tile or hits a context the client maps to <c>GiftContext</c>.</summary>
[HttpPost("api/avatar/v2/gifts/generate")]
public async Task<IActionResult> Generate([FromBody] GenerateGiftRequest? req)
{
var pid = this.RequireCurrentPlayerId();
// Pick a random wardrobe StoreItem (Slug starts with
// "wardrobe-" — that's the convention StoreService uses for
// catalogue rows derived from outfits_assets bundles).
var pool = await db.StoreItems
.Where(s => s.IsActive && s.Slug.StartsWith("wardrobe-"))
.Select(s => new { s.Slug, s.DisplayName })
.ToListAsync();
if (pool.Count == 0) return StatusCode(503, "no wardrobe items in catalogue");
var rng = new Random();
var pick = pool[rng.Next(pool.Count)];
var guid = pick.Slug["wardrobe-".Length..];
// Bare GUIDs crash AvatarItem.FromRecNetString — append `,,,`
// so the watch's parse-and-iterate doesn't blow up in
// GiftManager.DequeueGift. See AvatarItemsController.cs:31-40
// for the canonical wire-format note.
var gift = new GiftPackageEntity
{
RecipientPlayerId = pid,
FromPlayerId = null,
AvatarItemType = 0, // Outfit
AvatarItemDescOrHairDyeDesc = guid + ",,,",
CurrencyType = 0, // Invalid (no currency in this gift)
GiftContext = req?.GiftContext ?? 0,
GiftRarity = RollRarity(rng),
Message = $"Enjoy your new {pick.DisplayName}!",
Platform = -1,
PackageVariant = "Standard",
PackageMaterial = string.Empty,
IsValid = true,
SupportsCurrentPlatform = true,
};
db.GiftPackages.Add(gift);
await db.SaveChangesAsync();
// Push so the watch shows the new-gift toast.
await notifications.NotifyAsync(pid,
PushNotificationId.GiftPackageReceived, ToWire(gift));
return Ok(ToWire(gift));
}
[HttpPost("api/avatar/v2/gifts/consume/{id:long}")]
public Task<IActionResult> ConsumeViaPath(long id) => ConsumeImpl(id);
/// <summary>POST <c>/api/avatar/v2/gifts/consume/</c> (note trailing
/// slash, no id in path). The watch's <c>LocalConsumeGiftPackage</c>
/// (verified at <c>Cpp2IL_ISIL/.../RecNet/Avatars_NestedType__LocalConsumeGiftPackage_d__19.txt:276-301</c>)
/// posts to this exact URL with a form-urlencoded body containing
/// <c>Id</c> and <c>UnlockedLevel</c> fields. The previous
/// implementation took <c>[FromBody] ConsumeGiftRequest</c> which
/// only accepts <c>application/json</c> — the watch's form-encoded
/// body got 415 Unsupported Media Type
/// (<c>output_log.txt:'Failed to consume gift … HTTP Error 415'</c>),
/// which left the gift unconsumed → the avatar item never landed
/// in the player's inventory → mirror-unequip rendered a missing
/// item and crashed.
///
/// Accept both JSON and form-urlencoded by reading the raw body
/// and pulling <c>Id</c> from whichever container we got.</summary>
[HttpPost("api/avatar/v2/gifts/consume")]
[Consumes("application/json", "application/x-www-form-urlencoded", "multipart/form-data", "text/plain")]
public async Task<IActionResult> ConsumeViaBody()
{
long id = 0;
if (Request.HasFormContentType)
{
var form = await Request.ReadFormAsync();
var raw = form["Id"].ToString();
if (string.IsNullOrWhiteSpace(raw)) raw = form["id"].ToString();
long.TryParse(raw, out id);
}
else
{
using var reader = new System.IO.StreamReader(Request.Body);
var body = await reader.ReadToEndAsync();
if (!string.IsNullOrWhiteSpace(body))
{
try
{
using var doc = System.Text.Json.JsonDocument.Parse(body);
if (doc.RootElement.TryGetProperty("Id", out var v) ||
doc.RootElement.TryGetProperty("id", out v))
{
if (v.ValueKind == System.Text.Json.JsonValueKind.Number)
v.TryGetInt64(out id);
else if (v.ValueKind == System.Text.Json.JsonValueKind.String)
long.TryParse(v.GetString(), out id);
}
}
catch (System.Text.Json.JsonException) { /* fall through; id stays 0 */ }
}
}
if (id == 0)
{
var raw = Request.Query["Id"].ToString();
if (string.IsNullOrWhiteSpace(raw)) raw = Request.Query["id"].ToString();
long.TryParse(raw, out id);
}
if (id == 0) return BadRequest("missing Id");
return await ConsumeImpl(id);
}
private async Task<IActionResult> ConsumeImpl(long id)
{
var pid = this.RequireCurrentPlayerId();
var gift = await db.GiftPackages.FirstOrDefaultAsync(g => g.Id == id);
if (gift is null) return NotFound();
if (gift.RecipientPlayerId != pid) return Forbid();
if (gift.Consumed) return Ok(ToWire(gift)); // idempotent
// Append avatar item to player's inventory list (JSON list of
// GUID strings). Defensive parse — corrupted JSON resets to
// empty list rather than 500.
if (!string.IsNullOrEmpty(gift.AvatarItemDescOrHairDyeDesc))
{
var avatar = await db.Avatars.FirstOrDefaultAsync(a => a.PlayerId == pid);
if (avatar is null)
{
avatar = new AvatarEntity { PlayerId = pid };
db.Avatars.Add(avatar);
}
try
{
var list = System.Text.Json.JsonSerializer
.Deserialize<List<string>>(avatar.InventoryJson) ?? new();
if (!list.Contains(gift.AvatarItemDescOrHairDyeDesc))
{
list.Add(gift.AvatarItemDescOrHairDyeDesc);
avatar.InventoryJson = System.Text.Json.JsonSerializer.Serialize(list);
avatar.UpdatedAt = DateTime.UtcNow;
}
}
catch
{
avatar.InventoryJson = System.Text.Json.JsonSerializer.Serialize(
new[] { gift.AvatarItemDescOrHairDyeDesc });
avatar.UpdatedAt = DateTime.UtcNow;
}
}
// Apply currency / XP / level rewards bundled with the gift.
// The previous implementation only handled the avatar item path,
// so token gifts from the admin SPA marked themselves consumed
// but never moved the player's wallet — the player tapped the
// gift, the box played its animation, and the balance stayed
// unchanged. CurrencyType is the canonical RecNet enum
// (1 = Tokens, 2 = Coins; 0 means "no currency in this gift").
if (gift.CurrencyType > 0 && gift.Currency > 0)
{
await level.GrantCurrencyAsync(pid, gift.CurrencyType, gift.Currency,
$"gift_consumed:{gift.Id}");
}
if (gift.Xp > 0)
{
await level.AwardXpAsync(pid, gift.Xp, $"gift_consumed:{gift.Id}");
}
// Direct level grants bypass XP curves — same pattern
// /api/admin/v1/players/{id}/levelup uses. We don't surface
// that as a separate helper; bump the column inline.
if (gift.Level > 0)
{
var player = await db.Players.FirstOrDefaultAsync(p => p.Id == pid);
if (player is not null) player.Level += gift.Level;
}
gift.Consumed = true;
gift.ConsumedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
// Profile-update push so the watch's wallet readout refreshes
// without waiting for the next /balance poll. Mirrors what
// /api/admin/v1/players/{id}/gift does after the inbox push so
// the toast number animates up the moment the box closes.
if (gift.CurrencyType > 0 || gift.Xp > 0 || gift.Level > 0)
{
await notifications.NotifyAsync(pid,
PushNotificationId.SubscriptionUpdateProfile,
new { Reason = "GiftConsumed", gift.CurrencyType, gift.Currency, gift.Xp, gift.Level });
}
return Ok(ToWire(gift));
}
/// <summary>Weighted rarity roll — Common 65, Uncommon 25,
/// Rare 8, Epic 1.8, Legendary 0.2 percent.</summary>
private static int RollRarity(Random rng) => rng.NextDouble() switch
{
< 0.65 => 0, // Common
< 0.90 => 10, // Uncommon
< 0.98 => 20, // Rare
< 0.998 => 30, // Epic
_ => 50, // Legendary
};
// Wire shape verified against the 2020.12 watch's
// NLEKGNENMCO+LOCNECLOHCA gift DTO deserializer (Cpp2IL ISIL
// NLEKGNENMCO_NestedType_LOCNECLOHCA.txt:792-946). All keys read
// via Util.GetKey<T> (mandatory): Id, Message, FromPlayerId,
// AvatarItemType, AvatarItemDesc, ConsumableItemDesc,
// EquipmentPrefabName, EquipmentModificationGuid, Platform,
// PlatformsToSpawnOn, BalanceType, CurrencyType, Currency, Xp,
// GiftRarity, GiftContext. Missing any one → KeyNotFoundException
// surfaces as "Failed to download gifts: Malformed Response" from
// BootSequence and BLOCKS LOGIN.
private static object ToWire(GiftPackageEntity g) => new
{
g.Id,
g.FromPlayerId,
g.ConsumableItemDesc,
g.AvatarItemType,
AvatarItemDesc = g.AvatarItemDescOrHairDyeDesc,
g.AvatarItemDescOrHairDyeDesc,
g.EquipmentPrefabName,
g.EquipmentModificationGuid,
g.CurrencyType,
g.Currency,
g.Xp,
g.Level,
g.GiftContext,
g.GiftRarity,
g.Message,
g.Platform,
// PlatformsToSpawnOn — required by the watch's gift DTO
// deserializer. This is a SINGLE int (4-byte field at offset
// 0x6C in LOCNECLOHCA, separate enum from Platform itself).
// The watch reads it with strict Util.GetKey<int>, so sending
// an empty array crashes Convert.ChangeType([], typeof(int))
// with "Object must implement IConvertible". -1 mirrors the
// sibling Platform field's "every platform" sentinel — no
// platform-gated gift restrictions on a private server.
PlatformsToSpawnOn = -1,
// BalanceType — required mandatory key on the gift DTO. The
// enum identifies whether the gift's payload is a Currency
// balance update (0) vs Inventory item (1) vs XP (2). For
// currency-bearing gifts we'd send 0; the safe default for
// our seed gifts (which are item-only) is 0 too since the
// watch only reads it to dispatch the balance-update side
// and we never carry one. Wrong type doesn't crash.
BalanceType = 0,
g.PackageMaterial,
g.PackageVariant,
g.Consumed,
g.IsValid,
g.ErrorMessage,
g.SupportsCurrentPlatform,
g.IsGifted,
};
}
@@ -0,0 +1,193 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using System.Text.Json;
using System.Text.Json.Serialization;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
namespace DorkNet.Server.Controllers.API.Avatar.V2;
/// <summary>
/// api.rec.net/api/avatar/v2 — older Avatar settings endpoint used by
/// the 2020 client (the V3 endpoint is for newer builds and uses a
/// different shape entirely).
///
/// Wire shape verified by disassembling RecNet.Avatar.Deserialize at
/// RVA 0xFA3C30. Required keys (Util.GetKey&lt;string&gt;): OutfitSelections,
/// HairColor, SkinColor, FaceFeatures. Empty strings satisfy the
/// deserializer; the client interprets them as "use defaults" and falls
/// back to its built-in starter avatar.
///
/// This implementation persists each set/get to <see cref="AvatarEntity"/>
/// instead of stubbing — round-trip across sessions, multi-user-safe
/// (one row per player keyed by PlayerId).
/// </summary>
[ApiController]
[Authorize]
public class AvatarV2Controller(DorkNetDbContext db, ILogger<AvatarV2Controller> logger) : ControllerBase
{
[HttpGet("api/avatar/v2")]
public async Task<ActionResult<AvatarV2Dto>> GetAvatar()
{
var pid = this.RequireCurrentPlayerId();
var a = await db.Avatars.FirstOrDefaultAsync(x => x.PlayerId == pid);
return Ok(ToDto(a));
}
/// <summary>
/// POST /api/avatar/v2/set — replace the player's avatar settings.
///
/// CRITICAL: do NOT use <c>[FromBody] AvatarV2Dto</c> here. The
/// watch's <c>RecNet.Avatar.Serialize()</c> emits each of
/// OutfitSelections / HairColor / SkinColor / FaceFeatures as
/// dictionary values whose runtime types are determined by the
/// IRecNetObject serializer it uses — FaceFeatures in particular
/// sometimes ships as a nested JSON OBJECT instead of a JSON
/// string, and the watch may also omit fields entirely when
/// they're empty.
///
/// System.Text.Json's strict type binder responds to either of those
/// with a 400 (model state invalid → ApiController auto-400) and
/// the watch then retries every frame, accumulating "Failed to
/// upload Rec Room player avatar changes: HTTP Error 400" errors
/// (output_log.txt:852+, 1000+ repeats) until the player object
/// gets destroyed.
///
/// Parse the body manually as JsonElement so we accept any shape
/// the watch sends: string-or-object for FaceFeatures (re-encode
/// the object as a string before persisting), missing fields (keep
/// existing values), null values (treat as empty).
/// </summary>
[HttpPost("api/avatar/v2")]
[HttpPost("api/avatar/v2/set")]
[Consumes("application/json", "application/x-www-form-urlencoded", "text/plain")]
public async Task<ActionResult<AvatarV2Dto>> SetAvatar()
{
var pid = this.RequireCurrentPlayerId();
// Read the raw body so we control parsing — bypasses the
// model binder's strict type matching that was causing 400s.
string raw;
using (var reader = new System.IO.StreamReader(Request.Body))
raw = await reader.ReadToEndAsync();
string? outfitSelections = null;
string? hairColor = null;
string? skinColor = null;
string? faceFeatures = null;
if (!string.IsNullOrWhiteSpace(raw))
{
try
{
using var doc = JsonDocument.Parse(raw);
var root = doc.RootElement;
if (root.ValueKind == JsonValueKind.Object)
{
outfitSelections = ReadStringField(root, "OutfitSelections");
hairColor = ReadStringField(root, "HairColor");
skinColor = ReadStringField(root, "SkinColor");
faceFeatures = ReadStringField(root, "FaceFeatures");
}
}
catch (JsonException ex)
{
// Fall through with all-null updates if the body isn't
// JSON. Log so we can see what the watch actually sends.
logger.LogWarning(
"[avatar-set] could not parse body as JSON ({Msg}); raw={Raw}",
ex.Message, raw.Length > 500 ? raw.Substring(0, 500) + "…" : raw);
}
}
var a = await db.Avatars.FirstOrDefaultAsync(x => x.PlayerId == pid);
if (a is null)
{
a = new AvatarEntity { PlayerId = pid };
db.Avatars.Add(a);
}
// Only overwrite fields the watch actually sent; missing keys
// keep the previously-stored value so a partial PATCH-style
// update doesn't accidentally wipe HairColor/SkinColor/etc.
if (outfitSelections is not null) a.OutfitSelections = outfitSelections;
if (hairColor is not null) a.HairColor = hairColor;
if (skinColor is not null) a.SkinColor = skinColor;
if (faceFeatures is not null) a.FaceFeatures = faceFeatures;
a.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return Ok(ToDto(a));
}
/// <summary>Read a field that should be a string but may arrive
/// as <c>null</c>, a JSON object/array (re-encoded back to a JSON
/// string), or be missing entirely (return null → caller keeps
/// the existing DB value).</summary>
private static string? ReadStringField(JsonElement root, string name)
{
if (!root.TryGetProperty(name, out var v)) return null;
return v.ValueKind switch
{
JsonValueKind.String => v.GetString() ?? string.Empty,
JsonValueKind.Null => string.Empty,
JsonValueKind.Object => v.GetRawText(),
JsonValueKind.Array => v.GetRawText(),
JsonValueKind.Number => v.GetRawText(),
JsonValueKind.True or JsonValueKind.False => v.GetRawText(),
_ => null,
};
}
/// <summary>Public lookup of another player's equipped avatar — the
/// client uses this to render remote players' avatars in shared
/// rooms. No auth requirement on read so anonymous calls in the
/// boot path don't 401.</summary>
[HttpGet("api/avatar/v2/equipped/{playerId:long}")]
[HttpGet("api/avatar/v3/equipped/{playerId:long}")]
[HttpGet("api/avatar/v4/equipped/{playerId:long}")]
[AllowAnonymous]
public async Task<ActionResult<AvatarV2Dto>> GetEquippedFor(long playerId)
{
var a = await db.Avatars.FirstOrDefaultAsync(x => x.PlayerId == playerId);
return Ok(ToDto(a));
}
/// <summary>Bare <c>api/avatar/v{2,3}/equipped</c> returns the
/// caller's own equipped avatar. Without the bare path the watch's
/// "what am I wearing" query falls through to the catch-all and
/// gets <c>[]</c>.</summary>
[HttpGet("api/avatar/v2/equipped")]
[HttpGet("api/avatar/v3/equipped")]
[HttpGet("api/avatar/v4/equipped")]
public async Task<ActionResult<AvatarV2Dto>> GetEquippedSelf()
{
var pid = this.RequireCurrentPlayerId();
var a = await db.Avatars.FirstOrDefaultAsync(x => x.PlayerId == pid);
return Ok(ToDto(a));
}
private static AvatarV2Dto ToDto(AvatarEntity? a) => new()
{
OutfitSelections = a?.OutfitSelections ?? string.Empty,
FaceFeatures = a?.FaceFeatures ?? string.Empty,
HairColor = a?.HairColor ?? string.Empty,
SkinColor = a?.SkinColor ?? string.Empty,
};
}
public class AvatarV2Dto
{
[JsonPropertyName("OutfitSelections")]
public string OutfitSelections { get; set; } = string.Empty;
[JsonPropertyName("HairColor")]
public string HairColor { get; set; } = string.Empty;
[JsonPropertyName("SkinColor")]
public string SkinColor { get; set; } = string.Empty;
[JsonPropertyName("FaceFeatures")]
public string FaceFeatures { get; set; } = string.Empty;
}
@@ -0,0 +1,103 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using System.Text.Json;
using System.Text.Json.Serialization;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
namespace DorkNet.Server.Controllers.API.Avatar.V3;
/// <summary>
/// api.rec.net/api/avatar/v{2,3}/saved — saved-outfit slots. Wire
/// type <c>RecNet.SavedOutfit</c> (per agent ISIL extraction):
/// <c>Slot(int), PreviewImageName(string), OutfitSelections(string),
/// FaceFeatures(string), SkinColor(string), HairColor(string)</c>.
///
/// State persists in <see cref="AvatarEntity.SavedOutfitsJson"/> as
/// a JSON list of <see cref="SavedOutfitDto"/> records keyed by
/// Slot. Caps at 20 entries per player to keep the column under
/// the SQLite text-blob comfort zone.
/// </summary>
[ApiController]
[Authorize]
public class AvatarSavedOutfitsController(DorkNetDbContext db) : ControllerBase
{
private const int MaxSlots = 20;
[HttpGet("api/avatar/v2/saved")]
[HttpGet("api/avatar/v3/saved")]
[HttpGet("api/avatar/v4/saved")]
public async Task<IActionResult> List()
{
var pid = this.RequireCurrentPlayerId();
var avatar = await db.Avatars.FirstOrDefaultAsync(a => a.PlayerId == pid);
var list = ParseList(avatar?.SavedOutfitsJson);
return Ok(list);
}
/// <summary>POST <c>v3/saved/set</c> — upsert one outfit by Slot.
/// Body is the SavedOutfit shape directly.</summary>
[HttpPost("api/avatar/v3/saved/set")]
[HttpPost("api/avatar/v2/saved/set")]
public async Task<IActionResult> Set([FromBody] SavedOutfitDto body)
{
if (body is null) return BadRequest("missing body");
var pid = this.RequireCurrentPlayerId();
var avatar = await db.Avatars.FirstOrDefaultAsync(a => a.PlayerId == pid);
if (avatar is null)
{
avatar = new AvatarEntity { PlayerId = pid };
db.Avatars.Add(avatar);
}
var list = ParseList(avatar.SavedOutfitsJson);
var idx = list.FindIndex(o => o.Slot == body.Slot);
if (idx >= 0) list[idx] = body;
else
{
if (list.Count >= MaxSlots) return BadRequest($"max {MaxSlots} saved outfits per player");
list.Add(body);
}
avatar.SavedOutfitsJson = JsonSerializer.Serialize(list);
avatar.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return Ok(body);
}
[HttpDelete("api/avatar/v3/saved/{slot:int}")]
[HttpDelete("api/avatar/v2/saved/{slot:int}")]
public async Task<IActionResult> Delete(int slot)
{
var pid = this.RequireCurrentPlayerId();
var avatar = await db.Avatars.FirstOrDefaultAsync(a => a.PlayerId == pid);
if (avatar is null) return NotFound();
var list = ParseList(avatar.SavedOutfitsJson);
var removed = list.RemoveAll(o => o.Slot == slot);
if (removed == 0) return NotFound();
avatar.SavedOutfitsJson = JsonSerializer.Serialize(list);
avatar.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return Ok();
}
private static List<SavedOutfitDto> ParseList(string? json)
{
if (string.IsNullOrWhiteSpace(json)) return new();
try
{
return JsonSerializer.Deserialize<List<SavedOutfitDto>>(json) ?? new();
}
catch { return new(); }
}
}
public sealed class SavedOutfitDto
{
[JsonPropertyName("Slot")] public int Slot { get; set; }
[JsonPropertyName("PreviewImageName")] public string PreviewImageName { get; set; } = string.Empty;
[JsonPropertyName("OutfitSelections")] public string OutfitSelections { get; set; } = string.Empty;
[JsonPropertyName("FaceFeatures")] public string FaceFeatures { get; set; } = string.Empty;
[JsonPropertyName("SkinColor")] public string SkinColor { get; set; } = string.Empty;
[JsonPropertyName("HairColor")] public string HairColor { get; set; } = string.Empty;
}
@@ -0,0 +1,482 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using System.Text.Json;
using System.Text.Json.Serialization;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.API.Avatar.V4;
/// <summary>
/// api.rec.net/api/avatar/v{2,3,4}/items — list of avatar items the
/// caller has UNLOCKED. The watch's
/// <c>RecNet.Avatars+&lt;DowloadUnlockedAvatarItems&gt;</c> coroutine
/// (Cpp2IL_ISIL/.../Avatars_NestedType__DowloadUnlockedAvatarItems_d__21.txt:149
/// hits <c>"{api/avatar/}v4/items"</c>) parses the result as
/// <c>List&lt;UnlockedAvatarItem&gt;</c>; the wardrobe UI then
/// renders a "buy" button for any item NOT in this list and an
/// "equip" affordance for items that are.
///
/// Wire shape per
/// Cpp2IL_ISIL/.../Avatars_NestedType_UnlockedAvatarItem.txt:186-212:
/// "AvatarItemType" (int — Outfit=0 / HairDye=1, NOT slot-based),
/// "AvatarItemDesc" (string — the item GUID with three empty
/// customizations appended, see below),
/// "PlatformMask" (int), "FriendlyName" (string),
/// "Tooltip" (string), "Rarity" (int).
///
/// IMPORTANT — <c>AvatarItemDesc</c> format: the client passes this
/// string to <c>AvatarItem.FromRecNetString(string)</c>
/// (Cpp2IL_ISIL/.../RecRoom/Avatar/Data/Runtime/AvatarItem.txt:81-257).
/// The desc is <c>String.Split(',')</c> into <c>guid + customization[0..3]</c>
/// — the inner overload then indexes <c>customization[0]</c>,
/// <c>customization[1]</c>, <c>customization[2]</c> and throws
/// <c>ArgumentException</c> at <c>ArraySegment.get_Current</c> if the
/// segment is shorter than 3. Every desc we ship MUST therefore have
/// at least 4 comma-separated parts, so we suffix bare GUIDs with
/// <c>",,,"</c> (three empty customizations).
///
/// CATALOG SOURCE — <c>data/avatar_item_lookup.json</c>. Generated by
/// the manual/CI-only BepInEx patch under
/// <c>tools/AvatarCatalogDumpPatch</c>. That patch reads the watch's bundled
/// <c>AvatarItemWardrobeRuntimeConfig.avatarItemDataLookup</c>
/// SerializedDictionary at boot and writes every (guid, AvatarItemData)
/// pair to JSON. THIS is the authoritative safe list — any GUID NOT
/// in this file throws <c>InvalidOperationException("Missing data
/// for guid …")</c> inside the watch's wardrobe parser, which aborts
/// the entire coroutine on the first bad item and leaves the drawer
/// empty. Items extracted from <c>outfits_assets_*.bundle</c>
/// MonoBehaviours (the older <c>data/wardrobe_items.json</c>) are
/// MASK + SWATCH asset GUIDs, NOT AvatarItem GUIDs, so that file
/// can't be used as a safelist.
///
/// PER-PLAYER ownership: the watch intersects each store tile's
/// <c>AvatarItemDesc</c> against THIS list to flip the buy-button
/// to "Already Owned." Returning the global wardrobe here therefore
/// makes every store item appear owned. We instead intersect the
/// caller's <see cref="AvatarEntity.InventoryJson"/> with the master
/// catalog so the watch sees only items the player actually owns.
/// Anonymous calls return [].
/// </summary>
[ApiController]
public class AvatarItemsController(DorkNetDbContext db, IConfiguration config) : ControllerBase
{
/// <summary>Master catalog keyed by GUID. Loaded once on first
/// request from <c>data/avatar_item_lookup.json</c>. Same
/// lazy-load idiom as before but the catalog itself is now a
/// dump of the watch's bundled
/// <c>avatarItemDataLookup</c> instead of the asset-bundle
/// MonoBehaviour scan in the previous wardrobe_items.json.</summary>
private static readonly Lazy<CatalogData> _catalog = new(LoadCatalog);
[HttpGet("api/avatar/v2/items")]
[HttpGet("api/avatar/v3/items")]
[HttpGet("api/avatar/v4/items")]
[Authorize]
public async Task<ActionResult<List<UnlockedAvatarItemDto>>> Get()
{
// Keep this switch as a fast kill-switch, but default it on now that
// the response is bounded below. The risky path is not the parser
// itself; it is feeding the 2020 watch too many BrowsableAvatarItem /
// DynamicAvatarItemImposter rows at once.
if (!config.GetValue("Avatar:EnableUnlockedItems", true))
return Ok(new List<UnlockedAvatarItemDto>());
var maxItems = Math.Clamp(config.GetValue("Avatar:MaxUnlockedItems", 30), 1, 120);
var maxPerSlot = Math.Clamp(config.GetValue("Avatar:MaxUnlockedItemsPerSlot", 4), 1, 20);
var pid = this.CurrentPlayerId();
if (pid is not long me) return Ok(new List<UnlockedAvatarItemDto>());
var avatar = await db.Avatars
.Where(a => a.PlayerId == me)
.Select(a => new { a.InventoryJson, a.OutfitSelections })
.FirstOrDefaultAsync();
var owned = ParseInventory(avatar?.InventoryJson);
var ownedHairDyeSlugs = await db.PlayerInventory
.Where(i => i.PlayerId == me && i.Quantity > 0)
.Select(i => i.ItemSlug)
.ToListAsync();
var ownedHairDyes = ownedHairDyeSlugs
.Where(slug => StoreService.TryGetHairDyePayload(slug, out _))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
if (owned.Count == 0 && ownedHairDyes.Count == 0) return Ok(new List<UnlockedAvatarItemDto>());
var catalog = _catalog.Value;
var equipped = ParseEquippedItemGuids(avatar?.OutfitSelections);
var ordered = owned
.OrderByDescending(guid => equipped.Contains(guid))
.ThenBy(guid => catalog.SlotByGuid.TryGetValue(guid, out var slot) ? slot : int.MaxValue)
.ThenBy(guid => guid, StringComparer.OrdinalIgnoreCase);
var maxOutfitItems = Math.Max(0, maxItems - ownedHairDyes.Count);
var perSlot = new Dictionary<int, int>();
var result = new List<UnlockedAvatarItemDto>(Math.Min(owned.Count, maxItems));
foreach (var guid in ordered)
{
if (result.Count >= maxOutfitItems) break;
// Intersect against the safelist. Items not in the bundled
// lookup would crash the watch's wardrobe parser, so they're
// silently dropped here.
if (!catalog.Items.TryGetValue(guid, out var item)) continue;
var slot = catalog.SlotByGuid.TryGetValue(guid, out var value) ? value : -1;
perSlot.TryGetValue(slot, out var count);
if (count >= maxPerSlot && !equipped.Contains(guid)) continue;
perSlot[slot] = count + 1;
result.Add(item);
if (result.Count >= maxItems) break;
}
foreach (var slug in ownedHairDyes)
{
if (result.Count >= maxItems) break;
if (!StoreService.TryGetHairDyePayload(slug, out var hairColorGuid)) continue;
StoreService.TryGetHairDyeName(slug, out var friendlyName);
result.Add(new UnlockedAvatarItemDto
{
AvatarItemType = AvatarItemType.HairDye,
AvatarItemDesc = hairColorGuid,
PlatformMask = unchecked((int)0xFFFFFFFF),
FriendlyName = friendlyName,
Tooltip = string.Empty,
Rarity = 0,
});
}
return Ok(result);
}
/// <summary>The set of GUIDs the bundled
/// <c>AvatarItemWardrobeRuntimeConfig.avatarItemDataLookup</c>
/// contains. Exposed so other controllers (StoreService seed,
/// PlayerService starter inventory) can validate their seed
/// data against the same safe list.</summary>
public static IReadOnlySet<string> SafeGuids => _catalog.Value.SafeGuids;
/// <summary>Master catalog of all renderable items, keyed by
/// GUID. Used by callers that need item metadata (slot type,
/// name, hair-color flags) outside of the per-player ownership
/// check.</summary>
public static IReadOnlyDictionary<string, UnlockedAvatarItemDto> Catalog
=> _catalog.Value.Items;
/// <summary>GET <c>api/avatar/v1/lockeditems</c> — metadata for
/// specific avatar item descriptions the caller does not own. The
/// store UI calls this with item descs from purchasable rows and
/// expects the same item-detail shape used by unlocked items,
/// minus ownership. We resolve against the safe runtime catalog so
/// unknown GUIDs never reach the client parser.</summary>
[HttpGet("api/avatar/v1/lockeditems")]
[AllowAnonymous]
public async Task<IActionResult> LockedItems()
{
var requested = RequestedAvatarItemGuids().ToList();
if (requested.Count == 0) return Ok(Array.Empty<object>());
var owned = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var pid = this.CurrentPlayerId();
if (pid is long me)
{
var invJson = await db.Avatars
.Where(a => a.PlayerId == me)
.Select(a => a.InventoryJson)
.FirstOrDefaultAsync();
foreach (var guid in ParseInventory(invJson))
owned.Add(guid);
}
var catalog = _catalog.Value.Items;
var rows = requested
.Where(guid => !owned.Contains(guid))
.Distinct(StringComparer.OrdinalIgnoreCase)
.Select(guid => catalog.TryGetValue(guid, out var item) ? item : null)
.Where(item => item is not null)
.Select(item => new
{
item!.AvatarItemType,
item.AvatarItemDesc,
item.FriendlyName,
item.Tooltip,
item.Rarity,
})
.ToArray();
return Ok(rows);
}
/// <summary>Per-OutfitType slot index → list of GUIDs in that slot.
/// Used by <see cref="DorkNet.Server.Services.PlayerService"/> to
/// build a starter loadout (one per slot) and a starter inventory
/// (a few per slot).</summary>
public static IReadOnlyDictionary<int, List<string>> BySlot
=> _catalog.Value.BySlot;
public static bool TryGetOutfitType(string guid, out int outfitType) =>
_catalog.Value.SlotByGuid.TryGetValue(guid, out outfitType);
private IEnumerable<string> RequestedAvatarItemGuids()
{
foreach (var pair in Request.Query)
foreach (var raw in pair.Value)
foreach (var part in (raw ?? string.Empty).Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
{
var value = part.Trim();
if (string.IsNullOrWhiteSpace(value)) continue;
var comma = value.IndexOf(',');
if (comma >= 0) value = value[..comma];
if (Guid.TryParse(value, out _)) yield return value;
}
}
private static List<string> ParseInventory(string? json)
{
if (string.IsNullOrWhiteSpace(json)) return new();
try
{
var strings = JsonSerializer.Deserialize<List<string>>(json);
if (strings is not null) return strings;
}
catch { }
try
{
using var doc = JsonDocument.Parse(json);
if (doc.RootElement.ValueKind != JsonValueKind.Array) return new();
var result = new List<string>();
foreach (var element in doc.RootElement.EnumerateArray())
{
string? value = null;
if (element.ValueKind == JsonValueKind.String)
value = element.GetString();
else if (element.ValueKind == JsonValueKind.Object &&
element.TryGetProperty("itemId", out var itemId) &&
itemId.ValueKind == JsonValueKind.String)
value = itemId.GetString();
if (string.IsNullOrWhiteSpace(value)) continue;
const string wardrobePrefix = "wardrobe-";
if (value.StartsWith(wardrobePrefix, StringComparison.Ordinal))
value = value[wardrobePrefix.Length..];
var comma = value.IndexOf(',');
if (comma >= 0)
value = value[..comma];
result.Add(value);
}
return result
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
}
catch { return new(); }
}
private static HashSet<string> ParseEquippedItemGuids(string? outfitSelections)
{
var result = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
if (string.IsNullOrWhiteSpace(outfitSelections)) return result;
foreach (var selection in outfitSelections.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
{
var comma = selection.IndexOf(',');
var guid = comma >= 0 ? selection[..comma] : selection;
if (!string.IsNullOrWhiteSpace(guid))
result.Add(guid.Trim());
}
return result;
}
private sealed record CatalogData(
IReadOnlyDictionary<string, UnlockedAvatarItemDto> Items,
IReadOnlySet<string> SafeGuids,
IReadOnlyDictionary<int, List<string>> BySlot,
IReadOnlyDictionary<string, int> SlotByGuid);
private static CatalogData LoadCatalog()
{
// Try both lowercase 'data/' (csproj None+Link target) and
// uppercase 'Data/' (git-tracked source folder) so case-sensitivity
// mismatches between Windows (where the dev wrote the path) and
// Linux (where Coolify builds the container) can't lose the lookup
// file. Without this fallback, a single missed file → empty
// SafeGuids → StoreService.SeedAsync skips its stale-row cleanup
// → 418 swatch-GUID rows ship live → every store tile renders as
// Hat_Angler.
var candidates = new[]
{
Path.Combine(AppContext.BaseDirectory, "data", "avatar_item_lookup.json"),
Path.Combine(AppContext.BaseDirectory, "Data", "avatar_item_lookup.json"),
Path.Combine(Directory.GetCurrentDirectory(), "data", "avatar_item_lookup.json"),
Path.Combine(Directory.GetCurrentDirectory(), "Data", "avatar_item_lookup.json"),
};
var path = candidates.FirstOrDefault(System.IO.File.Exists);
Console.WriteLine(path is null
? $"[avatar-catalog] avatar_item_lookup.json not found in any of: {string.Join(", ", candidates)}"
: $"[avatar-catalog] loading from {path}");
if (path is null) return new CatalogData(
new Dictionary<string, UnlockedAvatarItemDto>(),
new HashSet<string>(),
new Dictionary<int, List<string>>(),
new Dictionary<string, int>());
using var fs = System.IO.File.OpenRead(path);
var dump = JsonSerializer.Deserialize<LookupFile>(fs,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true })
?? new LookupFile();
var items = new Dictionary<string, UnlockedAvatarItemDto>(dump.Items.Count, StringComparer.OrdinalIgnoreCase);
var safe = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var bySlot = new Dictionary<int, List<string>>();
var slotByGuid = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
foreach (var row in dump.Items)
{
if (string.IsNullOrEmpty(row.Guid)) continue;
safe.Add(row.Guid);
// Prefer the baked display_name from the JSON if present
// (tools/add-display-names.py writes it once per refresh).
// Falls back to the raw-name cleanup if the field is missing
// — e.g. partial dumps or older fixture files.
var pretty = !string.IsNullOrEmpty(row.DisplayName)
? row.DisplayName
: PrettyName(row.Name);
items[row.Guid] = new UnlockedAvatarItemDto
{
// AvatarItemType is the wire enum the watch parses
// (Outfit=0 / HairDye=1), not the slot. Rows in
// avatarItemDataLookup are wearable avatar items, including
// hair meshes that *use* hair color. They must still be sent
// as Outfit; sending HairDye makes the client treat the item
// GUID as a color GUID and log "Can not find hair dye color".
AvatarItemType = AvatarItemType.Outfit,
AvatarItemDesc = row.Guid + ",,,",
PlatformMask = unchecked((int)0xFFFFFFFF),
FriendlyName = pretty,
Tooltip = string.Empty,
Rarity = 0,
};
if (!bySlot.TryGetValue(row.OutfitType, out var lst))
bySlot[row.OutfitType] = lst = new List<string>();
lst.Add(row.Guid);
slotByGuid[row.Guid] = row.OutfitType;
}
return new CatalogData(items, safe, bySlot, slotByGuid);
}
private static string PrettyName(string raw)
{
if (string.IsNullOrEmpty(raw)) return string.Empty;
// Strip leading/trailing parens that the bundled config wraps
// names in, then turn underscores into spaces. Keep it simple —
// the watch shows whatever string we send.
var trimmed = raw.Trim().TrimStart('(').TrimEnd(')');
// Drop common asset-name suffixes. Items in the runtime config
// use the raw asset name (e.g. "Hat_Wizard", "shirt_sherlock");
// the watch never normalises these, so we do it server-side.
var s = trimmed.Replace('_', ' ').Trim();
return s;
}
/// <summary>Shape of <c>data/avatar_item_lookup.json</c> as written
/// by <c>tools/AvatarCatalogDumpPatch</c>.</summary>
private sealed class LookupFile
{
[JsonPropertyName("items")]
public List<LookupItem> Items { get; set; } = new();
// Auxiliary GUID lists (prefab/mask/swatch/decal). Not used
// server-side yet — they'd matter for resolving asset
// references the watch might subsequently request, but the
// wire protocol only ever ships the AvatarItem GUID.
[JsonPropertyName("prefab_guids")]
public List<string> PrefabGuids { get; set; } = new();
[JsonPropertyName("mask_guids")]
public List<string> MaskGuids { get; set; } = new();
[JsonPropertyName("swatch_guids")]
public List<string> SwatchGuids { get; set; } = new();
[JsonPropertyName("decal_guids")]
public List<string> DecalGuids { get; set; } = new();
}
private sealed class LookupItem
{
[JsonPropertyName("guid")] public string Guid { get; set; } = string.Empty;
[JsonPropertyName("name")] public string Name { get; set; } = string.Empty;
// Algorithmically derived from `name` by
// tools/add-display-names.py — Rec Room's official UI strings
// live in Unity asset bundles we can't read from a code dump,
// so this is a best-effort cleanup. Optional — server falls
// back to PrettyName() if missing.
[JsonPropertyName("display_name")] public string? DisplayName { get; set; }
[JsonPropertyName("outfit_type")] public int OutfitType { get; set; }
[JsonPropertyName("outfit_set")] public int OutfitSet { get; set; }
[JsonPropertyName("drawer_index")] public int DrawerIndex { get; set; }
[JsonPropertyName("hides_hair")] public bool HidesHair { get; set; }
[JsonPropertyName("hides_hand")] public bool HidesHand { get; set; }
// hides_left_ear / hides_right_ear were added to AvatarItemData
// in the 2020.12.18 client build. Optional in the JSON so older
// dumps still deserialize.
[JsonPropertyName("hides_left_ear")] public bool HidesLeftEar { get; set; }
[JsonPropertyName("hides_right_ear")] public bool HidesRightEar { get; set; }
[JsonPropertyName("uses_hair_color")] public bool UsesHairColor { get; set; }
[JsonPropertyName("uses_team_color")] public bool UsesTeamColor { get; set; }
[JsonPropertyName("is_team_item")] public bool IsTeamItem { get; set; }
}
/// <summary>Mirrors <c>RecNet.Avatars+AvatarItemType</c>
/// (Avatars.cs:186-190).</summary>
private static class AvatarItemType
{
public const int Outfit = 0;
public const int HairDye = 1;
}
/// <summary>OutfitType slot enum mirrored from
/// <c>RecRoom.Avatar.Data.Shared.OutfitType</c>. Used by other
/// callers (PlayerService, StoreService) for slot-aware seeding.</summary>
public static class Slot
{
public const int Hat = 0;
public const int BackHead = 1;
public const int Hair = 2;
public const int Eye = 10;
public const int Mouth = 20;
public const int Neck = 100;
public const int Shirt = 101;
public const int Belt = 102;
public const int Pocket = 103;
public const int TeamShirt = 104;
public const int Wrist = 200;
public const int Glove = 201;
public const int Watch = 202;
public const int TeamWrist = 203;
}
}
public class UnlockedAvatarItemDto
{
[JsonPropertyName("AvatarItemType")]
public int AvatarItemType { get; set; }
[JsonPropertyName("AvatarItemDesc")]
public string AvatarItemDesc { get; set; } = string.Empty;
[JsonPropertyName("PlatformMask")]
public int PlatformMask { get; set; }
[JsonPropertyName("FriendlyName")]
public string FriendlyName { get; set; } = string.Empty;
[JsonPropertyName("Tooltip")]
public string Tooltip { get; set; } = string.Empty;
[JsonPropertyName("Rarity")]
public int Rarity { get; set; }
}
@@ -0,0 +1,88 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
namespace DorkNet.Server.Controllers.API.BugReporting;
/// <summary>
/// api.rec.net/api/bugreporting/* — in-game "Report a Bug" UI.
/// Wire request DTO matches <c>RecNet.BugReporting+BugReportDTO</c>
/// (<c>Cpp2IL_CS/.../RecNet/BugReporting.cs</c>): <c>Summary,
/// Description, TestCaseKey, BuildVersion, BuildTimestamp,
/// BundleVersionCode</c>.
///
/// Plus <c>POST /api/bugreporting/v1/submit</c> (legacy v1 path) and
/// <c>POST /api/bugreporting/v2/submit</c> (renamed v2). The
/// <c>ReportBug</c> client API also includes screenshot + log byte
/// arrays as multipart parts; we accept-and-discard those (no
/// storage flow yet) but persist the text fields.
/// </summary>
[ApiController]
[Authorize]
public class BugReportsController(DorkNetDbContext db) : ControllerBase
{
private long Me => this.RequireCurrentPlayerId();
public sealed class BugReportRequest
{
public string? Summary { get; set; }
public string? Description { get; set; }
public string? TestCaseKey { get; set; }
public string? BuildVersion { get; set; }
public long? BuildTimestamp { get; set; }
public int? BundleVersionCode { get; set; }
}
[HttpPost("api/bugreporting/v1/submit")]
[HttpPost("api/bugreporting/v2/submit")]
[HttpPost("api/bugreporting/v1/reportbug")]
[HttpPost("api/bugreporting/v2/reportbug")]
[Consumes("application/json")]
public async Task<IActionResult> SubmitJson([FromBody] BugReportRequest req)
=> await PersistAsync(req);
/// <summary>Multipart form variant — the watch posts screenshot
/// + log as separate parts. We pull the text-only fields and
/// drop the binary attachments.</summary>
[HttpPost("api/bugreporting/v1/submit-multipart")]
[HttpPost("api/bugreporting/v2/reportbug-multipart")]
[Consumes("multipart/form-data")]
public async Task<IActionResult> SubmitMultipart(
[FromForm(Name = "Summary")] string? summary,
[FromForm(Name = "Description")] string? description,
[FromForm(Name = "TestCaseKey")] string? testCaseKey,
[FromForm(Name = "BuildVersion")] string? buildVersion,
[FromForm(Name = "BuildTimestamp")] long? buildTimestamp)
=> await PersistAsync(new BugReportRequest
{
Summary = summary,
Description = description,
TestCaseKey = testCaseKey,
BuildVersion = buildVersion,
BuildTimestamp = buildTimestamp,
});
private async Task<IActionResult> PersistAsync(BugReportRequest req)
{
var pid = Me;
var summary = (req.Summary ?? string.Empty).Trim();
var body = (req.Description ?? string.Empty).Trim();
if (summary.Length == 0 && body.Length == 0)
return BadRequest(new { Error = "summary or description required" });
db.BugReports.Add(new BugReportEntity
{
ReporterPlayerId = pid,
Title = summary[..Math.Min(128, summary.Length)],
Body = body[..Math.Min(4000, body.Length)],
ClientVersion = req.BuildVersion ?? string.Empty,
Platform = HttpContext.Request.Headers.UserAgent.ToString()[..Math.Min(32, HttpContext.Request.Headers.UserAgent.ToString().Length)],
Category = string.IsNullOrEmpty(req.TestCaseKey) ? "bug" : $"bug:{req.TestCaseKey}",
});
await db.SaveChangesAsync();
return Ok(new { Submitted = true });
}
}
@@ -0,0 +1,40 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
namespace DorkNet.Server.Controllers.API.Cards;
/// <summary>
/// api.{rec.net,localhost}/api/cards/v1/all — home-screen card panel
/// (announcements, promos, daily-bonus tiles). Returns global cards
/// PLUS any cards targeted at the caller, sorted by Priority then
/// CreatedAt. Expired cards filtered out server-side.
/// </summary>
[ApiController]
public class CardsController(DorkNetDbContext db) : ControllerBase
{
[HttpGet("api/cards/v1/all")]
public async Task<IActionResult> All()
{
var pid = this.CurrentPlayerId();
var now = DateTime.UtcNow;
var rows = await db.Cards
.Where(c => (c.PlayerId == null || c.PlayerId == pid) &&
(c.ExpiresAt == null || c.ExpiresAt > now))
.OrderByDescending(c => c.Priority).ThenByDescending(c => c.CreatedAt)
.Take(50)
.Select(c => new
{
c.Id,
c.Title,
c.Body,
c.ImageName,
c.ActionUrl,
c.Category,
c.CreatedAt,
})
.ToListAsync();
return Ok(rows);
}
}
@@ -0,0 +1,99 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using DorkNet.Server.Data;
using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.API.CommunityBoard;
/// <summary>
/// api.rec.net/api/communityboard/v1/current — backs the dorm's
/// community board. The watch's
/// <c>RecNet.CommunityBoard.GetCurrent</c> hits
/// <c>"{api/communityboard/}v1/current"</c>
/// (Cpp2IL_ISIL/.../CommunityBoard.txt:902) and parses the response as
/// a <c>CommunityBoardDTO</c> with FeaturedPlayer / FeaturedRoomGroup /
/// CurrentAnnouncement / InstagramImages / Videos
/// (CommunityBoard_NestedType_CommunityBoardDTO.txt:460-480).
///
/// State lives in <see cref="CommunityBoardService"/> as a single
/// DB row keyed at <c>CommunityBoardRows.Id = 1</c>. Admins update it
/// via <c>POST /api/admin/v1/communityboard</c> from the admin UI.
/// </summary>
[ApiController]
public class CommunityBoardController(
CommunityBoardService board,
DorkNetDbContext db,
DomainConfig domain) : ControllerBase
{
[HttpGet("/api/communityboard/v1/current")]
[HttpGet("/api/communityboard/v2/current")]
public async Task<IActionResult> Current()
{
var state = await board.GetAsync();
// InstagramImage URL backfill — the watch's
// InstagramImageDTO.Deserialize calls a string-typed GetKey for
// both ImageName AND ImageUrl
// (CommunityBoard_NestedType_InstagramImageDTO.txt:85-91); an
// empty URL yields a broken tile. The admin SPA persists only
// ImageName, so we derive the URL on read against the configured
// deployment apex (DORKNET_DOMAIN).
var instagram = state.InstagramImages.Select(img => new
{
ImageName = img.ImageName,
ImageUrl = string.IsNullOrEmpty(img.ImageUrl) && !string.IsNullOrEmpty(img.ImageName)
? $"https://{domain.Sub("img")}/{img.ImageName}"
: img.ImageUrl,
}).ToArray();
// FeaturedRoomGroup expansion — the watch's FeaturedRoomGroupDTO
// (Rooms_NestedType_FeaturedRoomGroupDTO.txt:75-93) reads
// FeaturedRooms as <c>List&lt;FeaturedRoom&gt;</c> where each
// FeaturedRoom is <c>{RoomName, RoomId, ImageName}</c> with
// RoomId required (Util.GetKey at FeaturedRoom.txt:415-417).
// The admin SPA persists the list as a bare <c>List&lt;long&gt;</c>
// of room ids — which the watch can't deserialize, throwing
// KeyNotFoundException for "RoomId" on each item. With the list
// throwing, GetCurrentCommunityBoardData fails and the whole
// community-board panel renders empty. Expand the ids to full
// FeaturedRoom shapes here so the wire payload deserialises.
object? featuredRoomGroup = null;
if (state.FeaturedRoomGroup is { } group)
{
var ids = group.FeaturedRooms.ToList();
var rooms = ids.Count == 0
? new()
: await db.Rooms
.Where(r => ids.Contains(r.Id))
.Select(r => new { r.Id, r.Name, r.ImageName })
.ToDictionaryAsync(r => r.Id);
// 2020.12 FeaturedRoomGroup deserializer (NMPFCIJPODA.PPGFHEDFBEA)
// reads required keys: FeaturedRoomGroupId, Name, Rooms (NOT
// FeaturedRooms). Missing/renamed → KeyNotFoundException →
// breaks the boot promise chain on the community-board fetch.
featuredRoomGroup = new
{
FeaturedRoomGroupId = 0L,
Name = group.Name ?? string.Empty,
Rooms = ids.Select(id => rooms.TryGetValue(id, out var r)
? new { RoomName = r.Name, RoomId = r.Id, ImageName = r.ImageName ?? string.Empty }
: new { RoomName = string.Empty, RoomId = id, ImageName = string.Empty })
.ToArray(),
// Keep the old key around for any 2020.03 clients hitting prod.
FeaturedRooms = ids.Select(id => rooms.TryGetValue(id, out var r)
? new { RoomName = r.Name, RoomId = r.Id, ImageName = r.ImageName ?? string.Empty }
: new { RoomName = string.Empty, RoomId = id, ImageName = string.Empty })
.ToArray(),
};
}
return Ok(new
{
FeaturedPlayer = state.FeaturedPlayer,
FeaturedRoomGroup = featuredRoomGroup,
CurrentAnnouncement = state.CurrentAnnouncement,
InstagramImages = instagram,
Videos = state.Videos,
});
}
}
@@ -0,0 +1,157 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using System.Security.Cryptography;
using System.Text.Json;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
namespace DorkNet.Server.Controllers.API.Compatibility;
[ApiController]
public class CompatibilityFeatureController(DorkNetDbContext db) : ControllerBase
{
[HttpPost("api/banappeal/generateCode")]
[Authorize]
public async Task<IActionResult> GenerateBanAppealCode()
{
var pid = this.RequireCurrentPlayerId();
var code = $"BA-{RandomNumberGenerator.GetInt32(100000, 999999)}";
db.PlayerSettings.Add(new PlayerSettingEntity
{
PlayerId = pid,
Key = $"banappeal:{DateTime.UtcNow:yyyyMMddHHmmss}",
Value = code,
});
await db.SaveChangesAsync();
return Ok(new { value = code });
}
[HttpPost("api/CampusCard/v1/UpdateAndGetSubscription")]
[Authorize]
[Consumes("application/json", "application/x-www-form-urlencoded", "multipart/form-data")]
public async Task<IActionResult> UpdateAndGetCampusCardSubscription()
{
var pid = this.RequireCurrentPlayerId();
var req = await ReadCampusCardRequestAsync();
var targetId = req.PlatformAccountSubscribedPlayerId > 0
? req.PlatformAccountSubscribedPlayerId
: pid;
if (targetId != pid && req.Subscription)
{
var exists = await db.Subscriptions.AnyAsync(s =>
s.SubscriberPlayerId == pid && s.TargetPlayerId == targetId);
if (!exists)
{
db.Subscriptions.Add(new SubscriptionEntity
{
SubscriberPlayerId = pid,
TargetPlayerId = targetId,
});
await db.SaveChangesAsync();
}
}
else if (targetId != pid && !req.Subscription)
{
await db.Subscriptions
.Where(s => s.SubscriberPlayerId == pid && s.TargetPlayerId == targetId)
.ExecuteDeleteAsync();
}
// 2020.12 deserializer for this response expects `subscription` to be a
// nested OBJECT (Subscription DTO with subscriptionId/recNetPlayerId/...
// fields), NOT a boolean. Sending a bool throws InvalidCastException
// on the client ("Unable to cast object of type 'Boolean' to type
// 'Dictionary`2'"). Send null when there's no active subscription —
// the client's generic-cast helper returns default(T) which is fine.
return Ok(new
{
subscription = (object?)null,
platformAccountSubscribedPlayerId = targetId,
});
}
[HttpPost("api/clubreporting/v1/report")]
[Authorize]
[Consumes("application/json", "application/x-www-form-urlencoded", "multipart/form-data")]
public async Task<IActionResult> ClubReport()
{
var pid = this.RequireCurrentPlayerId();
var req = await ReadClubReportAsync();
var club = req.ClubId > 0
? await db.Clubs.FirstOrDefaultAsync(c => c.Id == req.ClubId)
: null;
if (club is null) return Ok(new { Success = false, Message = "club_not_found" });
var message = $"Club report: {req.Message ?? string.Empty}".Trim();
if (message.Length > 1000) message = message[..1000];
db.Reports.Add(new ReportEntity
{
ReporterPlayerId = pid,
TargetPlayerId = club.CreatorPlayerId,
Category = req.ReportCategory,
Message = message,
});
await db.SaveChangesAsync();
return Ok(new { Success = true, Message = string.Empty });
}
private async Task<CampusCardRequest> ReadCampusCardRequestAsync()
{
if (Request.HasFormContentType)
{
var form = await Request.ReadFormAsync();
return new CampusCardRequest
{
Subscription = bool.TryParse(form["subscription"].FirstOrDefault(), out var s) && s,
PlatformAccountSubscribedPlayerId = long.TryParse(
form["platformAccountSubscribedPlayerId"].FirstOrDefault(), out var id) ? id : 0,
};
}
try
{
return await JsonSerializer.DeserializeAsync<CampusCardRequest>(
Request.Body,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true }) ?? new CampusCardRequest();
}
catch (JsonException) { return new CampusCardRequest(); }
}
private async Task<ClubReportRequest> ReadClubReportAsync()
{
if (Request.HasFormContentType)
{
var form = await Request.ReadFormAsync();
return new ClubReportRequest
{
ClubId = long.TryParse(form["clubId"].FirstOrDefault()
?? form["ClubId"].FirstOrDefault(), out var clubId) ? clubId : 0,
ReportCategory = int.TryParse(form["reportCategory"].FirstOrDefault()
?? form["ReportCategory"].FirstOrDefault(), out var cat) ? cat : 5,
Message = form["message"].FirstOrDefault() ?? form["Message"].FirstOrDefault(),
};
}
try
{
return await JsonSerializer.DeserializeAsync<ClubReportRequest>(
Request.Body,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true }) ?? new ClubReportRequest();
}
catch (JsonException) { return new ClubReportRequest(); }
}
private sealed class CampusCardRequest
{
public bool Subscription { get; set; }
public long PlatformAccountSubscribedPlayerId { get; set; }
}
private sealed class ClubReportRequest
{
public long ClubId { get; set; }
public int ReportCategory { get; set; } = 5;
public string? Message { get; set; }
}
}
@@ -0,0 +1,79 @@
using Microsoft.AspNetCore.Mvc;
using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.API.Config.V1;
[ApiController]
[Route("api/[controller]/v1")]
public class ConfigController(ConfigService configService) : ControllerBase
{
[HttpGet("motd")]
public ActionResult<string> GetMotd()
{
var cfg = configService.GetConfig($"{Request.Scheme}://{Request.Host}");
return Ok(cfg.MessageOfTheDay);
}
[HttpGet("objectives")]
public ActionResult<List<object>> GetObjectives() => Ok(new List<object>());
// AmplitudeConfig.Deserialize expects key "AmplitudeKey"
[HttpGet("amplitude")]
public ActionResult<object> GetAmplitude() => Ok(new { AmplitudeKey = string.Empty });
/// <summary>GET <c>api/config/v1/freegiftbutton</c> — the
/// 2020.12 client deserializes this endpoint as a raw Boolean.
/// This server does not run the timed free-gift campaign, so the
/// button is hidden.</summary>
[HttpGet("freegiftbutton")]
public IActionResult FreeGiftButton() => Content("false", "application/json");
/// <summary>
/// `Config.GetCohortNUXButtonConfigs(cohortId)` — returns the four
/// "What would you like to do first?" buttons shown in the OOBE
/// (RegistrationSceneNUX → ChooseCohortScreen). Each button maps to
/// a room the new player can teleport into.
///
/// Wire shape per CohortNUXButtonConfig (TypeDefIndex 11516) — list of:
/// Version (int)
/// ButtonNumber (int, 0..3 → Button1..Button4)
/// Override (int, 0=None, 1=Hotlist, 2=Custom)
/// CustomRoomName (string)
/// CustomTitle (string)
/// DefaultRoomName (string)
/// DefaultTitle (string)
///
/// The screen picks `CustomRoomName` if Override=Custom, else the
/// `DefaultRoomName` (resolved by Rooms.GetByName). For simplicity all
/// four use Override=None and route to Rec Room Original rooms we
/// already seeded — picking the most newcomer-friendly ones.
/// </summary>
[HttpGet("cohortnux/{cohortId:int}")]
public IActionResult GetCohortNux(int cohortId) => Ok(new[]
{
new {
Version = 1, ButtonNumber = 0, Override = 0,
CustomRoomName = "", CustomTitle = "",
DefaultRoomName = "RecCenter",
DefaultTitle = "Hang out at the Rec Center",
},
new {
Version = 1, ButtonNumber = 1, Override = 0,
CustomRoomName = "", CustomTitle = "",
DefaultRoomName = "Paintball",
DefaultTitle = "Play Paintball",
},
new {
Version = 1, ButtonNumber = 2, Override = 0,
CustomRoomName = "", CustomTitle = "",
DefaultRoomName = "Dodgeball",
DefaultTitle = "Play Dodgeball",
},
new {
Version = 1, ButtonNumber = 3, Override = 0,
CustomRoomName = "", CustomTitle = "",
DefaultRoomName = "GoldenTrophy",
DefaultTitle = "Quest for the Golden Trophy",
},
});
}
@@ -0,0 +1,19 @@
using Microsoft.AspNetCore.Mvc;
using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.API.Config.V1;
/// <summary>
/// Returns the full Rec Room service URL map. Some builds fetch this standalone
/// at /api/config/v1/services to discover where the other subdomains live.
/// Apex comes from <see cref="DomainConfig"/> (DORKNET_DOMAIN), so a single
/// env var controls every URL emitted.
/// </summary>
[ApiController]
[Route("api/config/v1")]
public class ServiceUrlsController(DomainConfig domain) : ControllerBase
{
[HttpGet("services")]
public ActionResult<Dictionary<string, string>> Get()
=> Ok(ConfigService.BuildServiceUrlMap(domain.Apex));
}
@@ -0,0 +1,17 @@
using Microsoft.AspNetCore.Mvc;
using DorkNet.Models.Config;
using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.API.Config.V2;
[ApiController]
[Route("api/[controller]/v2")]
public class ConfigController(ConfigService configService) : ControllerBase
{
[HttpGet]
public ActionResult<RecRoomConfig> GetConfig()
{
var baseUrl = $"{Request.Scheme}://{Request.Host}";
return Ok(configService.GetConfig(baseUrl));
}
}
@@ -0,0 +1,11 @@
using Microsoft.AspNetCore.Mvc;
namespace DorkNet.Server.Controllers.API.Events.V3;
[ApiController]
[Route("api/[controller]/v3")]
public class EventsController : ControllerBase
{
[HttpGet("list")]
public ActionResult<List<object>> GetActiveEvents() => Ok(new List<object>());
}
@@ -0,0 +1,15 @@
using Microsoft.AspNetCore.Mvc;
namespace DorkNet.Server.Controllers.API.GameConfigurations;
[ApiController]
public class GameConfigurationsController : ControllerBase
{
// Actual path used by the 2020 build
[HttpGet("api/gameconfigs/v1/all")]
[HttpGet("api/gameconfigs/v1")]
// Legacy path we originally assumed
[HttpGet("api/gameconfigurations/v1/all")]
[HttpGet("api/gameconfigurations/v1")]
public ActionResult<List<object>> GetAll() => Ok(new List<object>());
}
@@ -0,0 +1,151 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using System.Text.Json;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.API.GameRewards;
[ApiController]
[Authorize]
public class GameRewardsController(DorkNetDbContext db, LevelService level) : ControllerBase
{
private long Me => this.RequireCurrentPlayerId();
[HttpGet("api/gamerewards/v1/pending")]
public async Task<IActionResult> Pending()
{
var pid = Me;
var rows = await db.GameRewardSelections
.Where(r => r.PlayerId == pid && r.SelectedAt == null)
.OrderBy(r => r.CreatedAt)
.Take(20)
.ToListAsync();
return Ok(rows.Select(ToWire));
}
[HttpPost("api/gamerewards/v1/request")]
[Consumes("application/json", "application/x-www-form-urlencoded", "multipart/form-data")]
public async Task<IActionResult> RequestReward()
{
var req = await ReadRewardRequestAsync();
var row = new GameRewardSelectionEntity
{
PlayerId = Me,
RewardType = req.RewardType,
GiftContext = req.GiftContext,
Message = string.IsNullOrWhiteSpace(req.Message) ? "Choose a reward" : req.Message[..Math.Min(req.Message.Length, 256)],
};
db.GameRewardSelections.Add(row);
await db.SaveChangesAsync();
return Ok(new { Success = true, Error = string.Empty });
}
[HttpPost("api/gamerewards/v1/select")]
[Consumes("application/json", "application/x-www-form-urlencoded", "multipart/form-data")]
public async Task<IActionResult> SelectReward()
{
var req = await ReadRewardSelectAsync();
var pid = Me;
var row = await db.GameRewardSelections
.FirstOrDefaultAsync(r => r.Id == req.RewardSelectionId && r.PlayerId == pid && r.SelectedAt == null);
if (row is null) return Ok(new { Success = false, Error = "reward_not_found" });
row.SelectedAt = DateTime.UtcNow;
row.SelectedGiftDropId = req.GiftDropId;
await level.GrantCurrencyAsync(pid, 2, 25, $"gameReward:{row.Id}");
await level.AwardXpAsync(pid, 25, $"gameReward:{row.Id}");
await db.SaveChangesAsync();
return Ok(new { Success = true, Error = string.Empty });
}
private static object ToWire(GameRewardSelectionEntity row) => new
{
RewardSelectionId = row.Id,
row.Message,
row.GiftContext,
row.RewardType,
GiftDrop1 = GiftDrop(row.Id * 10 + 1, "Tokens", currency: 25),
GiftDrop2 = GiftDrop(row.Id * 10 + 2, "XP", currency: 0),
GiftDrop3 = GiftDrop(row.Id * 10 + 3, "Bonus Tokens", currency: 25),
row.CreatedAt,
};
private static object GiftDrop(long id, string name, int currency) => new
{
GiftDropId = id,
FriendlyName = name,
Tooltip = string.Empty,
ConsumableItemDesc = string.Empty,
AvatarItemDesc = string.Empty,
AvatarItemType = 0,
EquipmentPrefabName = string.Empty,
EquipmentModificationGuid = string.Empty,
IsQuery = false,
Unique = false,
SubscribersOnly = false,
Rarity = 0,
CurrencyType = 2,
Currency = currency,
Context = 0,
ItemSetId = 0,
ItemSetFriendlyName = string.Empty,
};
private async Task<RewardRequest> ReadRewardRequestAsync()
{
if (Request.HasFormContentType)
{
var form = await Request.ReadFormAsync();
return new RewardRequest
{
RewardType = int.TryParse(form["rewardType"].FirstOrDefault(), out var rt) ? rt : 0,
GiftContext = int.TryParse(form["giftContext"].FirstOrDefault(), out var gc) ? gc : 0,
Message = form["Message"].FirstOrDefault() ?? form["message"].FirstOrDefault(),
};
}
try
{
return await JsonSerializer.DeserializeAsync<RewardRequest>(
Request.Body,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true }) ?? new RewardRequest();
}
catch (JsonException) { return new RewardRequest(); }
}
private async Task<RewardSelect> ReadRewardSelectAsync()
{
if (Request.HasFormContentType)
{
var form = await Request.ReadFormAsync();
return new RewardSelect
{
RewardSelectionId = long.TryParse(form["rewardSelectionId"].FirstOrDefault(), out var id) ? id : 0,
GiftDropId = int.TryParse(form["giftDropId"].FirstOrDefault(), out var giftDropId) ? giftDropId : 0,
};
}
try
{
return await JsonSerializer.DeserializeAsync<RewardSelect>(
Request.Body,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true }) ?? new RewardSelect();
}
catch (JsonException) { return new RewardSelect(); }
}
private sealed class RewardRequest
{
public int RewardType { get; set; }
public int GiftContext { get; set; }
public string? Message { get; set; }
}
private sealed class RewardSelect
{
public long RewardSelectionId { get; set; }
public int GiftDropId { get; set; }
}
}
@@ -0,0 +1,67 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using DorkNet.Models.GameSessions;
using DorkNet.Server.Auth;
using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.API.GameSessions.V2;
[ApiController]
[Route("api/[controller]/v2")]
[Authorize]
public class GameSessionsController(GameSessionService sessionService) : ControllerBase
{
private long CurrentPlayerId => this.RequireCurrentPlayerId();
[HttpPost("joinRandom")]
public async Task<ActionResult<JoinGameSessionResponse>> JoinRandom([FromBody] JoinRandomGameSessionRequest req)
{
var session = await sessionService.JoinOrCreateAsync(req.RoomId, req.ActivityLevelId, req.RegionId);
return Ok(new JoinGameSessionResponse
{
Result = JoinGameErrorCode.Success,
GameSession = session,
});
}
[HttpPost("joinById")]
public async Task<ActionResult<JoinGameSessionResponse>> JoinById([FromBody] JoinByIdRequest req)
{
var session = await sessionService.GetByIdAsync(req.GameSessionId);
if (session is null)
{
return Ok(new JoinGameSessionResponse { Result = JoinGameErrorCode.NotFound });
}
if (session.IsFull)
{
return Ok(new JoinGameSessionResponse { Result = JoinGameErrorCode.Full });
}
return Ok(new JoinGameSessionResponse
{
Result = JoinGameErrorCode.Success,
GameSession = session,
});
}
[HttpPost("leave")]
public async Task<ActionResult> Leave([FromBody] long sessionId)
{
await sessionService.PlayerLeftAsync(sessionId);
return Ok();
}
[HttpGet("{id:long}")]
public async Task<ActionResult<GameSession>> GetSession(long id)
{
var session = await sessionService.GetByIdAsync(id);
if (session is null) return NotFound();
return Ok(session);
}
}
public class JoinByIdRequest
{
public long GameSessionId { get; set; }
}
@@ -0,0 +1,240 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
namespace DorkNet.Server.Controllers.API.Groups;
/// <summary>
/// api.rec.net/api/groups/* — clubs / groups. Wire types from
/// <c>RecNet.Group</c> + <c>RecNet.GroupMembership</c>:
///
/// • Group: <c>GroupId, Name, Description, CreatedAt, ImageName,
/// BanStatus, CreatorId, NumMembers, Members</c>.
/// • GroupMembership: <c>GroupId, PlayerId, Permissions</c>
/// (GroupMembershipPermissions flags).
///
/// URL surface (verb + template) verified against
/// <c>Cpp2IL_ISIL/.../RecNet/Groups.txt</c>:
/// GET api/groups/v1/{id}
/// GET api/groups/v1/name/{name}
/// GET api/groups/v1/memberships/{playerId}
/// POST api/groups/v1 (create — body CreateGroupRequest)
/// POST api/groups/v1/delete/{id}
/// </summary>
[ApiController]
public class GroupsController(DorkNetDbContext db) : ControllerBase
{
private long Me => this.RequireCurrentPlayerId();
private long? MeOrNull => this.CurrentPlayerId();
[HttpGet("api/groups/v1/{id:long}")]
public async Task<IActionResult> GetById(long id)
{
var g = await db.Clubs.FirstOrDefaultAsync(c => c.Id == id);
if (g is null) return NotFound();
var members = await db.ClubMemberships
.Where(m => m.ClubId == id)
.ToListAsync();
return Ok(ToWire(g, members));
}
[HttpGet("api/groups/v1/name/{name}")]
public async Task<IActionResult> GetByName(string name)
{
var g = await db.Clubs.FirstOrDefaultAsync(c => c.Name == name);
if (g is null) return NotFound();
var members = await db.ClubMemberships
.Where(m => m.ClubId == g.Id)
.ToListAsync();
return Ok(ToWire(g, members));
}
[HttpGet("api/groups/v1/memberships/{playerId:long}")]
public async Task<IActionResult> Memberships(long playerId)
{
var rows = await (from m in db.ClubMemberships
join c in db.Clubs on m.ClubId equals c.Id
where m.PlayerId == playerId
select new { m, c }).ToListAsync();
return Ok(rows.Select(r => new
{
GroupId = r.c.Id,
PlayerId = (int)r.m.PlayerId,
Permissions = r.m.Permissions,
Group = ToWire(r.c, new List<ClubMembershipEntity>()),
}));
}
public sealed class CreateGroupRequest
{
public string? Name { get; set; }
public string? Description { get; set; }
public string? ImageName { get; set; }
}
[HttpPost("api/groups/v1")]
[Authorize]
public async Task<IActionResult> Create([FromBody] CreateGroupRequest req)
{
if (string.IsNullOrWhiteSpace(req.Name))
return BadRequest(new { Result = 1, Error = "name required" });
var pid = Me;
var dupe = await db.Clubs.AnyAsync(c => c.Name == req.Name);
if (dupe) return Conflict(new { Result = 2, Error = "name taken" });
var club = new ClubEntity
{
Name = req.Name.Trim(),
Description = req.Description ?? string.Empty,
ImageName = req.ImageName ?? string.Empty,
CreatorPlayerId = pid,
BanStatus = 0,
};
db.Clubs.Add(club);
await db.SaveChangesAsync();
// Owner membership uses GroupMembershipPermissions.Owner = 127.
db.ClubMemberships.Add(new ClubMembershipEntity
{
ClubId = club.Id,
PlayerId = pid,
Permissions = 127,
});
await db.SaveChangesAsync();
return Ok(new
{
Result = 0,
Group = ToWire(club, new List<ClubMembershipEntity>
{
new() { ClubId = club.Id, PlayerId = pid, Permissions = 127 },
}),
});
}
public sealed class UpdateGroupRequest
{
public string? Name { get; set; }
public string? Description { get; set; }
public string? ImageName { get; set; }
}
/// <summary>POST <c>api/groups/v1/{id}</c> — owner-only update of
/// description / image (per <c>RecNet.Groups.UpdateGroup</c> body
/// shape). Mirrors the create response so the watch's
/// <c>CreateModifyGroupResponse</c> deserialiser parses both.</summary>
[HttpPost("api/groups/v1/{id:long}")]
[Authorize]
public async Task<IActionResult> Update(long id, [FromBody] UpdateGroupRequest req)
{
var pid = Me;
var g = await db.Clubs.FirstOrDefaultAsync(c => c.Id == id);
if (g is null) return NotFound();
if (g.CreatorPlayerId != pid) return Forbid();
if (req.Description is not null) g.Description = req.Description;
if (req.ImageName is not null) g.ImageName = req.ImageName;
// Name updates are routed to the dedicated endpoint below
// (uniqueness check + name index has to round-trip).
g.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
var members = await db.ClubMemberships.Where(m => m.ClubId == id).ToListAsync();
return Ok(new { Result = 0, Group = ToWire(g, members) });
}
public sealed class UpdateGroupNameRequest { public string? Name { get; set; } }
/// <summary>POST <c>api/groups/v1/name/{id}</c> — owner-only
/// rename. Unique-name collision returns Result=2 to match the
/// client's <c>CreateModifyGroupStatus</c> enum.</summary>
[HttpPost("api/groups/v1/name/{id:long}")]
[Authorize]
public async Task<IActionResult> UpdateName(long id, [FromBody] UpdateGroupNameRequest req)
{
if (string.IsNullOrWhiteSpace(req.Name))
return BadRequest(new { Result = 1, Error = "name required" });
var pid = Me;
var g = await db.Clubs.FirstOrDefaultAsync(c => c.Id == id);
if (g is null) return NotFound();
if (g.CreatorPlayerId != pid) return Forbid();
var dupe = await db.Clubs.AnyAsync(c => c.Id != id && c.Name == req.Name);
if (dupe) return Conflict(new { Result = 2, Error = "name taken" });
g.Name = req.Name.Trim();
g.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
var members = await db.ClubMemberships.Where(m => m.ClubId == id).ToListAsync();
return Ok(new { Result = 0, Group = ToWire(g, members) });
}
[HttpPost("api/groups/v1/delete/{id:long}")]
[HttpDelete("api/groups/v1/delete/{id:long}")]
[Authorize]
public async Task<IActionResult> Delete(long id)
{
var pid = Me;
var g = await db.Clubs.FirstOrDefaultAsync(c => c.Id == id);
if (g is null) return NotFound();
if (g.CreatorPlayerId != pid) return Forbid();
var memberships = await db.ClubMemberships.Where(m => m.ClubId == id).ToListAsync();
db.ClubMemberships.RemoveRange(memberships);
db.Clubs.Remove(g);
await db.SaveChangesAsync();
return Ok(new { Deleted = true });
}
[HttpPost("api/groups/v1/{id:long}/join")]
[Authorize]
public async Task<IActionResult> Join(long id)
{
var pid = Me;
var g = await db.Clubs.FirstOrDefaultAsync(c => c.Id == id);
if (g is null) return NotFound();
var existing = await db.ClubMemberships
.FirstOrDefaultAsync(m => m.ClubId == id && m.PlayerId == pid);
if (existing is not null) return Ok(new { Joined = true });
db.ClubMemberships.Add(new ClubMembershipEntity
{
ClubId = id,
PlayerId = pid,
Permissions = 0, // Member
});
await db.SaveChangesAsync();
return Ok(new { Joined = true });
}
[HttpPost("api/groups/v1/{id:long}/leave")]
[Authorize]
public async Task<IActionResult> Leave(long id)
{
var pid = Me;
var g = await db.Clubs.FirstOrDefaultAsync(c => c.Id == id);
if (g is null) return NotFound();
if (g.CreatorPlayerId == pid)
return BadRequest(new { Error = "owner cannot leave; transfer ownership first" });
var row = await db.ClubMemberships
.FirstOrDefaultAsync(m => m.ClubId == id && m.PlayerId == pid);
if (row is null) return NotFound();
db.ClubMemberships.Remove(row);
await db.SaveChangesAsync();
return Ok(new { Left = true });
}
private static object ToWire(ClubEntity g, List<ClubMembershipEntity> members) => new
{
GroupId = g.Id,
g.Name,
g.Description,
g.CreatedAt,
g.ImageName,
BanStatus = g.BanStatus,
CreatorId = (int)g.CreatorPlayerId,
NumMembers = members.Count,
Members = members.Select(m => new
{
GroupId = g.Id,
PlayerId = (int)m.PlayerId,
Permissions = m.Permissions,
}),
};
}
@@ -0,0 +1,904 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.AspNetCore.Http;
using System.Text.Json;
using DorkNet.Models.Auth;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.API.Images;
/// <summary>
/// api.rec.net/api/images/v* — in-game camera photo endpoints. The 2020
/// watch's camera UI ("save photo to album", "share photo") posts here.
///
/// Wire shape (observed from server logs — the 2020 watch hits these
/// without a corresponding storage.rec.net/upload, meaning the bytes
/// come straight in this request body):
/// POST api/images/v4/uploadsaved
/// - body: multipart/form-data with the image file plus optional
/// fields (Caption, Description, RoomId, IsPublic, TaggedPlayerIds).
/// On the 2020 client the field name varies — we accept "File",
/// "Image", and any *.png/*.jpg upload for safety.
/// - response: an ImageInfo-shaped object (PascalCase keys). The
/// watch's success path calls a JSON parser whose strict keys are
/// AccountId, ImageName, CreatedAt — so all three are required.
/// Returning [] (the previous catch-all) made the watch silently
/// drop the photo.
///
/// Saved photos are stored as <see cref="PhotoEntity"/> rows with
/// IsPublic=false by default. A separate Share button (POST
/// api/images/v4/share/{id}) flips IsPublic=true and surfaces them on
/// feed.rec.net.
/// </summary>
[ApiController]
public class ImagesController(
DorkNetDbContext db,
PlayerPresenceService presence,
LevelService level,
IObjectStorage storage,
ImageSignatureService signatures,
NotificationService notifications,
DomainConfig domain,
ILogger<ImagesController> logger) : ControllerBase
{
private static readonly string ImageDir =
Path.Combine(AppContext.BaseDirectory, "data", "images");
private static readonly byte[] TransparentPng = new byte[]
{
0x89,0x50,0x4E,0x47,0x0D,0x0A,0x1A,0x0A,0x00,0x00,0x00,0x0D,
0x49,0x48,0x44,0x52,0x00,0x00,0x00,0x01,0x00,0x00,0x00,0x01,
0x08,0x06,0x00,0x00,0x00,0x1F,0x15,0xC4,0x89,0x00,0x00,0x00,
0x0D,0x49,0x44,0x41,0x54,0x78,0x9C,0x63,0x00,0x01,0x00,0x00,
0x05,0x00,0x01,0x0D,0x0A,0x2D,0xB4,0x00,0x00,0x00,0x00,0x49,
0x45,0x4E,0x44,0xAE,0x42,0x60,0x82,
};
/// <summary>POST api/images/v{2-5}/uploadsaved — receive an image
/// from the in-game camera "save to album" button. Persists the
/// bytes in <see cref="RoomDataBlobEntity"/> and creates a
/// <see cref="PhotoEntity"/> row keyed by the uploader. The photo
/// defaults to IsPublic=false (saved-only) so it doesn't surface
/// on the public feed unless the user explicitly hits Share.</summary>
[HttpPost("api/images/v2/uploadsaved")]
[HttpPost("api/images/v3/uploadsaved")]
[HttpPost("api/images/v4/uploadsaved")]
[HttpPost("api/images/v5/uploadsaved")]
[Authorize]
public async Task<IActionResult> UploadSaved()
{
var pid = this.RequireCurrentPlayerId();
var bytes = await ReadImageBytesAsync();
if (bytes is null || bytes.Length == 0)
{
logger.LogWarning("[images] uploadsaved by {Pid}: no file bytes received", pid);
return BadRequest(new { error = "missing_file" });
}
// Pull optional metadata from the form (the 2020 watch sometimes
// includes a caption / room context, sometimes posts bytes only).
var form = Request.HasFormContentType ? await Request.ReadFormAsync() : null;
var caption = form?["Caption"].ToString() ?? form?["Description"].ToString() ?? string.Empty;
var taggedIds = form?["TaggedPlayerIds"].ToString() ?? string.Empty;
long roomId = 0;
if (form is not null && long.TryParse(form["RoomId"].ToString(), out var rid)) roomId = rid;
if (roomId == 0) roomId = presence.GetRoom(pid)?.RoomId ?? 0;
var isPublic = string.Equals(form?["IsPublic"].ToString(), "true", StringComparison.OrdinalIgnoreCase);
var isProfileThumbnail = IsProfileThumbnail(form);
LogUploadForm("uploadsaved", pid, form, isProfileThumbnail);
// Detect format. The watch uses PNG; if a JPEG slips through we
// tag it accordingly so the cdn serves the right MIME.
var extension = SniffExtension(bytes);
var blobName = $"img_p{pid}_{Guid.NewGuid():N}.{extension}";
// Dual-write during the migration window: S3 is the canonical
// store going forward, but we keep the DB Bytes column populated
// so reads from older code paths still work. PR 4 drops the
// column once every blob has a confirmed S3 copy.
try
{
using var s3Timeout = new CancellationTokenSource(TimeSpan.FromSeconds(4));
var (bucket, key) = BlobRouter.Route(blobName);
await storage.PutAsync(
bucket, key, bytes,
extension == "png" ? "image/png" : "image/jpeg",
s3Timeout.Token);
}
catch (Exception ex)
{
logger.LogError(
ex,
"[images] S3 upload failed for {Blob}; keeping DB fallback so the client does not fail",
blobName);
}
db.RoomDataBlobs.Add(new RoomDataBlobEntity
{
RoomId = 0,
BlobName = blobName,
UploadedByPlayerId = pid,
UploadedAt = DateTime.UtcNow,
ReferencedFilenamesCsv = string.Empty,
});
var photo = new PhotoEntity
{
UploaderPlayerId = pid,
BlobName = blobName,
Caption = caption.Trim(),
TaggedPlayerIdsCsv = NormaliseTaggedIds(taggedIds, exceptId: pid),
RoomId = roomId,
IsPublic = isPublic,
};
db.Photos.Add(photo);
if (isProfileThumbnail)
{
var player = await db.Players.FirstOrDefaultAsync(p => p.Id == pid);
if (player is not null)
{
player.ProfileImageName = blobName;
logger.LogInformation(
"[images] profile thumbnail upload set profile image: player={PlayerId} blob={Blob}",
pid,
blobName);
}
}
await db.SaveChangesAsync();
if (isProfileThumbnail)
{
var player = await db.Players.FirstOrDefaultAsync(p => p.Id == pid);
if (player is not null)
await NotifyAccountImageChangedAsync(player);
}
// Small XP bump for taking a photo — encourages camera use.
await level.AwardXpAsync(pid, LevelService.RoomVisitXp, $"photo_saved:{photo.Id}");
logger.LogInformation(
"[images] uploaded saved photo {Id} by {Pid} ({Bytes} bytes) → {Blob}",
photo.Id, pid, bytes.Length, blobName);
return Ok(BuildImageInfo(photo, pid));
}
/// <summary>POST api/images/v{1-5}/profile — direct profile image upload.
/// Older clients use this instead of tagging an uploadsaved request as a
/// ProfileThumbnail, so this route must always update Account.ProfileImage.</summary>
[HttpPost("api/images/v1/profile")]
[HttpPost("api/images/v2/profile")]
[HttpPost("api/images/v3/profile")]
[HttpPost("api/images/v4/profile")]
[HttpPost("api/images/v5/profile")]
[Authorize]
public async Task<IActionResult> UploadProfile()
{
var pid = this.RequireCurrentPlayerId();
var bytes = await ReadImageBytesAsync();
if (bytes is null || bytes.Length == 0)
{
logger.LogWarning("[images] profile upload by {Pid}: no file bytes received", pid);
return BadRequest(new { error = "missing_file" });
}
var form = Request.HasFormContentType ? await Request.ReadFormAsync() : null;
LogUploadForm("profile", pid, form, isProfile: true);
var extension = SniffExtension(bytes);
var blobName = $"img_p{pid}_{Guid.NewGuid():N}.{extension}";
var contentType = extension == "png" ? "image/png" : "image/jpeg";
try
{
using var s3Timeout = new CancellationTokenSource(TimeSpan.FromSeconds(4));
var (bucket, key) = BlobRouter.Route(blobName);
await storage.PutAsync(bucket, key, bytes, contentType, s3Timeout.Token);
}
catch (Exception ex)
{
logger.LogError(
ex,
"[images] profile S3 upload failed for {Blob}; keeping DB fallback so the client does not fail",
blobName);
}
db.RoomDataBlobs.Add(new RoomDataBlobEntity
{
RoomId = 0,
BlobName = blobName,
UploadedByPlayerId = pid,
UploadedAt = DateTime.UtcNow,
ReferencedFilenamesCsv = string.Empty,
});
var photo = new PhotoEntity
{
UploaderPlayerId = pid,
BlobName = blobName,
Caption = string.Empty,
TaggedPlayerIdsCsv = string.Empty,
RoomId = presence.GetRoom(pid)?.RoomId ?? 0,
IsPublic = false,
};
db.Photos.Add(photo);
var player = await db.Players.FirstOrDefaultAsync(p => p.Id == pid);
if (player is not null)
player.ProfileImageName = blobName;
await db.SaveChangesAsync();
if (player is not null)
await NotifyAccountImageChangedAsync(player);
logger.LogInformation(
"[images] uploaded profile photo {Id} by {Pid} ({Bytes} bytes) -> {Blob}",
photo.Id, pid, bytes.Length, blobName);
return Ok(new
{
ImageName = blobName,
AccountId = (int)pid,
CreatedAt = photo.CreatedAt,
});
}
/// <summary>GET api/images/v{2-5}/saved — list the caller's saved
/// photos. The watch's "My Photos" tab calls this to populate the
/// album view.</summary>
[HttpGet("api/images/v2/saved")]
[HttpGet("api/images/v3/saved")]
[HttpGet("api/images/v4/saved")]
[HttpGet("api/images/v5/saved")]
[Authorize]
public async Task<IActionResult> GetSaved()
{
var pid = this.RequireCurrentPlayerId();
var rows = await db.Photos
.Where(p => p.UploaderPlayerId == pid && p.DeletedAt == null)
.OrderByDescending(p => p.CreatedAt)
.Take(200)
.ToListAsync();
return Ok(rows.Select(p => BuildImageInfo(p, pid)));
}
/// <summary>GET <c>api/images/v2/named</c> — the watch's
/// <c>Images.DownloadNamedImageMappings</c> endpoint. Returns a
/// list of <c>NamedImageDTO</c> entries; an empty list is a valid
/// response (the watch's <c>ExpectListResponse</c> rejects an
/// object body but tolerates an empty array).</summary>
[HttpGet("api/images/v2/named")]
public IActionResult NamedImages() => Ok(Array.Empty<object>());
/// <summary>GET <c>api/images/v1/slideshow</c> — the watch's lobby
/// slideshow info. Response shape per <c>SlideshowInfoDTO.Deserialize</c>:
/// <c>{ValidTill (DateTime, required), Images (list, required)}</c>.
/// Far-future <c>ValidTill</c> stops the watch from re-polling.</summary>
[HttpGet("api/images/v1/slideshow")]
public IActionResult SlideshowInfo() => Ok(new
{
ValidTill = new DateTime(9999, 12, 31, 0, 0, 0, DateTimeKind.Utc),
Images = Array.Empty<object>(),
});
/// <summary>Binary image fallback for clients that request image bytes
/// through api/images/{name} instead of img.rec.net/{name}. Specific JSON
/// endpoints above still win route matching; this only handles CDN-shaped
/// image paths.</summary>
[HttpGet("api/images/{*path}", Order = 100)]
public async Task<IActionResult> ServeImageFromApiHost(string? path)
{
var fileName = Path.GetFileName(path ?? string.Empty);
// Strip the leading '$' sigil that ImageData.image_name uses for
// hash-addressed assets. Watch sends "%24<hash>.jpg"; route
// decode gives us "$<hash>.jpg"; the bytes are stored at
// "<hash>.jpg". Without the strip, placed in-world polaroids
// miss in S3 and the watch renders the "?" fallback.
if (!string.IsNullOrEmpty(fileName) && fileName[0] == '$') fileName = fileName[1..];
if (string.IsNullOrWhiteSpace(fileName) || !IsImageName(fileName))
return NotFound();
var result = await LoadImageBytesAsync(fileName);
if (result.Bytes is { Length: > 0 })
{
logger.LogInformation(
"[images] api image hit host={Host} path={Path} file={File} source={Source} bytes={Bytes} query='{Query}'",
Request.Host.Host,
path,
fileName,
result.Source,
result.Bytes.Length,
Request.QueryString);
Response.Headers.CacheControl = "public, max-age=300";
signatures.AddContentSignature(Response, result.Bytes);
return File(result.Bytes, MimeFromName(fileName));
}
logger.LogWarning(
"[images] api image MISS host={Host} path={Path} file={File} query='{Query}' -> transparent png",
Request.Host.Host,
path,
fileName,
Request.QueryString);
Response.Headers.CacheControl = "public, max-age=60";
signatures.AddContentSignature(Response, TransparentPng);
return File(TransparentPng, "image/png");
}
/// <summary>GET api/images/v{2-5}/byaccount/{accountId} — list a
/// player's PUBLIC photos. Drives the "their album" tab in
/// the watch's player profile.</summary>
[HttpGet("api/images/v2/byaccount/{accountId:long}")]
[HttpGet("api/images/v3/byaccount/{accountId:long}")]
[HttpGet("api/images/v4/byaccount/{accountId:long}")]
[HttpGet("api/images/v5/byaccount/{accountId:long}")]
public async Task<IActionResult> GetByAccount(long accountId,
[FromQuery] int take = 50, [FromQuery] int skip = 0)
{
take = Math.Clamp(take, 1, 200);
skip = Math.Max(0, skip);
var rows = await db.Photos
.Where(p => p.UploaderPlayerId == accountId && p.IsPublic && p.DeletedAt == null)
.OrderByDescending(p => p.CreatedAt)
.Skip(skip).Take(take)
.ToListAsync();
return Ok(rows.Select(p => BuildImageInfo(p, accountId)));
}
/// <summary>POST api/images/v{2-5}/share/{id} — flip a saved photo
/// to public so it shows up on feed.rec.net. The watch's "Share"
/// button hits this; if the watch posts to a different URL we'll
/// add it when we see it in logs.</summary>
[HttpPost("api/images/v2/share/{id:long}")]
[HttpPost("api/images/v3/share/{id:long}")]
[HttpPost("api/images/v4/share/{id:long}")]
[HttpPost("api/images/v5/share/{id:long}")]
[Authorize]
public async Task<IActionResult> Share(long id)
{
var pid = this.RequireCurrentPlayerId();
var photo = await db.Photos.FirstOrDefaultAsync(p => p.Id == id);
if (photo is null) return NotFound();
if (photo.UploaderPlayerId != pid) return Forbid();
photo.IsPublic = true;
await db.SaveChangesAsync();
return Ok(BuildImageInfo(photo, pid));
}
/// <summary>DELETE api/images/v{2-5}/{id} — uploader (or admin)
/// soft-deletes a saved photo.</summary>
[HttpDelete("api/images/v2/{id:long}")]
[HttpDelete("api/images/v3/{id:long}")]
[HttpDelete("api/images/v4/{id:long}")]
[HttpDelete("api/images/v5/{id:long}")]
[Authorize]
public async Task<IActionResult> Delete(long id)
{
var pid = this.RequireCurrentPlayerId();
var photo = await db.Photos.FirstOrDefaultAsync(p => p.Id == id);
if (photo is null) return NotFound();
if (photo.UploaderPlayerId != pid)
{
var isAdmin = await db.Players.Where(x => x.Id == pid).Select(x => x.IsAdmin).FirstOrDefaultAsync();
if (!isAdmin) return Forbid();
}
photo.DeletedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return Ok(new { deleted = id });
}
// ── Legacy v1 ops (real persistence, was Ack-only stubs) ─────────
public sealed class CheerImageRequest
{
public long ImageId { get; set; }
public long PhotoId { get; set; }
}
public sealed class BulkCheeredRequest
{
public List<long>? SavedImageIds { get; set; }
public List<long>? ImageIds { get; set; }
}
[HttpPost("api/images/v4/cheered/bulk")]
[Authorize]
public async Task<IActionResult> CheeredBulk([FromBody] BulkCheeredRequest? body)
{
var pid = this.RequireCurrentPlayerId();
var ids = (body?.SavedImageIds ?? body?.ImageIds ?? new())
.Where(id => id > 0)
.Distinct()
.Take(200)
.ToList();
if (ids.Count == 0) return Ok(Array.Empty<object>());
var cheered = await db.Cheers
.Where(c => c.FromPlayerId == pid && ids.Contains(c.TargetPhotoId))
.Select(c => c.TargetPhotoId)
.ToListAsync();
var set = cheered.ToHashSet();
return Ok(ids.Select(id => new
{
SavedImageId = id,
IsCheered = set.Contains(id),
}));
}
/// <summary>POST <c>api/images/v1/cheer</c> — cheer a photo.
/// Was previously Ack-only; now persists a <c>CheerEntity</c> row
/// keyed on <c>TargetPhotoId</c> + bumps <c>Photos.CheerCount</c>.
/// Idempotent per (player, photo).</summary>
[HttpPost("api/images/v1/cheer")]
[Authorize]
public async Task<IActionResult> CheerV1([FromBody] CheerImageRequest? body,
[FromForm(Name = "ImageId")] long? imageIdForm,
[FromForm(Name = "PhotoId")] long? photoIdForm)
{
var pid = this.RequireCurrentPlayerId();
var photoId = body?.PhotoId ?? body?.ImageId
?? photoIdForm ?? imageIdForm ?? 0;
if (photoId <= 0) return BadRequest(new { error = "missing photoId" });
var photo = await db.Photos.FirstOrDefaultAsync(p => p.Id == photoId && p.DeletedAt == null);
if (photo is null) return NotFound();
var existing = await db.Cheers.FirstOrDefaultAsync(c =>
c.FromPlayerId == pid && c.TargetPhotoId == photoId);
if (existing is null)
{
db.Cheers.Add(new CheerEntity
{
FromPlayerId = pid,
TargetPhotoId = photoId,
CheeredAt = DateTime.UtcNow,
});
photo.CheerCount += 1;
await db.SaveChangesAsync();
}
return Ok(new { photo.Id, photo.CheerCount });
}
/// <summary>GET <c>api/images/v1/listsaved</c> — the 2020 client
/// deserializes this as SavedImagesListDTO { Images: string[] }.
/// Do not return the richer v2/v4 photo-object list here.</summary>
[HttpGet("api/images/v1/listsaved")]
[Authorize]
public async Task<IActionResult> ListSavedV1()
{
var pid = this.RequireCurrentPlayerId();
var names = await db.Photos
.Where(p => p.UploaderPlayerId == pid && p.DeletedAt == null)
.OrderByDescending(p => p.CreatedAt)
.Select(p => p.BlobName)
.Take(200)
.ToListAsync();
return Ok(new { Images = names });
}
public sealed class ModifyAccessibilityRequest
{
public long PhotoId { get; set; }
public long ImageId { get; set; }
public string? ImageName { get; set; }
public bool IsPublic { get; set; }
}
/// <summary>POST <c>api/images/v1/modifyaccessibility</c> — flip
/// the IsPublic flag on a saved photo. Was Ack-only; now writes
/// the column.</summary>
[HttpPost("api/images/v1/modifyaccessibility")]
[Authorize]
public async Task<IActionResult> ModifyAccessibility(
[FromBody] ModifyAccessibilityRequest? body,
[FromForm(Name = "PhotoId")] long? photoIdForm,
[FromForm(Name = "ImageId")] long? imageIdForm,
[FromForm(Name = "ImageName")] string? imageNameForm,
[FromForm(Name = "IsPublic")] bool? isPublicForm)
{
var pid = this.RequireCurrentPlayerId();
var photoId = body?.PhotoId ?? body?.ImageId
?? photoIdForm ?? imageIdForm ?? 0;
var imageName = body?.ImageName ?? imageNameForm;
if (photoId <= 0 && string.IsNullOrWhiteSpace(imageName))
return BadRequest(new { error = "missing photoId" });
var isPublic = body?.IsPublic ?? isPublicForm ?? true;
var photo = photoId > 0
? await db.Photos.FirstOrDefaultAsync(p => p.Id == photoId)
: await db.Photos.FirstOrDefaultAsync(p => p.BlobName == imageName);
if (photo is null) return NotFound();
if (photo.UploaderPlayerId != pid) return Forbid();
photo.IsPublic = isPublic;
await db.SaveChangesAsync();
return Ok(new { photo.Id, photo.IsPublic });
}
public sealed class DeleteSavedRequest
{
public long PhotoId { get; set; }
public long ImageId { get; set; }
public string? ImageName { get; set; }
}
/// <summary>POST <c>api/images/v1/deletesaved</c> + DELETE alias —
/// soft-delete a saved photo. Was Ack-only; now flips
/// <c>DeletedAt</c>.</summary>
[HttpPost("api/images/v1/deletesaved")]
[HttpDelete("api/images/v1/deletesaved/{id:long}")]
[Authorize]
public async Task<IActionResult> DeleteSavedV1(
long? id,
[FromBody] DeleteSavedRequest? body,
[FromForm(Name = "PhotoId")] long? photoIdForm,
[FromForm(Name = "ImageId")] long? imageIdForm,
[FromForm(Name = "ImageName")] string? imageNameForm)
{
var photoId = id ?? body?.PhotoId ?? body?.ImageId
?? photoIdForm ?? imageIdForm ?? 0;
var imageName = body?.ImageName ?? imageNameForm;
if (photoId <= 0 && string.IsNullOrWhiteSpace(imageName))
return BadRequest(new { error = "missing photoId" });
if (photoId <= 0)
{
var pid = this.RequireCurrentPlayerId();
var photo = await db.Photos.FirstOrDefaultAsync(p =>
p.UploaderPlayerId == pid && p.BlobName == imageName && p.DeletedAt == null);
if (photo is null) return NotFound();
photo.DeletedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return Ok(new RecNetResult { Success = true, Error = string.Empty });
}
return await Delete(photoId);
}
public sealed class SendLinkRequest
{
public long PhotoId { get; set; }
public long ImageId { get; set; }
public List<long>? RecipientIds { get; set; }
}
/// <summary>POST <c>api/images/v1/sendlink</c> — send a link to a
/// photo as a DM to one or more recipients. Was Ack-only; now
/// inserts one <see cref="MessageEntity"/> per recipient.</summary>
[HttpPost("api/images/v1/sendlink")]
[Authorize]
public async Task<IActionResult> SendLink(
[FromBody] SendLinkRequest? body,
[FromForm(Name = "PhotoId")] long? photoIdForm,
[FromForm(Name = "ImageId")] long? imageIdForm,
[FromForm(Name = "RecipientIds")] string? recipientIdsForm)
{
var pid = this.RequireCurrentPlayerId();
var photoId = body?.PhotoId ?? body?.ImageId ?? photoIdForm ?? imageIdForm ?? 0;
if (photoId <= 0) return BadRequest(new { error = "missing photoId" });
var photo = await db.Photos.FirstOrDefaultAsync(p => p.Id == photoId);
if (photo is null) return NotFound();
var recipients = body?.RecipientIds ?? ParseIdList(recipientIdsForm) ?? new();
if (recipients.Count == 0) return Ok(new { Sent = 0 });
var preview = $"shared a photo (id {photoId})";
foreach (var rid in recipients.Distinct())
{
db.Messages.Add(new MessageEntity
{
SenderPlayerId = pid,
RecipientPlayerId = rid,
Body = $"{preview} → /img/{photo.BlobName}",
});
}
await db.SaveChangesAsync();
return Ok(new { Sent = recipients.Count });
}
private static List<long>? ParseIdList(string? csv)
{
if (string.IsNullOrWhiteSpace(csv)) return null;
return csv.Split(',', StringSplitOptions.RemoveEmptyEntries)
.Select(s => long.TryParse(s.Trim(), out var v) ? v : 0L)
.Where(v => v > 0)
.ToList();
}
// ── Helpers ──────────────────────────────────────────────────────
/// <summary>Read raw image bytes from the request — supports both
/// multipart/form-data (the typical case, with field "File" or
/// "Image") and a raw octet-stream body (some 2020 client paths).</summary>
private async Task<byte[]?> ReadImageBytesAsync()
{
if (Request.HasFormContentType)
{
var form = await Request.ReadFormAsync();
// Try common field names; fall back to the first uploaded file.
var file = form.Files["File"]
?? form.Files["Image"]
?? form.Files["image"]
?? form.Files["file"]
?? form.Files.FirstOrDefault();
if (file is null || file.Length == 0) return null;
using var ms = new MemoryStream();
await file.CopyToAsync(ms);
return ms.ToArray();
}
// Raw body fallback — when the watch posts the bytes directly.
if (Request.ContentLength is > 0)
{
using var ms = new MemoryStream();
await Request.Body.CopyToAsync(ms);
return ms.ToArray();
}
return null;
}
/// <summary>Detect PNG vs JPEG by magic bytes — anything else
/// defaults to .png since the 2020 client's camera always saves
/// PNG.</summary>
private static string SniffExtension(byte[] bytes)
{
if (bytes.Length >= 4 &&
bytes[0] == 0x89 && bytes[1] == 0x50 && bytes[2] == 0x4E && bytes[3] == 0x47)
return "png";
if (bytes.Length >= 3 &&
bytes[0] == 0xFF && bytes[1] == 0xD8 && bytes[2] == 0xFF)
return "jpg";
return "png";
}
private async Task<(byte[]? Bytes, string Source)> LoadImageBytesAsync(string fileName)
{
var (bucket, key) = BlobRouter.Route(fileName);
try
{
using var s3Timeout = new CancellationTokenSource(TimeSpan.FromSeconds(2));
var s3Bytes = await storage.GetAsync(bucket, key, s3Timeout.Token);
if (s3Bytes is { Length: > 0 })
return (s3Bytes, $"s3:{bucket}/{key}");
}
catch (Exception ex)
{
logger.LogWarning(ex,
"[images] API image S3 lookup threw for {File} bucket={Bucket} key={Key}",
fileName, bucket, key);
}
// S3 is the only canonical store. Disk-on-server is a legacy
// surface used by tools/fetch-room-images.py'd thumbnails that
// ship with the server — keep that as a last resort, but DB
// bytes are gone.
var full = Path.Combine(ImageDir, fileName);
if (System.IO.File.Exists(full))
return (await System.IO.File.ReadAllBytesAsync(full), "disk");
return (null, "miss");
}
private static bool TryGetPlayerId(string fileName, string prefix, out long playerId)
{
playerId = 0;
if (!fileName.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) return false;
var start = prefix.Length;
var end = fileName.IndexOf('_', start);
return end > start && long.TryParse(fileName[start..end], out playerId);
}
private static bool IsImageName(string name)
{
var ext = Path.GetExtension(name).ToLowerInvariant();
return ext is ".png" or ".jpg" or ".jpeg" or ".gif" or ".webp" or ".avif";
}
private static string MimeFromName(string name)
{
var ext = Path.GetExtension(name).ToLowerInvariant();
return ext switch
{
".png" => "image/png",
".jpg" or ".jpeg" => "image/jpeg",
".gif" => "image/gif",
".webp" => "image/webp",
".avif" => "image/avif",
_ => "image/png",
};
}
private void LogUploadForm(string endpoint, long playerId, IFormCollection? form, bool isProfile)
{
if (form is null)
{
logger.LogInformation(
"[images] {Endpoint} metadata player={PlayerId} form=false profile={Profile}",
endpoint,
playerId,
isProfile);
return;
}
var fileNames = string.Join(
",",
form.Files.Select(f => $"{f.Name}:{f.FileName}:{f.Length}"));
var fieldNames = string.Join(",", form.Keys);
var meta = form["imgMeta"].ToString();
if (string.IsNullOrWhiteSpace(meta))
meta = form["imageMeta"].ToString();
if (meta.Length > 512)
meta = meta[..512];
logger.LogInformation(
"[images] {Endpoint} metadata player={PlayerId} files={Files} fields={Fields} profile={Profile} imgMeta={ImgMeta}",
endpoint,
playerId,
fileNames,
fieldNames,
isProfile,
meta);
}
private static bool IsProfileThumbnail(IFormCollection? form)
{
if (form is null) return false;
static bool IsProfileValue(string? value)
{
if (string.IsNullOrWhiteSpace(value)) return false;
return value.Trim().Equals("4", StringComparison.OrdinalIgnoreCase)
|| value.Contains("ProfileThumbnail", StringComparison.OrdinalIgnoreCase);
}
if (IsProfileValue(form["SavedImageType"].ToString())
|| IsProfileValue(form["savedImageType"].ToString())
|| IsProfileValue(form["ImageType"].ToString())
|| IsProfileValue(form["imageType"].ToString()))
return true;
var meta = form["imgMeta"].ToString();
if (string.IsNullOrWhiteSpace(meta))
meta = form["imageMeta"].ToString();
if (string.IsNullOrWhiteSpace(meta))
return false;
if (IsProfileValue(meta))
return true;
try
{
using var doc = JsonDocument.Parse(meta);
if (doc.RootElement.ValueKind != JsonValueKind.Object)
return false;
foreach (var name in new[] { "savedImageType", "SavedImageType", "imageType", "ImageType" })
{
if (!doc.RootElement.TryGetProperty(name, out var value))
continue;
if (value.ValueKind == JsonValueKind.Number && value.TryGetInt32(out var n) && n == 4)
return true;
if (value.ValueKind == JsonValueKind.String && IsProfileValue(value.GetString()))
return true;
}
}
catch (JsonException)
{
}
return false;
}
/// <summary>Build the response shape every images/v* endpoint
/// returns. PascalCase keys + redundant aliases so whichever JSON
/// parser the 2020 watch uses, it finds the field it wants. The
/// most important keys are AccountId, ImageName, CreatedAt — those
/// are what the watch's strict deserialiser reads. <c>Url</c>
/// is built against the configured deployment apex (DORKNET_DOMAIN)
/// so every callsite for the URL string flows through one source
/// of truth.</summary>
private object BuildImageInfo(PhotoEntity p, long accountId)
{
var cdnHost = domain.Sub("cdn");
// Accessibility maps from IsPublic so the 2020.12 watch's IBANLCLBGLM
// deserialiser reads a non-default value (0=Private, 1=Public).
var accessibility = p.IsPublic ? 1 : 0;
return new
{
// 2020.12 watch's IBANLCLBGLM strict keys (per
// docs/recroom-2020-client-response-contracts.md:1048).
// Missing any of these throws KeyNotFoundException in LitJson
// and the gallery empties out.
Id = p.Id,
ImageName = p.BlobName,
AccountId = (int)accountId,
// Server-side mirror of AccountId as PlayerId — some
// call-sites read PlayerId (lowercase id) instead of
// AccountId. Including both is harmless on the watch.
PlayerId = (int)accountId,
RoomId = p.RoomId,
// PlayerEventId is always 0 here (photo-event linkage isn't
// tracked yet); the field still has to be present so the
// strict deserialiser doesn't bail.
PlayerEventId = 0L,
Accessibility = accessibility,
// AccessibilityLocked = whether the watch can flip the
// privacy toggle. We don't gate on this server-side so the
// value is always false (UI is free to toggle).
AccessibilityLocked = false,
// ImageType: 0=ScreenShot, 1=ProfileImage, 2=ImageOfMe, …
// we don't differentiate yet — surface 0 unconditionally.
Type = 0,
ImageType = 0,
CreatedAt = p.CreatedAt,
TaggedPlayerIds = ParseTagged(p.TaggedPlayerIdsCsv).ToArray(),
CheerCount = p.CheerCount,
// We don't track per-photo comments yet — surface 0 for
// the strict-key requirement.
CommentCount = 0,
// Bonus fields — useful for other clients (and our own feed UI).
Filename = p.BlobName,
Url = $"https://{cdnHost}/{p.BlobName}",
Caption = p.Caption,
Description = p.Caption,
IsPublic = p.IsPublic,
ViewCount = p.ViewCount,
};
}
private static IEnumerable<long> ParseTagged(string? csv) =>
string.IsNullOrWhiteSpace(csv)
? Enumerable.Empty<long>()
: csv.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(s => long.TryParse(s, out var v) ? v : 0L)
.Where(v => v > 0);
private Task NotifyAccountImageChangedAsync(PlayerEntity player)
{
var account = new DorkNet.Models.Auth.RecNetAccount
{
AccountId = (int)player.Id,
RawUsername = player.Username,
Username = player.Username,
DisplayName = player.DisplayName ?? player.Username,
ProfileImage = player.ProfileImageName ?? string.Empty,
TreatAsJunior = player.IsJunior,
HasBirthday = true,
Platforms = 1,
};
var selfAccount = new DorkNet.Models.Auth.RecNetSelfAccount
{
AccountId = account.AccountId,
RawUsername = account.RawUsername,
Username = account.Username,
DisplayName = account.DisplayName,
ProfileImage = account.ProfileImage,
TreatAsJunior = account.TreatAsJunior,
HasBirthday = account.HasBirthday,
Platforms = account.Platforms,
Email = player.Email ?? string.Empty,
Phone = player.Phone ?? string.Empty,
Birthday = player.Birthday,
JuniorState = player.IsJunior ? 1 : 0,
ParentAccountId = null,
};
return Task.WhenAll(
notifications.NotifyTypedAsync(player.Id, "AccountUpdate", account),
notifications.NotifyTypedAsync(player.Id, "SelfAccountUpdate", selfAccount));
}
private static string NormaliseTaggedIds(string? csv, long exceptId)
{
if (string.IsNullOrWhiteSpace(csv)) return string.Empty;
var ids = csv.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(s => long.TryParse(s, out var v) ? v : 0L)
.Where(v => v > 0 && v != exceptId)
.Distinct()
.ToList();
return string.Join(",", ids);
}
}
@@ -0,0 +1,39 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using System.Security.Claims;
namespace DorkNet.Server.Controllers.API.Images.V2;
[ApiController]
[Route("api/[controller]/v2")]
[Authorize]
public class ImagesController : ControllerBase
{
private static readonly string ImageDir =
Path.Combine(AppContext.BaseDirectory, "data", "images");
[HttpPost("upload")]
public async Task<ActionResult<UploadImageResponse>> Upload(IFormFile file)
{
Directory.CreateDirectory(ImageDir);
var ext = Path.GetExtension(file.FileName).ToLowerInvariant();
if (ext is not (".png" or ".jpg" or ".jpeg"))
return BadRequest("Only PNG/JPG accepted");
var name = $"{Guid.NewGuid():N}{ext}";
var path = Path.Combine(ImageDir, name);
await using var stream = System.IO.File.Create(path);
await file.CopyToAsync(stream);
var baseUrl = $"{Request.Scheme}://{Request.Host}";
return Ok(new UploadImageResponse { ImageName = name, Url = $"{baseUrl}/img/{name}" });
}
}
public class UploadImageResponse
{
public string ImageName { get; set; } = string.Empty;
public string Url { get; set; } = string.Empty;
}
@@ -0,0 +1,697 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using DorkNet.Models.Notification;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.API.Inventions;
/// <summary>
/// api.rec.net/api/inventions/* — Maker-Pen creations the player can
/// re-spawn or share. URL surface + verbs verified against
/// <c>Cpp2IL_ISIL/.../RecNet/Inventions.txt</c>: the "update*"
/// routes use <c>Core.Get</c> (GET) despite being mutations, so we
/// expose them as [HttpGet]; only <c>cheer</c>, <c>report</c>,
/// <c>settags</c>, <c>save</c>, <c>addversion</c>, and <c>batch</c>
/// are POST. <c>delete</c>, <c>publish</c>, <c>unpublish</c>,
/// <c>download</c> are also GET.
///
/// Wire DTO matches the watch's <c>OBBBPCBIMME.PPGFHEDFBEA</c>
/// deserializer (Cpp2IL ISIL <c>OBBBPCBIMME.txt:588-704</c>): keys
/// <c>InventionId, ReplicationId, CreatorPlayerId, Name, Description,
/// ImageName, CurrentVersionNumber, IsPublished, AllowTrial,
/// ModifiedAt, CreatedAt, FirstPublishedAt, CreationRoomId,
/// NumPlayersHaveUsedInRoom, NumDownloads, CheerCount,
/// CreatorPermission, GeneralPermission, IsAGInvention, Price,
/// HideFromPlayer</c>. Casing is significant — note IsAGInvention
/// has a capital G.
/// </summary>
[ApiController]
public class InventionsController(
DorkNetDbContext db,
LevelService level,
NotificationService notifications) : ControllerBase
{
private long? CurrentPlayerIdOrNull => this.CurrentPlayerId();
private long CurrentPlayerId => this.RequireCurrentPlayerId();
// ── Browse ───────────────────────────────────────────────────────────
[HttpGet("api/inventions/v3/popular")]
public async Task<ActionResult> Popular([FromQuery] int take = 50)
{
take = Math.Clamp(take, 1, 100);
var rows = await db.Inventions
.Where(i => !i.IsDeleted && i.IsPublished)
.OrderByDescending(i => i.CheerCount)
.Take(take)
.ToListAsync();
return Ok(rows.Select(ToWire));
}
[HttpGet("api/inventions/v3/saved")]
[Authorize]
public async Task<ActionResult> Saved([FromQuery] int take = 50)
{
take = Math.Clamp(take, 1, 100);
var pid = CurrentPlayerId;
var rows = await db.Inventions
.Where(i => !i.IsDeleted && i.CreatorPlayerId == pid)
.OrderByDescending(i => i.UpdatedAt)
.Take(take)
.ToListAsync();
return Ok(rows.Select(ToWire));
}
[HttpGet("api/inventions/v3/created")]
[Authorize]
public Task<ActionResult> Created([FromQuery] int take = 50) => Saved(take);
/// <summary><c>GetLocalPlayerInventions</c> — same as Saved but
/// at v1 path. Watch hits both depending on tab.</summary>
[HttpGet("api/inventions/v1/mine")]
[Authorize]
public Task<ActionResult> Mine() => Saved(100);
[HttpGet("api/inventions/v1/search")]
public async Task<ActionResult> Search([FromQuery] string value = "")
{
if (string.IsNullOrWhiteSpace(value))
return Ok(Array.Empty<object>());
// The watch's @-prefix means "search by player". Two callers
// use this with different payload shapes — both end up here:
// * PlayerDetailsWatchUIFlow.SetAccount → @<accountId> (numeric)
// loads the Inventions tab on a player profile. Verified
// in Cpp2IL — see PlayerDetailsWatchUIFlow.txt:1867,1950.
// * Inventions search UI → @<username> (text) free-text
// prefix match on Username.
if (value.StartsWith('@'))
{
var tail = value[1..];
List<long> creators;
if (long.TryParse(tail, out var accountId))
{
creators = new List<long> { accountId };
}
else
{
creators = await db.Players
.Where(p => p.Username.StartsWith(tail))
.Select(p => p.Id).Take(50).ToListAsync();
}
var byCreator = await db.Inventions
.Where(i => !i.IsDeleted && i.IsPublished
&& creators.Contains(i.CreatorPlayerId))
.OrderByDescending(i => i.CheerCount)
.Take(50)
.ToListAsync();
return Ok(byCreator.Select(ToWire));
}
var v = value.ToLowerInvariant();
var rows = await db.Inventions
.Where(i => !i.IsDeleted && i.IsPublished
&& (i.Name.ToLower().Contains(v)
|| i.TagsCsv.ToLower().Contains(v)))
.OrderByDescending(i => i.CheerCount)
.Take(50)
.ToListAsync();
return Ok(rows.Select(ToWire));
}
public sealed record InventionBatchRequest(List<long> InventionIds);
[HttpPost("api/inventions/v1/batch")]
public async Task<ActionResult> Batch([FromBody] InventionBatchRequest req)
{
if (req?.InventionIds is null || req.InventionIds.Count == 0)
return Ok(Array.Empty<object>());
var ids = req.InventionIds.Take(200).ToList();
var rows = await db.Inventions
.Where(i => !i.IsDeleted && ids.Contains(i.Id))
.ToListAsync();
return Ok(rows.Select(ToWire));
}
/// <summary>GET <c>api/inventions/v1/tagfilters</c> — drives
/// <c>RecNet.Inventions.GetTags</c>. The watch deserialises the
/// response as <c>RecNet.GetFiltersResponse</c> (an
/// <c>IRecNetObject</c> with <c>PinnedFilters</c> +
/// <c>PopularFilters</c> string-list properties), then a
/// <c>Func&lt;GetFiltersResponse, List&lt;string&gt;&gt;</c>
/// projects whichever list the caller wanted. Verified at
/// Cpp2IL_ISIL/.../RecNet/Inventions.txt:4834-4852 — the URL
/// literal is followed by a <c>typeof(Func`2&lt;GetFiltersResponse,
/// List`1&lt;String&gt;&gt;)</c>. Returning a bare string array
/// (the previous shape) tripped the LitJson importer's
/// <c>List → Dictionary</c> cast inside <c>Util.Deserialize&lt;T&gt;</c>
/// and surfaced as "Failed to get tags: Malformed Response"
/// (output_log.txt:1602+1639) — and that bubbles up as the
/// dorm-load destabilisation we've been hunting.</summary>
[HttpGet("api/inventions/v1/tagfilters")]
public IActionResult TagFilters()
{
var tags = new[]
{
"sport", "game", "vehicle", "weapon", "decor", "tool",
"art", "music", "puzzle", "combat", "build", "race",
};
return Ok(new
{
PinnedFilters = tags,
PopularFilters = tags,
});
}
[HttpGet("api/inventions/v1/creatorIds")]
public IActionResult CreatorIdsInRoom([FromQuery] long roomId)
{
// We don't yet track per-room invention placements; return
// empty list (correct shape: List<int>).
_ = roomId;
return Ok(Array.Empty<int>());
}
/// <summary>GET <c>api/inventions/v1/fulllineageowner</c> —
/// raw Boolean. The client uses this to decide whether a set of
/// referenced inventions can be saved together under one owner.
/// DorkNet tracks current creator ownership, so the concrete rule
/// here is: every requested invention id must exist and share the
/// same CreatorPlayerId.</summary>
[HttpGet("api/inventions/v1/fulllineageowner")]
public async Task<IActionResult> FullLineageOwner()
{
var ids = Request.Query
.SelectMany(q => q.Value)
.SelectMany(v => (v ?? string.Empty).Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
.Select(v => long.TryParse(v, out var id) ? id : 0L)
.Where(id => id > 0)
.Distinct()
.Take(200)
.ToList();
if (ids.Count == 0) return Content("true", "application/json");
var owners = await db.Inventions
.Where(i => !i.IsDeleted && ids.Contains(i.Id))
.Select(i => new { i.Id, i.CreatorPlayerId })
.ToListAsync();
var ok = owners.Count == ids.Count
&& owners.Select(o => o.CreatorPlayerId).Distinct().Count() <= 1;
return Content(ok ? "true" : "false", "application/json");
}
// ── Single fetch ─────────────────────────────────────────────────────
/// <summary>GET <c>/api/inventions/v1/room?id={roomId}</c> — every
/// invention used by the room. The 2020.12 watch fires this on
/// EVERY room load right after fetching the room data blob; it uses
/// the response to pre-resolve invention references the blob will
/// spawn. Without this, imported rooms with invention-based
/// geometry (floors, walls, custom shapes) render with everything
/// missing — the watch silently skips invention spawns when it
/// has no list, and you fall through the void.
///
/// We match on <see cref="InventionEntity.CreationRoomId"/> which
/// the zip importer populates from each Invention.json's
/// <c>CreationRoomId</c> field. Returns an array of
/// <see cref="ToWire"/>-shaped Invention objects (same wire shape
/// the single-fetch endpoint returns) so the watch's existing
/// deserializer works.</summary>
[HttpGet("api/inventions/v1/room")]
public async Task<ActionResult> ByRoom([FromQuery] long id)
{
// The watch sends our local room id, but zip-imported inventions
// carry the ORIGINAL Rec Room room id in CreationRoomId (the
// importer preserves it so cross-zip references survive). Match
// by name first to find any candidate original ids, then union
// the lookup. For dorm/RR-Original rooms (CreatorPlayerId=1,
// never imported), CreationRoomId equals the local id directly.
var roomName = await db.Rooms
.Where(r => r.Id == id)
.Select(r => r.Name)
.FirstOrDefaultAsync();
var inventions = await db.Inventions
.Where(i => !i.IsDeleted
&& (i.CreationRoomId == id
// Imported rooms: the invention has CreationRoomId =
// the original Rec Room id. We can't reverse-resolve
// without an OriginalRoomId column, so use
// ReplicationId-based name match: every invention
// imported alongside this room has CreatorPlayerId
// matching the room creator AND CreationRoomId set
// to a non-zero value that isn't an RR-Original.
// Loose match — accept any invention whose creator
// is the room creator and CreationRoomId is "the same
// remote id" for that room. We approximate by
// looking at the most-frequent CreationRoomId among
// inventions whose creator matches the room creator
// and joining on that.
|| (roomName != null && db.Inventions
.Where(j => !j.IsDeleted
&& j.CreatorPlayerId == db.Rooms
.Where(r => r.Id == id)
.Select(r => r.CreatorPlayerId)
.FirstOrDefault()
&& j.CreationRoomId != null
&& j.CreationRoomId != id)
.GroupBy(j => j.CreationRoomId)
.OrderByDescending(g => g.Count())
.Select(g => g.Key)
.FirstOrDefault() == i.CreationRoomId)
))
.ToListAsync();
return Ok(inventions.Select(ToWire).ToList());
}
[HttpGet("api/inventions/v1")]
public async Task<ActionResult> SingleByQuery([FromQuery] long inventionId)
{
var i = await db.Inventions.FirstOrDefaultAsync(x => x.Id == inventionId && !x.IsDeleted);
if (i is null) return NotFound();
if (!i.IsPublished && i.CreatorPlayerId != CurrentPlayerIdOrNull)
return Forbid();
return Ok(ToWire(i));
}
[HttpGet("api/inventions/v1/details")]
public async Task<ActionResult> Details([FromQuery] long inventionId)
{
var i = await db.Inventions.FirstOrDefaultAsync(x => x.Id == inventionId && !x.IsDeleted);
if (i is null) return NotFound();
if (!i.IsPublished && i.CreatorPlayerId != CurrentPlayerIdOrNull)
return Forbid();
var versions = await db.InventionVersions
.Where(v => v.InventionId == inventionId)
.OrderByDescending(v => v.VersionNumber)
.ToListAsync();
return Ok(new
{
Invention = ToWire(i),
Versions = versions.Select(ToVersionWire),
});
}
[HttpGet("api/inventions/v1/personaldetails/{id:long}")]
[Authorize]
public async Task<ActionResult> PersonalDetails(long id)
{
var i = await db.Inventions.FirstOrDefaultAsync(x => x.Id == id && !x.IsDeleted);
if (i is null) return NotFound();
return Ok(new
{
Invention = ToWire(i),
CanEdit = i.CreatorPlayerId == CurrentPlayerId,
});
}
[HttpGet("api/inventions/v1/versions")]
public async Task<ActionResult> Versions([FromQuery] long inventionId)
{
var rows = await db.InventionVersions
.Where(v => v.InventionId == inventionId)
.OrderByDescending(v => v.VersionNumber)
.ToListAsync();
return Ok(rows.Select(ToVersionWire));
}
[HttpGet("api/inventions/v1/version")]
public async Task<ActionResult> Version(
[FromQuery] long inventionId, [FromQuery] int version)
{
var v = await db.InventionVersions
.FirstOrDefaultAsync(x => x.InventionId == inventionId && x.VersionNumber == version);
if (v is null) return NotFound();
return Ok(ToVersionWire(v));
}
// ── Create / version ─────────────────────────────────────────────────
public sealed class SaveInventionV4Request
{
public string? Name { get; set; }
public string? Description { get; set; }
public string? ImageName { get; set; }
public int InstantiationCost { get; set; }
public int LightsCost { get; set; }
public int AiCost { get; set; }
public long? CreationRoomId { get; set; }
public string? InventionDataFilename { get; set; }
public List<long>? ReferencedInventions { get; set; }
public int CreatorAccountRole { get; set; }
}
/// <summary>POST <c>api/inventions/v4/save</c> — 2020.12 maker-pen
/// save flow. v4 wraps the new invention plus its first version in
/// a Status/Invention/InventionVersion object.</summary>
[HttpPost("api/inventions/v4/save")]
[Authorize]
public async Task<ActionResult> SaveV4([FromBody] SaveInventionV4Request req)
{
var pid = CurrentPlayerId;
var name = (req.Name ?? string.Empty).Trim();
if (string.IsNullOrWhiteSpace(name)) return BadRequest(new { Status = 1, Error = "missing name" });
var blobName = (req.InventionDataFilename ?? string.Empty).Trim();
if (string.IsNullOrWhiteSpace(blobName)) return BadRequest(new { Status = 1, Error = "missing inventionDataFilename" });
var inv = new InventionEntity
{
CreatorPlayerId = pid,
ReplicationId = Guid.NewGuid().ToString("D"),
Name = name,
Description = req.Description ?? string.Empty,
ImageName = req.ImageName ?? string.Empty,
Permission = 0,
CurrentBlobName = blobName,
TagsCsv = string.Empty,
CreationRoomId = req.CreationRoomId,
CreatorPermission = 100,
GeneralPermission = 0,
IsPublished = false,
CurrentVersionNumber = 1,
};
db.Inventions.Add(inv);
await db.SaveChangesAsync();
var v1 = new InventionVersionEntity
{
InventionId = inv.Id,
ReplicationId = Guid.NewGuid().ToString("D"),
VersionNumber = 1,
BlobName = blobName,
InstantiationCost = Math.Max(0, req.InstantiationCost),
LightsCost = Math.Max(0, req.LightsCost),
};
db.InventionVersions.Add(v1);
if (req.ReferencedInventions is { Count: > 0 })
{
foreach (var referencedId in req.ReferencedInventions.Distinct().Take(200))
{
db.ObjectiveProgress.Add(new ObjectiveProgressEntity
{
PlayerId = pid,
Key = $"invention:{inv.Id}:references:{referencedId}",
IsCompleted = true,
ClearedAt = DateTime.UtcNow,
});
}
}
await db.SaveChangesAsync();
await level.AwardXpAsync(pid, LevelService.InventionSavedXp, $"invention_save:{inv.Id}");
return Ok(new
{
Status = 0,
Invention = ToWireV4(inv),
InventionVersion = ToVersionWire(v1),
});
}
public sealed record AddVersionRequest(
long InventionId, string BlobName,
int? InstantiationCost, int? LightsCost);
[HttpPost("api/inventions/v3/addversion")]
[Authorize]
public async Task<ActionResult> AddVersion([FromBody] AddVersionRequest req)
{
var pid = CurrentPlayerId;
var inv = await db.Inventions.FirstOrDefaultAsync(x => x.Id == req.InventionId && !x.IsDeleted);
if (inv is null) return NotFound();
if (inv.CreatorPlayerId != pid) return Forbid();
if (string.IsNullOrWhiteSpace(req.BlobName)) return BadRequest("missing BlobName");
var nextVer = inv.CurrentVersionNumber + 1;
inv.CurrentBlobName = req.BlobName;
inv.CurrentVersionNumber = nextVer;
inv.UpdatedAt = DateTime.UtcNow;
db.InventionVersions.Add(new InventionVersionEntity
{
InventionId = inv.Id,
ReplicationId = Guid.NewGuid().ToString("D"),
VersionNumber = nextVer,
BlobName = req.BlobName,
InstantiationCost = req.InstantiationCost ?? 0,
LightsCost = req.LightsCost ?? 0,
});
await db.SaveChangesAsync();
return Ok(ToWire(inv));
}
// ── GET-based mutations (Core.Get pattern) ───────────────────────────
/// <summary>Watch sends ONE of name/description/imgName/permission
/// per request — `UpdateInvention*` ISIL builds different
/// query strings. Pull whichever ones came in and apply.</summary>
[HttpGet("api/inventions/v1/update")]
[Authorize]
public async Task<ActionResult> Update(
[FromQuery] long inventionId,
[FromQuery] string? name,
[FromQuery] string? description,
[FromQuery] string? imgName,
[FromQuery] int? permission)
{
var pid = CurrentPlayerId;
var inv = await db.Inventions.FirstOrDefaultAsync(x => x.Id == inventionId && !x.IsDeleted);
if (inv is null) return NotFound();
if (inv.CreatorPlayerId != pid) return Forbid();
if (!string.IsNullOrWhiteSpace(name)) inv.Name = name.Trim();
if (description is not null) inv.Description = description;
if (imgName is not null) inv.ImageName = imgName;
if (permission.HasValue)
{
inv.GeneralPermission = ClampInventionPermission(permission.Value);
if (inv.GeneralPermission > 0 && !inv.IsPublished)
{
inv.IsPublished = true;
inv.FirstPublishedAt = DateTime.UtcNow;
}
// Mirror legacy 0/1/2 column for back-compat.
inv.Permission = inv.GeneralPermission >= 60 ? 2 : (inv.GeneralPermission >= 20 ? 1 : 0);
}
inv.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return Ok(ToWire(inv));
}
[HttpGet("api/inventions/v1/delete")]
[Authorize]
public async Task<ActionResult> Delete([FromQuery] long inventionId)
{
var pid = CurrentPlayerId;
var inv = await db.Inventions.FirstOrDefaultAsync(x => x.Id == inventionId);
if (inv is null) return NotFound();
if (inv.CreatorPlayerId != pid) return Forbid();
inv.IsDeleted = true;
inv.GeneralPermission = 0;
inv.IsPublished = false;
inv.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return Ok(ToWire(inv));
}
[HttpGet("api/inventions/v3/publish")]
[Authorize]
public async Task<ActionResult> Publish(
[FromQuery] long inventionId, [FromQuery] int permissionLevel)
{
var pid = CurrentPlayerId;
var inv = await db.Inventions.FirstOrDefaultAsync(x => x.Id == inventionId && !x.IsDeleted);
if (inv is null) return NotFound();
if (inv.CreatorPlayerId != pid) return Forbid();
var perm = ClampInventionPermission(permissionLevel);
if (perm == 0) return BadRequest("permissionLevel must be > 0 (Unassigned)");
inv.GeneralPermission = perm;
inv.IsPublished = true;
inv.FirstPublishedAt ??= DateTime.UtcNow;
inv.Permission = perm >= 60 ? 2 : (perm >= 20 ? 1 : 0);
inv.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return Ok(ToWire(inv));
}
[HttpGet("api/inventions/v1/unpublish")]
[Authorize]
public async Task<ActionResult> Unpublish([FromQuery] long inventionId)
{
var pid = CurrentPlayerId;
var inv = await db.Inventions.FirstOrDefaultAsync(x => x.Id == inventionId && !x.IsDeleted);
if (inv is null) return NotFound();
if (inv.CreatorPlayerId != pid) return Forbid();
inv.GeneralPermission = 0;
inv.IsPublished = false;
inv.Permission = 0;
inv.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return Ok(ToWire(inv));
}
[HttpGet("api/inventions/v1/download")]
public async Task<ActionResult> Download([FromQuery] long inventionId)
{
var inv = await db.Inventions.FirstOrDefaultAsync(x => x.Id == inventionId && !x.IsDeleted);
if (inv is null) return NotFound();
inv.SpawnCount += 1;
var pid = CurrentPlayerIdOrNull;
if (pid is long me && me != inv.CreatorPlayerId)
{
// NumPlayersHaveUsedInRoom — increment per-distinct-player.
// Approximate by distinct cheer/check; for now bump once
// per download by non-creator.
inv.NumPlayersHaveUsedInRoom += 1;
}
inv.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return Ok(ToWire(inv));
}
// ── POST mutations ───────────────────────────────────────────────────
public sealed record SetTagsRequest(long InventionId, string? AutoTags, string? PlayerAddedTags);
[HttpPost("api/inventions/v1/settags")]
[Authorize]
public async Task<ActionResult> SetTags([FromBody] SetTagsRequest req)
{
var pid = CurrentPlayerId;
var inv = await db.Inventions.FirstOrDefaultAsync(x => x.Id == req.InventionId && !x.IsDeleted);
if (inv is null) return NotFound();
if (inv.CreatorPlayerId != pid) return Forbid();
var combined = string.Join(',', new[] { req.AutoTags, req.PlayerAddedTags }
.Where(s => !string.IsNullOrWhiteSpace(s))).Trim(',');
inv.TagsCsv = combined;
inv.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return Ok(ToWire(inv));
}
public sealed record CheerRequest(long InventionId);
[HttpPost("api/inventions/v1/cheer")]
[Authorize]
public async Task<ActionResult> Cheer([FromBody] CheerRequest req)
{
var pid = CurrentPlayerId;
var inv = await db.Inventions.FirstOrDefaultAsync(x => x.Id == req.InventionId && !x.IsDeleted);
if (inv is null) return NotFound();
// Idempotent: one cheer per (player, invention).
var existing = await db.Cheers.FirstOrDefaultAsync(c =>
c.FromPlayerId == pid && c.TargetInventionId == req.InventionId);
if (existing is null)
{
db.Cheers.Add(new CheerEntity
{
FromPlayerId = pid,
TargetInventionId = req.InventionId,
CheeredAt = DateTime.UtcNow,
});
inv.CheerCount += 1;
await db.SaveChangesAsync();
if (inv.CreatorPlayerId != pid)
await notifications.NotifyAsync(inv.CreatorPlayerId,
PushNotificationId.InventionModerationStateChanged,
new { Type = "cheer", inv.Id, From = pid, inv.CheerCount });
}
return Ok(new { inv.Id, inv.CheerCount });
}
public sealed record ReportInventionRequest(
long InventionId, int? ReportCategory, string? Details);
[HttpPost("api/inventions/v1/report")]
[Authorize]
public async Task<ActionResult> Report([FromBody] ReportInventionRequest req)
{
var pid = CurrentPlayerId;
var inv = await db.Inventions.FirstOrDefaultAsync(x => x.Id == req.InventionId && !x.IsDeleted);
if (inv is null) return NotFound();
db.Reports.Add(new ReportEntity
{
ReporterPlayerId = pid,
TargetPlayerId = inv.CreatorPlayerId,
TargetInventionId = req.InventionId,
Category = req.ReportCategory ?? 5 /* Other */,
Message = (req.Details ?? string.Empty)[..Math.Min(1000, (req.Details ?? string.Empty).Length)],
});
await db.SaveChangesAsync();
return Ok(new { Reported = true });
}
// ── Wire serializers ─────────────────────────────────────────────────
private static int ClampInventionPermission(int v) => v switch
{
0 or 10 or 20 or 40 or 60 or 80 or 100 => v,
_ => 0,
};
// Wire shape verified against the 2020.12 watch's
// OBBBPCBIMME.PPGFHEDFBEA deserializer (Cpp2IL ISIL OBBBPCBIMME.txt
// lines 588-704). All keys read via Util.GetKey<T> are required —
// a missing key throws KeyNotFoundException → the watch logs
// "Received malformed RecNet response" and aborts the join/fetch.
//
// Required (in disasm order):
// InventionId, ReplicationId, CreatorPlayerId, Name, Description,
// ImageName, CurrentVersionNumber, IsPublished, AllowTrial,
// ModifiedAt, CreatedAt, NumPlayersHaveUsedInRoom, NumDownloads,
// CheerCount, CreatorPermission, GeneralPermission, IsAGInvention,
// Price, HideFromPlayer.
// Optional (Util.GetKeyOrDefault): FirstPublishedAt, CreationRoomId.
//
// Casing is significant — LitJson + Util.GetKey are case-sensitive.
// In particular IsAGInvention has a capital G (matches the asset's
// "IsAGInvention" property, not the entity column IsAgInvention).
private static object ToWire(InventionEntity i) => new
{
InventionId = i.Id,
ReplicationId = string.IsNullOrEmpty(i.ReplicationId)
? Guid.Empty.ToString("D") : i.ReplicationId,
// Wire field is int — IDs in this server are <2^31 so safe.
CreatorPlayerId = (int)i.CreatorPlayerId,
i.Name,
i.Description,
i.ImageName,
i.CurrentVersionNumber,
i.IsPublished,
AllowTrial = true,
ModifiedAt = i.UpdatedAt,
i.CreatedAt,
i.FirstPublishedAt,
i.CreationRoomId,
i.NumPlayersHaveUsedInRoom,
NumDownloads = i.SpawnCount,
i.CheerCount,
i.CreatorPermission,
i.GeneralPermission,
IsAGInvention = i.IsAgInvention,
Price = 0,
HideFromPlayer = false,
};
private static object ToWireV4(InventionEntity i) => ToWire(i);
private static object ToVersionWire(InventionVersionEntity v) => new
{
v.InventionId,
ReplicationId = string.IsNullOrEmpty(v.ReplicationId)
? Guid.Empty.ToString("D") : v.ReplicationId,
v.VersionNumber,
v.InstantiationCost,
v.LightsCost,
v.BlobName,
};
}
@@ -0,0 +1,263 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using DorkNet.Models.Notification;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.API.Inventory;
/// <summary>
/// api.rec.net/api/{equipment,consumables}/* — backs the watch's
/// Backpack tab. Wire DTOs match the decompiled client classes:
///
/// • <c>RecNet.Equipment</c> — fields: <c>PrefabName, ModificationGuid,
/// PlatformMask, IsPlatformLocked, FriendlyName, Tooltip, Rarity,
/// Favorited</c>.
/// • <c>RecNet.Consumable</c> — fields: <c>Id(long), ConsumableType,
/// PlatformMask, Count, InitialCount, UnlockedLevel, CreatedAt,
/// ActiveDurationMinutes(int?), ConsumableItem(nested), Category,
/// IsActive, IsPlatformLocked</c>.
///
/// Persistence: one <see cref="PlayerInventoryEntity"/> row per
/// (player, item). For equipment, Quantity=1 + IsActive tracks
/// favourite. For consumables, Quantity is the stack count + IsActive
/// tracks "currently activated".
/// </summary>
[ApiController]
[Authorize]
public class InventoryController(
DorkNetDbContext db,
NotificationService notifications) : ControllerBase
{
private long Me => this.RequireCurrentPlayerId();
// Categories the StoreItem table uses for equipment vs
// consumables. The seed data tags items per these strings.
private static readonly HashSet<string> EquipmentCategories = new()
{
"equipment", "tool", "maker_pen", "weapon",
};
private static readonly HashSet<string> ConsumableCategories = new()
{
"consumable",
};
// ── Equipment ────────────────────────────────────────────────────────
[HttpGet("api/equipment/v1/getUnlocked")]
[HttpGet("api/equipment/v2/getUnlocked")]
[HttpGet("api/equipment/v3/getUnlocked")]
public async Task<IActionResult> GetUnlockedEquipment()
{
var pid = Me;
var rows = await (from inv in db.PlayerInventory
join item in db.StoreItems on inv.ItemSlug equals item.Slug
where inv.PlayerId == pid
&& EquipmentCategories.Contains(item.Category)
select new { inv, item }).ToListAsync();
return Ok(rows.Select(r => ToEquipmentWire(r.inv, r.item)));
}
public sealed class EquipmentUpdateRequest
{
public string PrefabName { get; set; } = string.Empty;
public string ModificationGuid { get; set; } = string.Empty;
public bool Favorited { get; set; }
}
/// <summary>POST <c>api/equipment/v1/update</c> — toggle the
/// "favorited" flag on a piece of equipment (the watch lets the
/// player pin commonly-used tools).</summary>
[HttpPost("api/equipment/v1/update")]
public async Task<IActionResult> UpdateEquipment([FromBody] EquipmentUpdateRequest req)
{
var pid = Me;
// Catalog-backed skins use a compound slug so we can round-trip
// the client's separate PrefabName + ModificationGuid fields.
var slug = !string.IsNullOrWhiteSpace(req.ModificationGuid)
? StoreService.EquipmentSkinSlug(req.PrefabName, req.ModificationGuid)
: req.PrefabName;
if (string.IsNullOrWhiteSpace(slug)) return BadRequest("missing prefab/mod identifier");
var row = await db.PlayerInventory
.FirstOrDefaultAsync(p => p.PlayerId == pid && p.ItemSlug == slug);
if (row is null && !string.IsNullOrWhiteSpace(req.ModificationGuid))
{
// Legacy rows created before catalog-backed equipment skins
// used the raw modification GUID as ItemSlug.
row = await db.PlayerInventory
.FirstOrDefaultAsync(p => p.PlayerId == pid && p.ItemSlug == req.ModificationGuid);
}
if (row is null && !string.IsNullOrWhiteSpace(req.PrefabName))
{
row = await db.PlayerInventory
.FirstOrDefaultAsync(p => p.PlayerId == pid && p.ItemSlug == req.PrefabName);
}
if (row is null)
{
// Player doesn't own this; reject rather than silently
// creating ownership (avoids letting clients grant items
// to themselves).
return Forbid();
}
row.IsActive = req.Favorited;
await db.SaveChangesAsync();
return Ok(new { Updated = true });
}
// ── Consumables ──────────────────────────────────────────────────────
// 2020.12 client bumped the URL to v2 AND changed the response shape from
// per-item to BulkConsumable: each entry groups N instances of the same
// ConsumableItemDesc with parallel Ids[] / CreatedAts[] arrays. Deserializer
// is PLHFAEBLDAE.PPGFHEDFBEA reading PASCALCASE keys: Ids, CreatedAts,
// ConsumableItemDesc, Count, InitialCount, IsActive, ActiveDurationMinutes,
// IsTransferable. Returning the v1 per-item shape on v2 throws "Malformed
// Response" and the chain calls BootSequence.OINIBKFNFAJ — fatal kick.
[HttpGet("api/consumables/v1/getUnlocked")]
public async Task<IActionResult> GetUnlockedConsumablesV1()
{
var pid = Me;
var rows = await (from inv in db.PlayerInventory
join item in db.StoreItems on inv.ItemSlug equals item.Slug
where inv.PlayerId == pid
&& ConsumableCategories.Contains(item.Category)
select new { inv, item }).ToListAsync();
return Ok(rows.Select(r => ToConsumableWire(r.inv, r.item)));
}
[HttpGet("api/consumables/v2/getUnlocked")]
public async Task<IActionResult> GetUnlockedConsumablesV2()
{
var pid = Me;
var rows = await (from inv in db.PlayerInventory
join item in db.StoreItems on inv.ItemSlug equals item.Slug
where inv.PlayerId == pid
&& ConsumableCategories.Contains(item.Category)
select new { inv, item }).ToListAsync();
// Group by ConsumableItemDesc — one BulkConsumable per consumable type.
var bulk = rows
.GroupBy(r => StoreService.TryGetConsumableItemDesc(r.item.Slug, out var d) ? d : r.item.Slug)
.Select(g => new
{
Ids = g.Select(r => r.inv.Id).ToArray(),
CreatedAts = g.Select(r => r.inv.AcquiredAt).ToArray(),
ConsumableItemDesc = g.Key,
Count = g.Sum(r => r.inv.Quantity),
InitialCount = g.Sum(r => r.inv.Quantity),
IsActive = false,
ActiveDurationMinutes = 0,
IsTransferable = false,
});
return Ok(bulk);
}
public sealed class ConsumableItemRequest
{
public long Id { get; set; }
public int DeltaCount { get; set; }
}
[HttpPost("api/consumables/v1/consume")]
public async Task<IActionResult> Consume([FromBody] ConsumableItemRequest req)
{
var pid = Me;
var row = await db.PlayerInventory
.FirstOrDefaultAsync(p => p.PlayerId == pid && p.Id == req.Id);
if (row is null) return NotFound();
var delta = Math.Max(1, Math.Abs(req.DeltaCount));
row.Quantity = Math.Max(0, row.Quantity - delta);
if (row.Quantity == 0) db.PlayerInventory.Remove(row);
await db.SaveChangesAsync();
await notifications.NotifyAsync(pid,
row.Quantity > 0
? PushNotificationId.ConsumableMappingAdded
: PushNotificationId.ConsumableMappingRemoved,
new { row.Id, row.ItemSlug, row.Quantity });
return Ok(new { row.Id, Count = row.Quantity });
}
public sealed class ActivateConsumableRequest
{
public long Id { get; set; }
public bool IsActive { get; set; }
}
[HttpPost("api/consumables/v1/updateActive")]
public async Task<IActionResult> UpdateActive([FromBody] ActivateConsumableRequest req)
{
var pid = Me;
var row = await db.PlayerInventory
.FirstOrDefaultAsync(p => p.PlayerId == pid && p.Id == req.Id);
if (row is null) return NotFound();
row.IsActive = req.IsActive;
await db.SaveChangesAsync();
return Ok(new { row.Id, row.IsActive });
}
// ── Wire serializers ─────────────────────────────────────────────────
private static object ToEquipmentWire(PlayerInventoryEntity inv, StoreItemEntity item)
{
var prefabName = item.Slug;
var modificationGuid = inv.ItemSlug;
var rarity = 0;
if (StoreService.TryGetEquipmentPayload(item.Slug, out var prefab, out var mod, out var skin))
{
prefabName = prefab;
modificationGuid = mod;
rarity = skin?.rarity ?? 0;
}
return new
{
PrefabName = prefabName,
ModificationGuid = modificationGuid,
PlatformMask = -1, // All
IsPlatformLocked = false,
FriendlyName = item.DisplayName,
Tooltip = item.Description,
Rarity = rarity,
Favorited = inv.IsActive,
};
}
private static object ToConsumableWire(PlayerInventoryEntity inv, StoreItemEntity item)
{
var type = StoreService.TryGetConsumableItemDesc(item.Slug, out var consumableDesc)
? consumableDesc
: item.Slug;
var category = StoreService.TryGetHairDyePayload(item.Slug, out _)
? 6
: 0;
return new
{
inv.Id,
ConsumableItemDesc = type,
ConsumableType = type,
PlatformMask = -1,
Count = inv.Quantity,
InitialCount = inv.Quantity, // we don't track original purchase count
UnlockedLevel = 0,
CreatedAt = inv.AcquiredAt,
ActiveDurationMinutes = (int?)null,
ConsumableItem = new
{
Id = inv.Id,
Type = type,
FriendlyName = item.DisplayName,
Tooltip = item.Description,
ImageName = item.ImageName,
},
Category = category,
inv.IsActive,
IsPlatformLocked = false,
};
}
}
@@ -0,0 +1,301 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.API.Leaderboard;
/// <summary>
/// leaderboard.{rec.net,localhost}/leaderboard/* — per-stat-channel
/// leaderboard reads + writes. URLs verified against
/// <c>Cpp2IL_ISIL/.../RecNet/Leaderboards.txt:1934, 2416, 2700</c>:
///
/// POST leaderboard/SetStat body: SetStatRequestDTO
/// <c>{StatChannel(int), RoomId(long), StatValue(int)}</c>
/// POST leaderboard/GetPlayerRank body: GetPlayerRankData
/// <c>{PlayerId(int), StatChannel(int), RoomId(long)}</c>
///
/// **Subdomain matters**: the client uses
/// <c>Core.SendRequest(method, Service.Leaderboard, requestUri)</c>
/// where <c>Service.Leaderboard = 9</c> resolves via the
/// <c>RecRoomConfig.ServiceUrls</c> map (we serve via
/// <c>api/config/v2</c>) to <c>https://leaderboard.{apex}</c>. So
/// the actual outbound URL is <c>leaderboard.localhost/leaderboard/SetStat</c>
/// — not <c>api.localhost/api/leaderboard/...</c>.
///
/// Response shapes:
/// SetStat → RequestResult ack <c>{success:true,error:""}</c>
/// GetPlayerRank → FullLeaderboard.Entry — <b>camelCase</b> per
/// <c>FullLeaderboard_NestedType_Entry.txt:111-121</c>:
/// <c>{playerId(int), score(long), rank(int)}</c>.
///
/// Without explicit subdomain routing the request fell through to
/// <see cref="GlobalCatchAllController"/> which returns <c>[]</c>;
/// the client's <c>Util.Deserialize&lt;Entry&gt;</c> tried to cast
/// list-as-dictionary →
/// <c>InvalidCastException: Unable to cast 'List' to 'Dictionary'</c>
/// → "Failed to retrieve leaderboard: Malformed Response".
///
/// We bind both the leaderboard subdomain (canonical) AND the api
/// host (defensive — some 2020 builds may proxy via api/) so either
/// path lands here.
/// </summary>
[ApiController]
public class LeaderboardController(
DorkNetDbContext db,
PlayerPresenceService playerPresence) : ControllerBase
{
public sealed class SetStatRequest
{
public int StatChannel { get; set; }
public long RoomId { get; set; }
public int StatValue { get; set; }
}
/// <summary>POST <c>/leaderboard/SetStat</c> on the leaderboard
/// subdomain (canonical client path) AND <c>/api/leaderboard/SetStat</c>
/// on the api host (legacy / proxy fallback).
///
/// **Wire semantics**: the watch's
/// <c>Leaderboards.ConditionalSetStat_ReturningNewValue</c>
/// (Cpp2IL_ISIL/.../RecNet/Leaderboards.txt:645) handles the
/// per-channel direction (high-score vs low-time-better) LOCALLY
/// via a SetMode enum and only POSTs to <c>SetStat</c> when the
/// new value should replace the cached one. The wire request
/// itself carries no direction hint — it's just
/// <c>{StatChannel, RoomId, StatValue}</c>. So the server must
/// trust the client and assign unconditionally; an earlier
/// <c>Math.Max</c> here silently kept the LARGEST value, which
/// broke channels like StuntRunner (channel 7, low elapsed time
/// wins) where the watch posts the new best and we'd discard it
/// for any prior worse value.</summary>
[HttpPost("/leaderboard/SetStat")]
[HttpPost("/api/leaderboard/SetStat")]
[Authorize]
public async Task<IActionResult> SetStat([FromBody] SetStatRequest req)
{
var pid = this.RequireCurrentPlayerId();
var row = await db.LeaderboardStats.FirstOrDefaultAsync(s =>
s.PlayerId == pid && s.StatChannel == req.StatChannel);
if (row is null)
{
row = new LeaderboardStatEntity
{
PlayerId = pid,
StatChannel = req.StatChannel,
Value = req.StatValue,
};
db.LeaderboardStats.Add(row);
}
else
{
row.Value = req.StatValue;
row.UpdatedAt = DateTime.UtcNow;
}
// Auto-attach the channel to whichever room the score came
// from so every room implicitly gets its own leaderboards.
// The watch's <c>SetStatRequestDTO</c> carries
// <c>RoomId</c> (see Cpp2IL_ISIL .../SetStatRequestDTO);
// when it's zero (rare — direct stat report outside a room
// context) fall back to the player's current presence so a
// stray score still gets bucketed somewhere visible.
var roomId = req.RoomId > 0 ? req.RoomId : (playerPresence.GetRoom(pid)?.RoomId ?? 0);
if (roomId > 0)
{
var meta = await db.LeaderboardChannelMeta
.FirstOrDefaultAsync(c => c.Channel == req.StatChannel);
if (meta is null)
{
// First time this channel reports. Stamp a meta row so
// the admin SPA's per-room Leaderboards tab surfaces it
// immediately, with a placeholder name the admin can
// rename later. Direction defaults to higher-is-better;
// admin flips the toggle for time-trial channels.
db.LeaderboardChannelMeta.Add(new LeaderboardChannelMetaEntity
{
Channel = req.StatChannel,
RoomId = roomId,
Name = $"Channel {req.StatChannel}",
LowerIsBetter = false,
ValueFormat = "count",
});
}
else if (meta.RoomId == 0)
{
// Channel registered globally (e.g. from the hardcoded
// KnownStatChannels fallback or an unscoped admin
// entry). Bind it to this room now that we know which
// room it came from. Don't re-bind if it already
// points at a different room — the admin must be the
// one to reassign once a channel has a home.
meta.RoomId = roomId;
meta.UpdatedAt = DateTime.UtcNow;
}
}
await db.SaveChangesAsync();
return Ok(new { success = true, error = "" });
}
public sealed class GetPlayerRankRequest
{
public int PlayerId { get; set; }
public int StatChannel { get; set; }
public long RoomId { get; set; }
}
[HttpPost("/leaderboard/GetPlayerRank")]
[HttpPost("/api/leaderboard/GetPlayerRank")]
public async Task<IActionResult> GetPlayerRank([FromBody] GetPlayerRankRequest req)
{
var pid = req.PlayerId > 0
? (long)req.PlayerId
: this.CurrentPlayerId() ?? 0;
// Empty/no-row case: return a zero entry. The client tolerates
// this — `Entry.Deserialize` reads playerId/score/rank as
// primitives with defaults.
if (pid == 0)
return Ok(new { playerId = 0, score = 0L, rank = 0 });
var row = await db.LeaderboardStats.FirstOrDefaultAsync(s =>
s.PlayerId == pid && s.StatChannel == req.StatChannel);
if (row is null)
return Ok(new { playerId = (int)pid, score = 0L, rank = 0 });
var higherCount = await db.LeaderboardStats
.CountAsync(s => s.StatChannel == req.StatChannel && s.Value > row.Value);
return Ok(new
{
playerId = (int)pid,
score = row.Value,
rank = higherCount + 1,
});
}
public sealed class GetNearbyScoresRequest
{
public int PlayerId { get; set; }
public int StatChannel { get; set; }
public long RoomId { get; set; }
public int WindowSize { get; set; }
}
/// <summary>POST <c>/leaderboard/GetNearbyScores</c> — returns the
/// N entries on either side of the caller's rank. Wire shape per
/// <c>GetNearbyScoresRequestDTO.cs</c>: PlayerId/StatChannel/RoomId
/// inherited from GetRankRequestDTO + WindowSize.</summary>
[HttpPost("/leaderboard/GetNearbyScores")]
[HttpPost("/api/leaderboard/GetNearbyScores")]
public async Task<IActionResult> GetNearbyScores([FromBody] GetNearbyScoresRequest req)
{
var pid = req.PlayerId > 0 ? (long)req.PlayerId : this.CurrentPlayerId() ?? 0;
var window = Math.Clamp(req.WindowSize, 1, 50);
var all = await db.LeaderboardStats
.Where(s => s.StatChannel == req.StatChannel)
.OrderByDescending(s => s.Value)
.Select(s => new { s.PlayerId, s.Value })
.ToListAsync();
if (all.Count == 0)
{
// Empty case must still come back as a SingleLeaderboard
// Dictionary, not a bare array — the 2020 watch's
// SingleLeaderboard.Deserialize reads dict["rows"]
// (Cpp2IL_ISIL/.../RecNet/SingleLeaderboard.txt:60).
// Returning `[]` cast-fails as List→Dictionary inside
// RecNet.Util.Deserialize, logged repeatedly as
// "Received malformed RecNet response: '[]'" and
// eventually starves the StuntRunner UI of a usable
// leaderboard.
return Ok(new { rows = Array.Empty<object>() });
}
var ranked = all.Select((r, i) => new
{
playerId = (int)r.PlayerId,
score = r.Value,
rank = i + 1,
}).ToList();
var myIdx = ranked.FindIndex(r => r.playerId == (int)pid);
if (myIdx < 0)
{
return Ok(new { rows = ranked.Take(window * 2 + 1).ToArray() });
}
var lo = Math.Max(0, myIdx - window);
var hi = Math.Min(ranked.Count - 1, myIdx + window);
return Ok(new { rows = ranked.GetRange(lo, hi - lo + 1).ToArray() });
}
public sealed class GetRanksRequest
{
public int PlayerId { get; set; }
public int StatChannel { get; set; }
public long RoomId { get; set; }
public int RankStart { get; set; }
public int RankEnd { get; set; }
}
/// <summary>POST <c>/leaderboard/GetRanks</c> — returns a slice of
/// the leaderboard between RankStart and RankEnd (inclusive,
/// 1-indexed). Wire shape per <c>GetRanksRequestDTO.cs</c>.</summary>
[HttpPost("/leaderboard/GetRanks")]
[HttpPost("/api/leaderboard/GetRanks")]
public async Task<IActionResult> GetRanks([FromBody] GetRanksRequest req)
{
var start = Math.Max(1, req.RankStart);
var end = Math.Max(start, req.RankEnd);
var skip = start - 1;
var take = Math.Clamp(end - skip, 1, 200);
var rows = await db.LeaderboardStats
.Where(s => s.StatChannel == req.StatChannel)
.OrderByDescending(s => s.Value)
.Skip(skip).Take(take)
.ToListAsync();
var entries = rows.Select((s, i) => new
{
playerId = (int)s.PlayerId,
score = s.Value,
rank = start + i,
}).ToArray();
// SingleLeaderboard wrapper — see GetNearbyScores above.
return Ok(new { rows = entries });
}
/// <summary>Top-N for a channel. Used by the admin tooling and
/// by the watch's full-leaderboard scoreboard view.</summary>
[HttpGet("/leaderboard/Top")]
[HttpGet("/api/leaderboard/Top")]
public async Task<IActionResult> Top([FromQuery] int channel, [FromQuery] int take = 50)
{
take = Math.Clamp(take, 1, 200);
var rows = await db.LeaderboardStats
.Where(s => s.StatChannel == channel)
.OrderByDescending(s => s.Value)
.Take(take)
.ToListAsync();
// FullLeaderboard wire shape: GlobalOverall / GlobalPeriodic /
// FriendsOverall / FriendsPeriodic + NextResetUTC. Use the
// same Entry list for all four buckets — friends-vs-global
// can come later.
var entries = rows.Select((s, i) => new
{
playerId = (int)s.PlayerId,
score = s.Value,
rank = i + 1,
}).ToArray();
return Ok(new
{
GlobalOverall = entries,
GlobalPeriodic = entries,
FriendsOverall = Array.Empty<object>(),
FriendsPeriodic = Array.Empty<object>(),
NextResetUTC = DateTime.UtcNow.AddDays(7),
});
}
}
@@ -0,0 +1,684 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using DorkNet.Models.Notification;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.API.Messages.V2;
/// <summary>
/// api.rec.net/api/messages/v2/* — direct-message inbox API. Backed
/// by <see cref="MessageEntity"/>. Sends fan out a SignalR push
/// (<c>MessageReceived</c>) so the recipient's watch refreshes
/// immediately.
/// </summary>
[ApiController]
[Route("api/[controller]/v2")]
[Authorize]
public class MessagesController(
DorkNetDbContext db,
NotificationService notifications,
PrivateInstanceService privateInstances,
OnlinePresenceService onlinePresence,
PlayerPresenceService presence,
ILogger<MessagesController> logger) : ControllerBase
{
private long Me => this.RequireCurrentPlayerId();
/// <summary>List the caller's inbox, newest-first. Response is a
/// flat array of <see cref="MessageDto"/>; the watch groups by
/// sender locally. v1/v3 paths share the same handler — the watch
/// uses whichever URL its <c>RecNet.Messages</c> static helper
/// built from <c>GameConfig.MessageServiceVersion</c>.</summary>
[HttpGet("get")]
[HttpGet("/api/messages/v1/get")]
[HttpGet("/api/messages/v3/get")]
public async Task<ActionResult<List<MessageDto>>> GetMessages([FromQuery] int take = 100)
{
take = Math.Clamp(take, 1, 200);
var rows = await db.Messages
.Where(m => m.RecipientPlayerId == Me || m.SenderPlayerId == Me)
// Defensive filter for the empty-body ghost rows that
// accumulated before SendFormMessage rejected them. The
// watch's MessagesScreenItem renders TextMessage (type 30)
// as "Message : {body}", so an empty body becomes a
// useless "Message :" entry the user has to scroll past.
// Control-channel types (10 RequestGameInvite, 20
// FriendStatusOnline, etc.) have their own labels and
// legitimately carry no body, so we keep them.
.Where(m => m.Type != 30 || (m.Body != null && m.Body != ""))
.OrderByDescending(m => m.SentAt)
.Take(take)
.ToListAsync();
return Ok(await ToDtosAsync(rows));
}
/// <summary>GET <c>api/messages/v1/unread</c> — list of unread
/// messages addressed to the caller. The watch uses the count for
/// the inbox badge and the entries themselves for the toast
/// preview.</summary>
[HttpGet("/api/messages/v1/unread")]
public async Task<ActionResult<List<MessageDto>>> Unread()
{
var rows = await db.Messages
.Where(m => m.RecipientPlayerId == Me && m.ReadAt == null)
// Same ghost-row filter as GetMessages above — keep the
// unread badge count honest by hiding empty TextMessages.
.Where(m => m.Type != 30 || (m.Body != null && m.Body != ""))
.OrderByDescending(m => m.SentAt)
.Take(100)
.ToListAsync();
return Ok(await ToDtosAsync(rows));
}
public sealed class DeleteRequest
{
public long? Id { get; set; }
public long? MessageId { get; set; }
public List<long>? MessageIds { get; set; }
}
/// <summary>POST <c>api/messages/v3/delete</c> — hard-delete one or
/// more messages addressed to the caller. The 2020 watch's inbox
/// "delete" action posts JSON <c>{"MessageIds":[8]}</c> (plural
/// array, verified from the live trace where the body was
/// <c>{"MessageIds":[8]}</c> against <c>/api/messages/v3/delete</c>);
/// some older / SPA call sites still post <c>id</c> or
/// <c>messageId</c> as a form/query. Accept all three shapes and
/// scope every delete to <c>RecipientPlayerId == Me</c> so the
/// sender can't unsend.</summary>
[HttpPost("/api/messages/v3/delete")]
[HttpPost("/api/messages/v1/delete")]
[Consumes("application/json")]
public async Task<IActionResult> DeleteJson([FromBody] DeleteRequest body)
{
var ids = new List<long>();
if (body.Id is long a && a > 0) ids.Add(a);
if (body.MessageId is long b && b > 0) ids.Add(b);
if (body.MessageIds is { Count: > 0 })
ids.AddRange(body.MessageIds.Where(v => v > 0));
return await DeleteByIds(ids);
}
[HttpPost("/api/messages/v3/delete")]
[HttpPost("/api/messages/v1/delete")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data")]
public async Task<IActionResult> DeleteForm(
[FromForm(Name = "id")] long? formId,
[FromForm(Name = "messageId")] long? formMessageId,
[FromForm(Name = "messageIds")] string? messageIdsCsv)
{
var ids = new List<long>();
if (formId is long a && a > 0) ids.Add(a);
if (formMessageId is long b && b > 0) ids.Add(b);
if (long.TryParse(Request.Query["id"], out var qi) && qi > 0) ids.Add(qi);
if (!string.IsNullOrWhiteSpace(messageIdsCsv))
{
foreach (var s in messageIdsCsv.Split(',', StringSplitOptions.RemoveEmptyEntries))
if (long.TryParse(s.Trim(), out var v) && v > 0) ids.Add(v);
}
return await DeleteByIds(ids);
}
private async Task<IActionResult> DeleteByIds(List<long> ids)
{
ids = ids.Distinct().ToList();
if (ids.Count == 0) return BadRequest(new { error = "missing_id" });
var deleted = await db.Messages
.Where(m => ids.Contains(m.Id) && m.RecipientPlayerId == Me)
.ExecuteDeleteAsync();
return Ok(new { success = true, error = "", deleted });
}
/// <summary>POST <c>api/messages/v3/markRead</c> — bulk
/// mark-as-read for every unread message addressed to the caller.
/// Optional <c>ids</c> CSV scopes to specific message ids.</summary>
[HttpPost("/api/messages/v3/markRead")]
[HttpPost("/api/messages/v1/markRead")]
public async Task<IActionResult> MarkReadBulk([FromForm(Name = "ids")] string? ids)
{
IQueryable<MessageEntity> q = db.Messages
.Where(m => m.RecipientPlayerId == Me && m.ReadAt == null);
if (!string.IsNullOrWhiteSpace(ids))
{
var idSet = ids
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(s => long.TryParse(s, out var v) ? v : 0L)
.Where(v => v > 0)
.ToHashSet();
if (idSet.Count > 0) q = q.Where(m => idSet.Contains(m.Id));
}
var now = DateTime.UtcNow;
await q.ExecuteUpdateAsync(s => s.SetProperty(m => m.ReadAt, now));
return Ok(new { success = true, error = "" });
}
public sealed record SendRequest(long RecipientId, string Body, long? RoomId = null);
/// <summary>Send a direct message — JSON wire shape used by some
/// SPA/admin callers. The 2020 watch posts to the same path with
/// form-urlencoded fields (<see cref="SendFormMessage"/>); the
/// <c>[Consumes]</c> attribute disambiguates which action MVC
/// picks. Empty bodies are rejected so the inbox doesn't get
/// padded with whitespace pings; messages over 2000 chars are
/// truncated to fit the column.</summary>
[HttpPost("send")]
[HttpPost("/api/messages/v1/send")]
[Consumes("application/json")]
public async Task<ActionResult<MessageDto>> SendMessage([FromBody] SendRequest req)
{
if (req.RecipientId <= 0 || req.RecipientId == Me)
return BadRequest(new { error = "invalid_recipient" });
var trimmed = (req.Body ?? string.Empty).Trim();
if (trimmed.Length == 0) return BadRequest(new { error = "empty_body" });
if (trimmed.Length > 2000) trimmed = trimmed[..2000];
var entry = new MessageEntity
{
SenderPlayerId = Me,
RecipientPlayerId = req.RecipientId,
Body = trimmed,
Type = 30, // TextMessage
RoomId = req.RoomId,
};
db.Messages.Add(entry);
await db.SaveChangesAsync();
await BroadcastMessageAsync(entry);
return Ok(ToDto(entry));
}
/// <summary>Form-urlencoded variant — what the 2020 watch actually
/// posts when the user taps "Send Join Request" or any other
/// <c>RecNet.Messages.SendMessage(player, type, data)</c> caller.
/// Wire shape (verified against client log <c>contentType=
/// application/x-www-form-urlencoded</c>, body <c>ToPlayerId=…&amp;
/// Type=10&amp;Data=</c>): the <c>Type</c> integer is a
/// <c>RecNet.Message.MessageType</c> enum value
/// (<c>Cpp2IL_CS/.../RecNet/Message.cs</c>) — 10 =
/// RequestGameInvite, 30 = TextMessage, etc. Empty <c>Data</c> is
/// allowed for control-channel types (join request, declines)
/// where the message itself is the signal.</summary>
[HttpPost("send")]
[HttpPost("/api/messages/v1/send")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data")]
public async Task<ActionResult<MessageDto>> SendFormMessage(
[FromForm(Name = "ToPlayerId")] long toPlayerId,
[FromForm(Name = "Type")] int type,
[FromForm(Name = "Data")] string? data,
[FromForm(Name = "RoomId")] long? roomId)
{
if (toPlayerId <= 0 || toPlayerId == Me)
return BadRequest(new { error = "invalid_recipient" });
var trimmed = (data ?? string.Empty);
if (trimmed.Length > 2000) trimmed = trimmed[..2000];
// For TextMessage (type 30) the body IS the message — empty
// is meaningless and renders as a ghost "Message :" row in
// the watch's inbox (MessagesScreenItem format string at
// Cpp2IL_ISIL/.../MessagesScreenItem.txt:2145 is
// "Message : {0}"). Control-channel types (RequestGameInvite,
// declines, FriendStatusOnline, etc.) intentionally carry
// empty Data — they get rendered by their own label string
// in the same screen item.
if (type == 30 && trimmed.Trim().Length == 0)
return BadRequest(new { error = "empty_text_message" });
var entry = new MessageEntity
{
SenderPlayerId = Me,
RecipientPlayerId = toPlayerId,
Body = trimmed,
Type = type,
RoomId = roomId,
};
db.Messages.Add(entry);
await db.SaveChangesAsync();
logger.LogInformation(
"[messages] send from={From} to={To} type={Type} dataLen={Len}",
Me, toPlayerId, type, trimmed.Length);
await BroadcastMessageAsync(entry);
return Ok(ToDto(entry));
}
/// <summary>POST <c>invite</c> — handles the watch's
/// <c>RecNet.Messages.SendGameInvite(playerId, roomInstanceId)</c>
/// (verified at <c>Cpp2IL_ISIL/.../Messages.txt:613-773</c>): form
/// fields <c>playerId</c> (recipient) + <c>roomInstanceId</c>
/// (the inviter's private match id). Two side-effects:
/// 1. Add the recipient to the invite list for that instance so
/// <c>POST /goto/instance/{id}</c> accepts them.
/// 2. Drop a Type-6 (GameInviteV2) message in their inbox carrying the
/// RoomInstanceId so the message UI's "Join" button knows
/// where to send them.
/// Registered at multiple paths because the exact URL the watch
/// builds for "invite" depends on the messages-namespace base —
/// rather than guess wrong and silently fall through to the
/// wildcard, we cover all plausible mounts and log the one that
/// actually fires.</summary>
[HttpPost("/invite")]
[HttpPost("/api/invite")]
[HttpPost("/api/messages/v1/invite")]
[HttpPost("/api/messages/v2/invite")]
[HttpPost("/api/messages/v3/invite")]
public async Task<ActionResult> SendInvite(
[FromForm(Name = "playerId")] long playerId,
[FromForm(Name = "roomInstanceId")] long roomInstanceId)
{
logger.LogInformation(
"[invite] from={From} → to={To} instance={Instance} (path={Path})",
Me, playerId, roomInstanceId, Request.Path);
if (playerId <= 0 || playerId == Me)
return BadRequest(new { error = "invalid_recipient" });
// Resolve which room the invite is for. Two code paths:
//
// PRIVATE instance: the sender created an invite-only match
// (via /goto/room/X?CreatePrivateInstance=1). The
// roomInstanceId the watch sends maps to a registered
// PrivateInstance and only the owner is allowed to invite
// anyone in. Enforces our private-match access control —
// no random can DM their way past the invite list.
//
// PUBLIC room: the sender is in a public room / a dorm /
// any non-private room. RoomInstanceId is just
// roomId*100_000 — synthetic, never registered. We have to
// resolve which Room they're in via PlayerPresenceService
// (heartbeats keep this up to date) so we can stamp RoomId
// on the message and the recipient's /goto/invite/{N} path
// knows where to send them. No ownership check applies for
// public rooms.
long resolvedRoomId;
string roomName;
var inst = await privateInstances.GetAsync(roomInstanceId);
if (inst is not null)
{
if (inst.OwnerPlayerId != Me) return Forbid();
resolvedRoomId = inst.RoomId;
roomName = await db.Rooms
.Where(r => r.Id == inst.RoomId)
.Select(r => r.Name)
.FirstOrDefaultAsync() ?? "Private match";
}
else
{
// Public-room path. Trust the sender's current presence —
// the watch only fires SendInvite from the social menu
// while you're in a room, and heartbeats refresh presence
// every few seconds. We DON'T trust the raw roomInstanceId
// from the form because it's user-controlled and a
// malicious mod could send a fabricated one.
var senderPresence = presence.GetRoom(Me);
if (senderPresence is null || senderPresence.RoomId <= 0)
{
logger.LogWarning(
"[invite] from={From} to={To} instance={Instance}: no private instance AND no presence room — rejecting",
Me, playerId, roomInstanceId);
return BadRequest(new { error = "sender_not_in_a_room", roomInstanceId });
}
resolvedRoomId = senderPresence.RoomId;
roomName = !string.IsNullOrWhiteSpace(senderPresence.Name)
? senderPresence.Name
: await db.Rooms
.Where(r => r.Id == senderPresence.RoomId)
.Select(r => r.Name)
.FirstOrDefaultAsync() ?? "Room";
}
// Persist a real RecNet.Message.MessageType.GameInviteV2 (=6)
// so the watch's GameInviteController calls
// Matchmaking.AcceptRoomInvite(inviteId, roomId), which posts
// goto/invite/{inviteId}. Type 0 follows the sender's current
// presence instead and can land in the wrong instance.
//
// Two saves: the first allocates the message Id (which becomes
// the `inviteId` the watch sends back in /goto/invite/{N}); the
// second backfills the JSON Body once we know it. We can't
// pre-allocate the Id because EF Core's identity columns only
// populate on SaveChanges.
var entry = new MessageEntity
{
SenderPlayerId = Me,
RecipientPlayerId = playerId,
// Placeholder so the row passes the NOT NULL constraint
// until we overwrite it post-save with the real JSON.
Body = string.Empty,
Type = 6,
RoomId = resolvedRoomId,
};
db.Messages.Add(entry);
await db.SaveChangesAsync();
// Only pin the invitee row for PRIVATE instances — public-room
// invites have no registered instance to track invitees against.
// GoToInvite handles the missing-mapping case by falling through
// to the message's RoomId + BuildResponseAsync(roomName).
if (inst is not null)
{
await privateInstances.InviteAsync(roomInstanceId, playerId, messageId: entry.Id);
}
// Body shape MUST be the JSON the watch's Message.Deserialize
// builds RoomInviteDetails from (verified at
// Cpp2IL_ISIL/.../RecNet/Message.txt:786-805 — for Type 6/7
// the parser hits `Json.Deserialize` and reads "inviteId" +
// "name"; for other types it `Int32.Parse`s the body).
//
// inviteId → the message Id; watch echoes it back in
// /goto/invite/{inviteId}, server looks the
// row up to resolve roomInstanceId.
// name → display string in the invite toast / row.
// roomInstanceId → extra field the watch ignores; server uses
// it (via TryParseRoomInstanceId's JSON
// fallback) so /goto/invite/{N} doesn't have
// to re-query the private-instance service.
//
// The roomInstanceId MUST be a JSON STRING, not a number. LitJson
// chokes on the dict-cast when a value is a 64-bit integer larger
// than int32 — confirmed via SaveDebugMod trace: the watch logs
// `data={"inviteId":31,"name":"Dorm_1811750","roomInstanceId":6546545006375796736}`
// `RoomInviteDetails=<null>`
// `System.NullReferenceException at RecNet.Util.GetKey from
// RecNet.Message.Deserialize`
// Json.Deserialize returns a null cast-result on the long value,
// GetKey("inviteId") then NREs and the whole parse aborts before
// RoomInviteDetails is built. Stringifying the value sidesteps
// LitJson's long-int handling entirely; the watch only reads
// inviteId + name so the string form is invisible to it.
entry.Body = System.Text.Json.JsonSerializer.Serialize(new
{
inviteId = entry.Id,
name = roomName,
roomInstanceId = roomInstanceId.ToString(System.Globalization.CultureInfo.InvariantCulture),
});
await db.SaveChangesAsync();
await BroadcastMessageAsync(entry);
return Ok(new { success = true, messageId = entry.Id });
}
/// <summary>Mark a single message as read. No-op if not
/// addressed to the caller — prevents the sender from "reading"
/// the recipient's copy.</summary>
[HttpPost("markread/{id:long}")]
public async Task<ActionResult> MarkRead(long id)
{
var msg = await db.Messages.FirstOrDefaultAsync(m =>
m.Id == id && m.RecipientPlayerId == Me);
if (msg is null) return NotFound();
if (msg.ReadAt is null)
{
msg.ReadAt = DateTime.UtcNow;
await db.SaveChangesAsync();
}
return Ok();
}
public sealed class SendMultipleBody
{
public List<long>? ToPlayerIds { get; set; }
public int Type { get; set; }
public string? Data { get; set; }
public long? RoomId { get; set; }
}
/// <summary>POST <c>api/messages/v1/sendMultiple</c> — fan-out a
/// message to many recipients. Persists one MessageEntity per
/// recipient. Wire shape: <c>{ToPlayerIds (int[]), Type (int), Data (string)}</c>.</summary>
[HttpPost("/api/messages/v1/sendMultiple")]
[Consumes("application/json")]
public async Task<IActionResult> SendMultiple([FromBody] SendMultipleBody body)
{
if (body.ToPlayerIds is null || body.ToPlayerIds.Count == 0)
return Ok(Array.Empty<object>());
var entries = new List<MessageEntity>();
foreach (var to in body.ToPlayerIds.Distinct().Where(id => id > 0 && id != Me))
{
var entry = new MessageEntity
{
SenderPlayerId = Me,
RecipientPlayerId = to,
Body = body.Data ?? string.Empty,
Type = body.Type,
RoomId = body.RoomId,
};
entries.Add(entry);
db.Messages.Add(entry);
}
await db.SaveChangesAsync();
foreach (var entry in entries)
{
await BroadcastMessageAsync(entry);
}
return Ok(Array.Empty<object>());
}
/// <summary>POST <c>api/messages/v{N}/send</c> — single-recipient
/// send. The 2020.12 watch uses this for any 1:1 message that
/// isn't a game invite, notably the <c>PartyUpRequest</c>
/// (MessageType = 120 in IGCIBGKPPMO.BBELBJELLHN) that fires
/// when the player taps "party up" on someone NOT in the same
/// room (in-room party formation goes purely through PunRPCs in
/// PlayerParty, no HTTP). Without this route, the watch's
/// Messages.SendMessage call 404s and party invites silently
/// fail with no on-screen feedback.
///
/// <para>Wire shape verified at KEBJPIGKGOI.txt:2618-2655 —
/// <c>{0}v2/send</c> with form fields <c>ToPlayerId</c> (singular,
/// long), <c>Type</c> (int), <c>Data</c> (string), <c>RoomId</c>
/// (optional long). KEBJPIGKGOI.txt:1456-1478 shows the sibling
/// sendMultiple path uses <c>ToPlayerIds</c> (plural) — same
/// other fields. v1 and v2 of <c>/send</c> are protocol-identical
/// so we accept both.</para></summary>
[HttpPost("/api/messages/v1/send")]
[HttpPost("/api/messages/v2/send")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
public async Task<IActionResult> SendSingle(
[FromForm(Name = "ToPlayerId")] long? toPlayerIdForm,
[FromForm(Name = "Type")] int typeForm,
[FromForm(Name = "Data")] string? dataForm,
[FromForm(Name = "RoomId")] long? roomIdForm)
{
var toPlayerId = toPlayerIdForm ?? 0;
if (toPlayerId <= 0 || toPlayerId == Me)
return BadRequest(new { error = "invalid_recipient" });
var entry = new MessageEntity
{
SenderPlayerId = Me,
RecipientPlayerId = toPlayerId,
Body = dataForm ?? string.Empty,
Type = typeForm,
RoomId = roomIdForm,
};
db.Messages.Add(entry);
await db.SaveChangesAsync();
await BroadcastMessageAsync(entry);
logger.LogInformation(
"[send] from={From} to={To} type={Type} roomId={RoomId} messageId={Id}",
Me, toPlayerId, typeForm, roomIdForm, entry.Id);
return Ok(new { success = true, messageId = entry.Id });
}
/// <summary>POST <c>api/offlineinvite/v1/send</c> — send an invite
/// message to a player who's offline; surfaces in their inbox on
/// next login. Wire shape: form-urlencoded <c>RecipientId</c> +
/// <c>Data</c>. Idempotent per (sender, recipient, body).</summary>
[HttpPost("/api/offlineinvite/v1/send")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data")]
public async Task<IActionResult> OfflineInviteSend(
[FromForm(Name = "RecipientId")] long? recipientId,
[FromForm(Name = "Data")] string? data)
{
if (recipientId is not long rid || rid <= 0 || rid == Me)
return Ok(new { success = false, error = "invalid_recipient" });
var entry = new MessageEntity
{
SenderPlayerId = Me,
RecipientPlayerId = rid,
Body = data ?? string.Empty,
Type = 0,
};
db.Messages.Add(entry);
await db.SaveChangesAsync();
await BroadcastMessageAsync(entry);
return Ok(new { success = true, messageId = entry.Id });
}
/// <summary>GET <c>api/offlineinvite/v1/all</c> — invites
/// received while the player was offline. Pulls unread
/// MessageEntity rows whose body looks like an invite.</summary>
[HttpGet("/api/offlineinvite/v1/all")]
public async Task<IActionResult> OfflineInviteAll()
{
var rows = await db.Messages
.Where(m => m.RecipientPlayerId == Me && m.ReadAt == null
&& (m.Body.Contains("invite") || m.Body.Contains("event")))
.OrderByDescending(m => m.SentAt)
.Take(50)
.Select(m => new
{
InviteId = m.Id,
FromPlayerId = (int)m.SenderPlayerId,
m.Body,
SentAt = m.SentAt,
})
.ToListAsync();
return Ok(rows);
}
/// <summary>The boot sequence asks for online friends. We
/// derive that from the SignalR hub's connected-player set —
/// the response shape is <c>[ {AccountId, Online}, ... ]</c>.
///
/// Both <c>favoriteFriendOnlineStatus</c> and the more general
/// <c>onlineStatus</c> URLs alias to this handler since they
/// share the wire shape; pre-2026-05-25 the onlineStatus route
/// was wrongly aliased to GetMessages and returned the inbox
/// list instead, breaking the friends-online HUD.</summary>
[HttpGet("/api/messages/v1/favoriteFriendOnlineStatus")]
[HttpGet("/api/messages/v1/onlineStatus")]
public async Task<ActionResult> FavoriteFriendOnlineStatus()
{
var friendIds = await db.Relationships
.Where(r => r.Status == RelationshipStatus.Friend &&
(r.RequesterId == Me || r.TargetId == Me))
.Select(r => r.RequesterId == Me ? r.TargetId : r.RequesterId)
.ToListAsync();
var online = onlinePresence.OnlinePlayerIds().ToHashSet();
return Ok(friendIds.Select(id => new
{
AccountId = id,
Online = online.Contains(id),
}));
}
/// <summary>Wire shape verified against
/// <c>Cpp2IL_ISIL/.../RecNet/Message.txt:591-787</c>.
/// <c>RecNet.Message.Deserialize</c> calls <c>Util.GetKey</c>
/// (throws on miss) for: <c>Id</c> (long), <c>FromPlayerId</c> (int),
/// <c>SentTime</c> (DateTime), <c>Type</c> (int — MessageType enum),
/// and reads <c>Data</c> (string) via <c>Util.GetKeyOrDefault</c>.
/// Optional nullable wrappers: <c>RoomId</c>, <c>PlayerEventId</c>.
/// Previous response used <c>{Id, Sender, Recipient, Body, SentAt, ReadAt}</c>
/// which crashed at <c>Util.GetKey("FromPlayerId")</c>.</summary>
public sealed record MessageDto(
long Id,
long FromPlayerId,
DateTime SentTime,
int Type,
string Data,
long? RoomId,
long? PlayerEventId);
private async Task<List<MessageDto>> ToDtosAsync(List<MessageEntity> rows)
{
var instanceIds = rows
.Where(m => IsInstanceInviteType(m.Type)
&& m.RoomId is null
&& long.TryParse(m.Body, System.Globalization.NumberStyles.Integer,
System.Globalization.CultureInfo.InvariantCulture, out _))
.Select(m => long.Parse(m.Body, System.Globalization.NumberStyles.Integer,
System.Globalization.CultureInfo.InvariantCulture))
.Distinct()
.ToList();
var roomByInstance = instanceIds.Count == 0
? new Dictionary<long, long>()
: await db.PrivateInstances
.Where(i => instanceIds.Contains(i.Id))
.Select(i => new { i.Id, i.RoomId })
.ToDictionaryAsync(i => i.Id, i => i.RoomId);
return rows.Select(m =>
{
long? roomId = m.RoomId;
if (roomId is null
&& IsInstanceInviteType(m.Type)
&& long.TryParse(m.Body, System.Globalization.NumberStyles.Integer,
System.Globalization.CultureInfo.InvariantCulture, out var instanceId)
&& roomByInstance.TryGetValue(instanceId, out var rid))
{
roomId = rid;
}
return ToDto(m, roomId);
}).ToList();
}
private static bool IsInstanceInviteType(int type) => type is 6 or 7;
/// <summary>Push a <c>MessageReceived</c> SignalR notification to BOTH
/// the recipient AND the sender. The recipient half is the obvious one
/// — that's what fires the watch's <c>OnMessageAdded</c> event and
/// gives them the toast / inbox-row update. The sender-half is less
/// obvious but equally needed: the watch's <c>Messages.SendMessage</c>
/// (Messages.txt:1790-1900) just does <c>ExpectHttpStatusSuccess</c>
/// on the response and never touches the local <c>MessageList</c>,
/// so without a self-targeted push the sender's chat thread doesn't
/// know about its own outgoing message until the next
/// <c>RefreshList</c> (which only fires on hub-reconnect or first
/// inbox open). Pushing to both sides keeps both watches in sync
/// in real time. Dedup happens client-side in
/// <c>OnMessageReceived</c> via the "already-in-MessageList by Id"
/// check (Messages.txt:2790-2793), so a player who somehow ends up
/// as both sender and recipient (admin self-messaging) only sees
/// the message once.</summary>
private async Task BroadcastMessageAsync(MessageEntity entry)
{
var dto = ToDto(entry);
await notifications.NotifyAsync(entry.RecipientPlayerId,
PushNotificationId.MessageReceived, dto);
if (entry.SenderPlayerId != entry.RecipientPlayerId)
{
await notifications.NotifyAsync(entry.SenderPlayerId,
PushNotificationId.MessageReceived, dto);
}
}
private static MessageDto ToDto(MessageEntity m, long? roomId = null) =>
new(
Id: m.Id,
FromPlayerId: m.SenderPlayerId,
SentTime: m.SentAt,
// Round-trip the stored Type verbatim. Earlier we coerced
// Type==0 → 30 for "pre-Type-column rows", but the
// Messages.Type Postgres patch (Program.cs:612-614) added
// the column with DEFAULT 30 and backfilled every legacy
// row to 30, so any Type==0 row in the DB now is a real
// GameInvite (e.g. SendInvite above). Coercing it to 30
// routes it through TextMessageController and renders
// "Message : <instanceId>" with no Accept button.
Type: m.Type,
Data: m.Body,
RoomId: roomId ?? m.RoomId,
PlayerEventId: null);
}
@@ -0,0 +1,211 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.API.Moderation;
/// <summary>
/// In-game moderation endpoints invoked by the watch's player-action
/// dialogs. URLs / form-fields verified against the Cpp2IL ISIL dump:
///
/// POST api/PlayerReporting/v3/create
/// PlayerReporting.txt:1696 + 1712-1822
/// Form: PlayerIdReported (int), ReportCategory (int), Details (string),
/// HeightReporter (float "F2"), HeightReported (float "F2"),
/// RoomId (long).
///
/// POST api/PlayerReporting/v3/voteToKick
/// PlayerReporting.txt:3175 + 3215-3237
/// Form: PlayerId (long), Response (bool), GameSessionId (long).
///
/// POST api/PlayerReporting/v1/instantKick
/// PlayerReporting.txt:3417 (single) + 3590 (bulk)
/// Body: PlayerId(s) — one or many. Admin/dev only.
///
/// POST api/PlayerReporting/v1/deviceId
/// PlayerReporting.txt:3736 + 3749
/// Form: oldDeviceId (string).
///
/// POST api/PlayersBanned/v2/ban
/// Moderation.txt:623-702
/// Form: PlayerId (long), Reason (int), BanType (int),
/// DisplayReason (string), BannedUntil (string MM/dd/yyyy HH:mm).
/// Admin only.
///
/// Without these handlers the watch fell through to the JSON catch-all
/// returning <c>{}</c>, which the OkResponse parser tolerates but the
/// admin-action then has no observable effect — i.e. "the admin
/// system doesn't work at all".
/// </summary>
[ApiController]
[Authorize]
public class InGameModerationController(
DorkNetDbContext db,
NotificationService notifications,
ILogger<InGameModerationController> logger) : ControllerBase
{
// ── /v3/create — file a player report ────────────────────────────
[HttpPost("api/PlayerReporting/v3/create")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
public async Task<IActionResult> CreateReport(
[FromForm(Name = "PlayerIdReported")] long? playerIdReported,
[FromForm(Name = "ReportCategory")] int reportCategory = 0,
[FromForm(Name = "Details")] string? details = null,
[FromForm(Name = "HeightReporter")] float? heightReporter = null,
[FromForm(Name = "HeightReported")] float? heightReported = null,
[FromForm(Name = "RoomId")] long roomId = 0)
{
if (playerIdReported is not long target || target <= 0)
return Ok(new { success = false, error = "missing_target" });
var reporter = this.RequireCurrentPlayerId();
if (reporter == target)
return Ok(new { success = false, error = "cannot_report_self" });
var trimmed = (details ?? string.Empty).Trim();
if (trimmed.Length > 1000) trimmed = trimmed[..1000];
db.Reports.Add(new ReportEntity
{
ReporterPlayerId = reporter,
TargetPlayerId = target,
Category = reportCategory,
Message = trimmed,
RoomId = roomId,
});
await db.SaveChangesAsync();
logger.LogInformation(
"[mod] report filed: reporter={Reporter} target={Target} category={Cat} room={Room}",
reporter, target, reportCategory, roomId);
return Ok(new { success = true, error = "" });
}
// ── /v3/voteToKick — room-vote kick ──────────────────────────────
[HttpPost("api/PlayerReporting/v3/voteToKick")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
public IActionResult VoteToKick(
[FromForm(Name = "PlayerId")] long? playerId,
[FromForm(Name = "Response")] bool? response,
[FromForm(Name = "GameSessionId")] long gameSessionId = 0)
{
var caller = this.RequireCurrentPlayerId();
logger.LogInformation(
"[mod] vote-to-kick by {Caller}: target={Target} yes={Yes} session={Session}",
caller, playerId, response, gameSessionId);
// Real implementation would tally votes per (session, target) and
// push a kick once the threshold tripped. For now we accept the
// vote without side-effects so the watch UI's "voted" indicator
// shows.
return Ok(new { success = true, error = "" });
}
// ── /v1/instantKick — admin one-shot ─────────────────────────────
/// <summary>
/// Admin/dev one-shot kick. The watch posts either
/// <c>?playerId=N</c> for a single target (PlayerReporting.txt:3417)
/// or a list body for bulk kicks (3590). Either form lands here.
/// Gated to <see cref="PlayerEntity.IsAdmin"/> /
/// <see cref="PlayerEntity.IsDeveloper"/>.
/// </summary>
[HttpPost("api/PlayerReporting/v1/instantKick")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
public async Task<IActionResult> InstantKick(
[FromQuery(Name = "playerId")] long? singleId,
[FromForm(Name = "PlayerId")] long? formId,
[FromForm(Name = "PlayerIds")] string? formIds)
{
var caller = this.RequireCurrentPlayerId();
if (!await IsCallerAdminOrDevAsync(caller))
return Forbid();
var ids = new List<long>();
if (singleId is long s && s > 0) ids.Add(s);
if (formId is long f && f > 0) ids.Add(f);
if (!string.IsNullOrEmpty(formIds))
{
foreach (var part in formIds.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
if (long.TryParse(part, out var v) && v > 0) ids.Add(v);
}
ids = ids.Distinct().ToList();
if (ids.Count == 0) return Ok(new { success = false, error = "missing_target" });
foreach (var target in ids)
{
logger.LogInformation("[mod] instant-kick by {Caller}: target={Target}", caller, target);
// Push a SignalR ModerationKick notification — the watch's
// PlayerReporting.OnModerationKick handler boots the player
// back to their dorm.
await notifications.NotifyAsync(
target,
Models.Notification.PushNotificationId.ModerationKick);
}
return Ok(new { success = true, error = "" });
}
// ── /v1/deviceId — log mismatched device id ──────────────────────
[HttpPost("api/PlayerReporting/v1/deviceId")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
public IActionResult MismatchedDeviceWarning(
[FromForm(Name = "oldDeviceId")] string? oldDeviceId)
{
logger.LogInformation(
"[mod] mismatched device id reported: caller={Caller} old={Old}",
this.CurrentPlayerId(), oldDeviceId);
return Ok(new { success = true, error = "" });
}
// ── /api/PlayersBanned/v2/ban — admin ban ────────────────────────
[HttpPost("api/PlayersBanned/v2/ban")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
public async Task<IActionResult> Ban(
[FromForm(Name = "PlayerId")] long? playerId,
[FromForm(Name = "Reason")] int reason = 0,
[FromForm(Name = "BanType")] int banType = 0,
[FromForm(Name = "DisplayReason")] string? displayReason = null,
[FromForm(Name = "BannedUntil")] string? bannedUntil = null)
{
var caller = this.RequireCurrentPlayerId();
if (!await IsCallerAdminOrDevAsync(caller))
return Forbid();
if (playerId is not long target || target <= 0)
return Ok(new { success = false, error = "missing_target" });
// BannedUntil arrives as "MM/dd/yyyy HH:mm" per Moderation.txt:696.
DateTime? until = null;
if (!string.IsNullOrEmpty(bannedUntil) &&
DateTime.TryParseExact(bannedUntil, "MM/dd/yyyy HH:mm",
System.Globalization.CultureInfo.InvariantCulture,
System.Globalization.DateTimeStyles.AssumeUniversal | System.Globalization.DateTimeStyles.AdjustToUniversal,
out var parsed))
{
until = parsed;
}
var p = await db.Players.FirstOrDefaultAsync(x => x.Id == target);
if (p is null) return Ok(new { success = false, error = "no_such_player" });
p.BannedUntil = until ?? DateTime.UtcNow.AddYears(99);
await db.SaveChangesAsync();
logger.LogInformation(
"[mod] ban by {Caller}: target={Target} reason={Reason} until={Until} display={Display}",
caller, target, reason, p.BannedUntil, displayReason);
// Push a ModerationRoomBan notification so the target watch
// shows the ban dialog and disconnects (PlayerReporting.txt:2253).
await notifications.NotifyAsync(
target,
Models.Notification.PushNotificationId.ModerationRoomBan);
return Ok(new { success = true, error = "" });
}
private async Task<bool> IsCallerAdminOrDevAsync(long callerId) =>
await db.Players
.Where(p => p.Id == callerId)
.Select(p => p.IsAdmin || p.IsDeveloper)
.FirstOrDefaultAsync();
}
@@ -0,0 +1,34 @@
using Microsoft.AspNetCore.Mvc;
using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.API.Notification.V2;
/// <summary>
/// Legacy raw-WebSocket notification endpoint. Replaced by the SignalR
/// hub at <c>notify.{apex}/hub/v1</c>. Returns 410 Gone for any client
/// that still tries the old path.
/// </summary>
[ApiController]
[Route("api/[controller]/v2")]
public class NotificationController(DomainConfig domain) : ControllerBase
{
[HttpGet]
public IActionResult Connect() => StatusCode(StatusCodes.Status410Gone, new
{
error = "use_signalr_hub",
hint = $"connect to {domain.Sub("notify")}/hub/v1 instead",
});
/// <summary>GET <c>api/notification/hub/v1</c> — returns the
/// SignalR hub URL the client should open. Without this the
/// watch falls back to its own default and may try the wrong
/// scheme. URL is built off the configured deployment apex
/// (DORKNET_DOMAIN).</summary>
[HttpGet("/api/notification/hub/v1")]
public IActionResult HubInfo() => Ok(new
{
Url = $"https://{domain.Sub("notify")}/hub/v1",
Protocol = "signalr",
Version = 1,
});
}
@@ -0,0 +1,128 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
namespace DorkNet.Server.Controllers.API.NotificationPrefs;
/// <summary>
/// api.rec.net/api/messages/v1/IOSGetNotificationPreferences +
/// IOSModifyNotificationPreferences. Wire DTO matches
/// <c>RecNet.IOSNotificationPreferences</c> (decompiled at
/// <c>Cpp2IL_CS/.../RecNet/IOSNotificationPreferences.cs</c>):
/// <c>ChatMessage(bool), FriendInvite(bool), FavoriteFriendOnline(bool)</c>.
///
/// Persists via <see cref="NotificationPrefsEntity"/> keyed by
/// (PlayerId, Platform). Defaults to all-on when no row exists yet.
/// </summary>
[ApiController]
[Authorize]
public class IOSNotificationPrefsController(DorkNetDbContext db) : ControllerBase
{
private long Me => this.RequireCurrentPlayerId();
[HttpGet("api/messages/v1/IOSGetNotificationPreferences")]
public async Task<IActionResult> Get()
{
var pid = Me;
var row = await db.NotificationPrefs.FirstOrDefaultAsync(p => p.PlayerId == pid);
return Ok(ToWire(row));
}
public sealed class IOSPrefsRequest
{
public bool ChatMessage { get; set; } = true;
public bool FriendInvite { get; set; } = true;
public bool FavoriteFriendOnline { get; set; } = true;
}
[HttpPost("api/messages/v1/IOSModifyNotificationPreferences")]
public async Task<IActionResult> Modify([FromBody] IOSPrefsRequest body)
{
var pid = Me;
var row = await db.NotificationPrefs.FirstOrDefaultAsync(p => p.PlayerId == pid);
if (row is null)
{
row = new NotificationPrefsEntity
{
PlayerId = pid,
Platform = "ios",
};
db.NotificationPrefs.Add(row);
}
row.AllowMessage = body.ChatMessage;
row.AllowFriendRequest = body.FriendInvite;
row.AllowAnnouncements = body.FavoriteFriendOnline;
row.AllowAll = body.ChatMessage || body.FriendInvite || body.FavoriteFriendOnline;
row.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return Ok(ToWire(row));
}
/// <summary>POST <c>IOSResetNotificationPreferencesBadgeCount</c> —
/// the watch sends this when the user opens the iOS app to clear
/// the badge counter. No persistent state to mutate; ack.</summary>
[HttpPost("api/messages/v1/IOSResetNotificationPreferencesBadgeCount")]
public IActionResult ResetBadge() => Ok(new { Reset = true });
public sealed class SaveTokenRequest
{
public string? Token { get; set; }
public string? DeviceToken { get; set; }
public string? Platform { get; set; }
}
/// <summary>POST <c>api/messages/v1/IOSSaveDeviceToken</c> —
/// upserts the caller's APNS token to
/// <see cref="PushTokenEntity"/> for a future APNS proxy to fan
/// out push notifications.</summary>
[HttpPost("api/messages/v1/IOSSaveDeviceToken")]
public async Task<IActionResult> SaveDeviceToken(
[FromBody] SaveTokenRequest? body,
[FromForm(Name = "Token")] string? tokenForm,
[FromForm(Name = "DeviceToken")] string? deviceTokenForm)
{
var pid = Me;
var token = body?.Token ?? body?.DeviceToken ?? tokenForm ?? deviceTokenForm ?? string.Empty;
var platform = body?.Platform ?? "ios";
if (string.IsNullOrWhiteSpace(token))
return BadRequest(new { error = "missing token" });
var row = await db.PushTokens.FirstOrDefaultAsync(t =>
t.PlayerId == pid && t.Platform == platform);
if (row is null)
{
row = new PushTokenEntity { PlayerId = pid, Platform = platform };
db.PushTokens.Add(row);
}
row.Token = token;
row.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return Ok(new { Saved = true });
}
/// <summary>POST <c>api/messages/v1/IOSClearDeviceToken</c> —
/// drops every push-token row for the caller (e.g. on iOS
/// logout).</summary>
[HttpPost("api/messages/v1/IOSClearDeviceToken")]
public async Task<IActionResult> ClearDeviceToken()
{
var pid = Me;
var rows = await db.PushTokens.Where(t => t.PlayerId == pid).ToListAsync();
if (rows.Count > 0)
{
db.PushTokens.RemoveRange(rows);
await db.SaveChangesAsync();
}
return Ok(new { Cleared = rows.Count });
}
private static object ToWire(NotificationPrefsEntity? r) => new
{
ChatMessage = r?.AllowMessage ?? true,
FriendInvite = r?.AllowFriendRequest ?? true,
FavoriteFriendOnline = r?.AllowAnnouncements ?? true,
};
}
@@ -0,0 +1,57 @@
using DorkNet.Server.Auth;
using Microsoft.AspNetCore.Mvc;
namespace DorkNet.Server.Controllers.API.Pageview;
/// <summary>
/// Endpoints behind the watch's <c>COFHGNFJMOG</c> page-view + link
/// system. The 2020.12 client fires <c>POST pageview/consume</c> on
/// every screen change in the boot/menu flow expecting a response
/// that tells it (a) whether there's an in-app deep-link URL to
/// resolve and (b) how long the answer is fresh for. Returning a 404
/// here surfaces as red toast spam in the menus and (per
/// <c>BootSequence.OINIBKFNFAJ</c>) can short-circuit the avatar /
/// inventory bootstrap.
///
/// <para>Response shape verified at
/// <c>Cpp2IL_ISIL/.../MPHABHIMOOO.txt:45,50</c> — the watch's
/// <c>MPHABHIMOOO.PPGFHEDFBEA(Dictionary)</c> reads two camelCase
/// keys:
/// <list type="bullet">
/// <item><c>url</c> — string. Empty means "no deep-link to load".</item>
/// <item><c>freshnessSeconds</c> — int. How long the client can
/// cache this response before re-polling.</item>
/// </list>
/// Returning a long freshness (1 hour) keeps a private server's
/// chattiness low when there's no real linking layer.</para>
///
/// <para>Sibling endpoints from the same watch class
/// (<c>actionlink/*</c>, <c>datalink/*</c>, <c>referral</c>) are out
/// of scope for now — they fire only on explicit user share/click,
/// not in the boot path. Add them as separate routes when they show
/// up in the production log.</para>
/// </summary>
[ApiController]
public class PageviewController(ILogger<PageviewController> logger) : ControllerBase
{
/// <summary>POST <c>pageview/consume</c> — record a screen
/// transition. The body is empty in 2020.12.
/// <c>Promise&lt;MPHABHIMOOO&gt;</c> result; the watch only uses
/// the <c>url</c> / <c>freshnessSeconds</c> fields. We
/// intentionally return an empty url + a 1-hour freshness — the
/// private server has no in-app deep-link system, so there's
/// nothing to navigate to and no reason to be polled more often.</summary>
[HttpPost("pageview/consume")]
[HttpPost("/pageview/consume")]
[HttpGet("pageview/consume")]
[HttpGet("/pageview/consume")]
public IActionResult Consume()
{
logger.LogDebug("[pageview] consume callerId={Caller}", this.CurrentPlayerId() ?? 0);
return Ok(new
{
url = string.Empty,
freshnessSeconds = 3600,
});
}
}
@@ -0,0 +1,335 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using DorkNet.Models.Notification;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.API.Photos;
/// <summary>
/// api.rec.net/api/photos/v1/* — social-feed wrapper around the
/// in-game camera. The camera itself uses storage.rec.net/upload with
/// FileType=Image to push the PNG bytes; that returns a Filename. The
/// watch's "Share" panel then POSTs here with that Filename, a caption
/// and any tagged players to create the public-feed photo.
///
/// Public reads (feed, by-player, by-room) are anonymous so the
/// feed.rec.net frontend can browse without an admin token.
/// Mutations (post, cheer, delete) require auth.
/// </summary>
[ApiController]
// Two hosts: api.rec.net is what the in-game watch hits; feed.rec.net
// also serves these endpoints so the public frontend website can do
// same-origin fetches without CORS.
[Route("api/photos/v1")]
public class PhotosController(
DorkNetDbContext db,
PlayerPresenceService presence,
NotificationService notifications,
LevelService level,
DomainConfig domain) : ControllerBase
{
public sealed record PostPhotoRequest(
string ImageName,
string? Caption,
string? TaggedPlayerIds,
long? RoomId,
bool? IsPublic);
/// <summary>POST api/photos/v1/post — promote an uploaded image
/// blob to the photo feed. Body: ImageName (filename returned by
/// storage.rec.net/upload), Caption, TaggedPlayerIds (CSV),
/// optional RoomId (defaults to caller's current room from
/// PlayerPresenceService), IsPublic (defaults true). Returns the
/// created PhotoEntity.</summary>
[HttpPost("post")]
[Authorize]
public async Task<ActionResult> PostPhoto([FromBody] PostPhotoRequest body)
{
var me = this.RequireCurrentPlayerId();
if (string.IsNullOrWhiteSpace(body.ImageName))
return BadRequest(new { error = "missing_image_name" });
// Verify the blob actually exists and was uploaded by us. Without
// this check anyone could cite another player's filename and
// post-attribute the photo to themselves.
var blob = await db.RoomDataBlobs
.Where(b => b.BlobName == body.ImageName)
.Select(b => new { b.UploadedByPlayerId })
.FirstOrDefaultAsync();
if (blob is null)
return NotFound(new { error = "image_blob_not_found" });
if (blob.UploadedByPlayerId != me)
return Forbid();
// Auto-resolve current room if the caller didn't supply one.
var roomId = body.RoomId ?? presence.GetRoom(me)?.RoomId ?? 0;
var photo = new PhotoEntity
{
UploaderPlayerId = me,
BlobName = body.ImageName,
Caption = (body.Caption ?? string.Empty).Trim(),
TaggedPlayerIdsCsv = NormaliseTaggedIds(body.TaggedPlayerIds, exceptId: me),
RoomId = roomId,
IsPublic = body.IsPublic ?? true,
};
db.Photos.Add(photo);
await db.SaveChangesAsync();
// Posting a photo gives a small XP bump — encourages camera use.
await level.AwardXpAsync(me, LevelService.InventionSavedXp, $"photo_posted:{photo.Id}");
// Push to tagged players so their "Photos of me" feed refreshes.
foreach (var taggedId in ParseTagged(photo.TaggedPlayerIdsCsv))
await notifications.NotifyAsync(taggedId,
PushNotificationId.SubscriptionUpdateProfile,
new { Reason = "TaggedInPhoto", PhotoId = photo.Id, From = me });
return Ok(ToDto(photo));
}
/// <summary>GET api/photos/v1/feed — public newest-first feed.
/// Anonymous-safe (so the frontend website can render without a
/// JWT). Soft-deleted and private photos are excluded.</summary>
[HttpGet("feed")]
[AllowAnonymous]
public async Task<ActionResult> Feed([FromQuery] int take = 20, [FromQuery] int skip = 0)
{
take = Math.Clamp(take, 1, 100);
skip = Math.Max(0, skip);
var rows = await db.Photos
.Where(p => p.IsPublic && p.DeletedAt == null)
.OrderByDescending(p => p.CreatedAt)
.Skip(skip).Take(take)
.ToListAsync();
return Ok(await EnrichAsync(rows));
}
/// <summary>GET api/photos/v1/by/{playerId} — photos posted by a
/// specific player. Anonymous-safe. Private photos hidden unless
/// the caller is the uploader (or an admin).</summary>
[HttpGet("by/{playerId:long}")]
[AllowAnonymous]
public async Task<ActionResult> ByPlayer(long playerId,
[FromQuery] int take = 20, [FromQuery] int skip = 0)
{
take = Math.Clamp(take, 1, 100);
skip = Math.Max(0, skip);
var me = this.CurrentPlayerId();
var includePrivate = me == playerId || await IsAdminAsync(me);
var q = db.Photos.Where(p => p.UploaderPlayerId == playerId && p.DeletedAt == null);
if (!includePrivate) q = q.Where(p => p.IsPublic);
var rows = await q
.OrderByDescending(p => p.CreatedAt)
.Skip(skip).Take(take)
.ToListAsync();
return Ok(await EnrichAsync(rows));
}
/// <summary>GET api/photos/v1/of/{playerId} — photos the player
/// is tagged in. Anonymous-safe.</summary>
[HttpGet("of/{playerId:long}")]
[AllowAnonymous]
public async Task<ActionResult> OfPlayer(long playerId,
[FromQuery] int take = 20, [FromQuery] int skip = 0)
{
take = Math.Clamp(take, 1, 100);
skip = Math.Max(0, skip);
var needle = $",{playerId},";
// SQLite EF translates `string.Contains` to LIKE — wrapping the
// CSV with leading/trailing commas avoids "12" matching "112".
var rows = await db.Photos
.Where(p => p.IsPublic && p.DeletedAt == null &&
("," + p.TaggedPlayerIdsCsv + ",").Contains(needle))
.OrderByDescending(p => p.CreatedAt)
.Skip(skip).Take(take)
.ToListAsync();
return Ok(await EnrichAsync(rows));
}
/// <summary>GET api/photos/v1/in/{roomId} — photos taken in a
/// specific room. Anonymous-safe.</summary>
[HttpGet("in/{roomId:long}")]
[AllowAnonymous]
public async Task<ActionResult> InRoom(long roomId,
[FromQuery] int take = 20, [FromQuery] int skip = 0)
{
take = Math.Clamp(take, 1, 100);
skip = Math.Max(0, skip);
var rows = await db.Photos
.Where(p => p.RoomId == roomId && p.IsPublic && p.DeletedAt == null)
.OrderByDescending(p => p.CreatedAt)
.Skip(skip).Take(take)
.ToListAsync();
return Ok(await EnrichAsync(rows));
}
/// <summary>GET api/photos/v1/{id} — single photo detail.
/// Increments ViewCount as a side effect (denormalised for the
/// "trending" sort).</summary>
[HttpGet("{id:long}")]
[AllowAnonymous]
public async Task<ActionResult> Get(long id)
{
var p = await db.Photos.FirstOrDefaultAsync(x => x.Id == id);
if (p is null || p.DeletedAt is not null) return NotFound();
if (!p.IsPublic)
{
var me = this.CurrentPlayerId();
if (me != p.UploaderPlayerId && !await IsAdminAsync(me)) return NotFound();
}
p.ViewCount += 1;
await db.SaveChangesAsync();
var enriched = await EnrichAsync(new[] { p });
return Ok(enriched.First());
}
/// <summary>POST api/photos/v1/{id}/cheer — like a photo. Idempotent
/// per (caller, photo) pair. Pushes a notification to the
/// uploader so their watch can refresh the count.</summary>
[HttpPost("{id:long}/cheer")]
[Authorize]
public async Task<ActionResult> Cheer(long id)
{
var me = this.RequireCurrentPlayerId();
var photo = await db.Photos.FirstOrDefaultAsync(p => p.Id == id);
if (photo is null || photo.DeletedAt is not null) return NotFound();
var existing = await db.Cheers.FirstOrDefaultAsync(c =>
c.FromPlayerId == me && c.TargetPhotoId == id &&
c.TargetPlayerId == 0 && c.TargetRoomId == 0);
if (existing is not null) return Ok(new { already_cheered = true });
db.Cheers.Add(new CheerEntity
{
FromPlayerId = me,
TargetPhotoId = id,
});
photo.CheerCount += 1;
await db.SaveChangesAsync();
if (photo.UploaderPlayerId != me)
{
await level.AwardXpAsync(photo.UploaderPlayerId,
LevelService.CheerReceivedXp, $"photo_cheer:{id}");
await notifications.NotifyAsync(photo.UploaderPlayerId,
PushNotificationId.SubscriptionUpdateProfile,
new { Reason = "PhotoCheer", PhotoId = id, From = me });
}
return Ok(new { cheered = true, count = photo.CheerCount });
}
/// <summary>DELETE api/photos/v1/{id} — soft-delete. Allowed for
/// the uploader or an admin. The blob bytes stay in
/// RoomDataBlobs for audit; the row's DeletedAt timestamp hides
/// it from feeds.</summary>
[HttpDelete("{id:long}")]
[Authorize]
public async Task<ActionResult> Delete(long id)
{
var me = this.RequireCurrentPlayerId();
var photo = await db.Photos.FirstOrDefaultAsync(p => p.Id == id);
if (photo is null) return NotFound();
if (photo.UploaderPlayerId != me && !await IsAdminAsync(me))
return Forbid();
photo.DeletedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return Ok(new { deleted = id });
}
// ── Helpers ──────────────────────────────────────────────────────
/// <summary>Trim/dedupe tagged ids and drop the uploader's own id.
/// CSV in, CSV out.</summary>
private static string NormaliseTaggedIds(string? csv, long exceptId)
{
if (string.IsNullOrWhiteSpace(csv)) return string.Empty;
var ids = csv.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(s => long.TryParse(s, out var v) ? v : 0L)
.Where(v => v > 0 && v != exceptId)
.Distinct()
.ToList();
return string.Join(",", ids);
}
private static IEnumerable<long> ParseTagged(string? csv) =>
string.IsNullOrWhiteSpace(csv)
? Enumerable.Empty<long>()
: csv.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(s => long.TryParse(s, out var v) ? v : 0L)
.Where(v => v > 0);
private async Task<bool> IsAdminAsync(long? playerId)
{
if (playerId is not long id) return false;
return await db.Players.Where(p => p.Id == id).Select(p => p.IsAdmin).FirstOrDefaultAsync();
}
/// <summary>Materialise photo rows into the wire DTO shape. One
/// extra DB hit fetches the uploader display names and room names
/// in batch so the JSON has human-readable labels — saves the
/// frontend from a per-row N+1.</summary>
private async Task<List<object>> EnrichAsync(IEnumerable<PhotoEntity> photos)
{
var photoList = photos.ToList();
if (photoList.Count == 0) return new();
var uploaderIds = photoList.Select(p => p.UploaderPlayerId).Distinct().ToList();
var roomIds = photoList.Select(p => p.RoomId).Where(id => id > 0).Distinct().ToList();
var uploaderNames = await db.Players
.Where(p => uploaderIds.Contains(p.Id))
.Select(p => new { p.Id, p.DisplayName, p.Username })
.ToListAsync();
var roomNames = await db.Rooms
.Where(r => roomIds.Contains(r.Id))
.Select(r => new { r.Id, r.Name })
.ToListAsync();
var uMap = uploaderNames.ToDictionary(u => u.Id);
var rMap = roomNames.ToDictionary(r => r.Id);
return photoList.Select(p =>
{
uMap.TryGetValue(p.UploaderPlayerId, out var u);
rMap.TryGetValue(p.RoomId, out var r);
return (object)new
{
p.Id,
p.UploaderPlayerId,
UploaderDisplayName = u?.DisplayName ?? $"Player_{p.UploaderPlayerId}",
UploaderUsername = u?.Username ?? string.Empty,
p.BlobName,
ImageUrl = $"https://{domain.Sub("cdn")}/{p.BlobName}",
p.Caption,
p.RoomId,
RoomName = r?.Name ?? string.Empty,
TaggedPlayerIds = ParseTagged(p.TaggedPlayerIdsCsv).ToArray(),
p.IsPublic,
p.CheerCount,
p.ViewCount,
p.CreatedAt,
};
}).ToList();
}
private object ToDto(PhotoEntity p) => new
{
p.Id,
p.UploaderPlayerId,
p.BlobName,
ImageUrl = $"https://{domain.Sub("cdn")}/{p.BlobName}",
p.Caption,
p.RoomId,
TaggedPlayerIds = ParseTagged(p.TaggedPlayerIdsCsv).ToArray(),
p.IsPublic,
p.CheerCount,
p.ViewCount,
p.CreatedAt,
};
}
@@ -0,0 +1,209 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using DorkNet.Models.Auth;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.API.PlatformLogin.V2;
/// <summary>
/// api.rec.net/api/platformlogin/* — OAuth-style password-grant login.
/// Login.LOGIN_API in dump.cs (line 578249) is "api/platformlogin/" — the
/// version suffix (v2 / v5) is appended by callers. The 2020 build uses v5.
///
/// Request body is application/x-www-form-urlencoded (HTTPUrlEncodedForm in
/// Login.LoginHelper, dump.cs:578319), NOT JSON. Form fields are bound
/// case-insensitively by ASP.NET Core, so the parameter names don't have to
/// match the on-the-wire casing exactly.
///
/// Response is a Login.LoginResponse: { Error, ErrorDescription,
/// AccessToken, RefreshToken } — JSON keys must be PascalCase (the
/// Util.GetKey calls inside Login.LoginResponse.Deserialize pass PascalCase
/// string literals; verified via global-metadata.dat scan).
/// </summary>
[ApiController]
[Route("api/platformlogin")]
public class PlatformLoginController(
PlayerService playerService,
AuthService authService,
ServerSettingsService settings,
DorkNetDbContext db) : ControllerBase
{
// Login.LoginToAccount → POST api/platformlogin/v2 / v5
// Form fields the 2020 client sends are not yet fully confirmed; common
// OAuth-2 password-grant + Rec Room platform fields:
// grant_type, username, password, platform, platformId, deviceId,
// buildTimestamp, version. Bind permissively and ignore extras.
[HttpPost("v2")]
[HttpPost("v5")]
public async Task<ActionResult<LoginResponse>> Login(
[FromForm(Name = "username")] string? Username,
[FromForm(Name = "Name")] string? Name,
[FromForm(Name = "platform")] int Platform = 0,
[FromForm(Name = "platformId")] string? PlatformId = null,
[FromForm(Name = "deviceId")] string? DeviceId = null,
[FromForm(Name = "buildTimestamp")] long BuildTimestamp = 0,
[FromForm(Name = "version")] string? Version = null)
{
var displayName = Username ?? Name ?? PlatformId ?? "Player";
// Admin "signups disabled" gate. Mirrors the same guard on
// /connect/token's device-id fallback in AuthController — if
// the device has no existing account, GetOrCreate would mint a
// new one here, bypassing the toggle. Refuse pre-creation.
if (await settings.AreSignupsDisabledAsync()
&& await playerService.GetByDeviceAsync(DeviceId) is null)
{
return Unauthorized(new LoginResponse
{
Error = "signups_disabled",
ErrorDescription = "Account creation is currently disabled by the server admin.",
});
}
// Account identity is keyed off deviceId. The service handles the
// null/empty case by synthesising a stable id from platformId or a
// GUID — either way, no two unrelated callers ever share an account.
var player = await playerService.GetOrCreateByDeviceAsync(
deviceId: DeviceId,
platform: Platform,
platformId: PlatformId,
displayName: displayName);
var (access, refresh) = authService.GenerateTokenPair(player.Id);
return Ok(new LoginResponse
{
AccessToken = access,
RefreshToken = refresh,
});
}
// Login.DownloadCachedLogins → GET api/platformlogin/cached
// Returns List<Login.CachedLogin>. Empty array means no remembered accounts.
//
// The 2020 client doesn't include a platformId in the path here, but
// BestHTTP appends current-session credentials as query params on these
// bootstrap calls — so we accept platform/platformId via query or form
// and reuse the same lookup as auth.rec.net/cachedlogin/forplatformid.
[HttpGet("cached")]
[HttpGet("cachedlogins")]
[HttpGet("savedlogins")]
[HttpGet("v1/cached")]
[HttpGet("v2/cached")]
public async Task<IActionResult> GetCachedLogins(
[FromQuery] int platform = 0,
[FromQuery] string? platformId = null)
{
var players = await playerService.GetCachedLoginsAsync(platform, platformId);
return Ok(players.Select(p => new CachedLogin
{
Platform = platform,
PlatformId = platformId ?? string.Empty,
AccountId = (int)p.Id,
LastLoginTime = p.LastSeenAt,
RequirePassword = false,
}).ToList());
}
// Login.RemoveCachedLogin → DELETE api/platformlogin/cached
[HttpDelete("cached")]
[HttpDelete("cachedlogins")]
public IActionResult RemoveCachedLogin() => Ok(new { });
// Login.RefreshLogin → POST api/platformlogin/refresh
// Response is Login.RefreshLoginResponse { Token } — single field, the
// new access token. (Note: dump.cs Login.RefreshLoginResponse has only
// "Token", not "AccessToken".)
[HttpPost("refresh")]
public async Task<IActionResult> Refresh()
{
// Refresh requires a valid bearer (access OR refresh JWT); both are
// signed with the same secret so ValidateToken accepts either.
// Refusing to "GetOrCreateAsync(null)" here means a stale install
// can't quietly mint a brand-new account when its refresh token is
// gone — it has to go through the cached-login flow instead.
var auth = Request.Headers.Authorization.ToString();
if (!auth.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
return Unauthorized();
var token = auth["Bearer ".Length..].Trim();
var resolved = authService.ValidateToken(token);
if (resolved is not long playerId)
return Unauthorized();
var player = await playerService.GetByIdAsync(playerId);
if (player is null) return Unauthorized();
var (access, _) = authService.GenerateTokenPair(player.Id);
return Ok(new RefreshLoginResponse { Token = access });
}
// Login.LoginToCachedAccount(int accountId) → POST api/platformlogin/cached/{accountId}
// Returns the same Login.LoginResponse shape as the regular login.
[HttpPost("cached/{accountId:long}")]
public async Task<IActionResult> LoginToCachedAccount(
long accountId,
[FromForm] int platform = 0,
[FromForm(Name = "platformId")] string? platformId = null,
[FromForm(Name = "platform_id")] string? platformIdSnake = null)
{
// No GetOrCreateAsync(null) fallback — if the cached id doesn't
// resolve, the cache is stale (another DB instance, deleted account,
// etc.) and we'd rather force the client back to the create-account
// flow than silently mint a new player.
var player = await playerService.GetByIdAsync(accountId);
if (player is null) return Unauthorized();
await playerService.TagPlatformAsync(player.Id, platform, platformId ?? platformIdSnake);
var (access, refresh) = authService.GenerateTokenPair(player.Id);
return Ok(new LoginResponse
{
AccessToken = access,
RefreshToken = refresh,
});
}
// Login.CheckHasPassword → GET api/platformlogin/haspassword
// Client uses ExpectPrimitiveResponse<bool> → Convert.ChangeType
// on the raw response, so we MUST emit a bare boolean literal.
// Real password state lives on PlayerEntity.PasswordHash;
// AuthAccountController on auth.* does the same lookup against the
// authenticated player. We mirror it here so the api.* host also
// returns the truthful value instead of an always-true stub.
[HttpGet("haspassword")]
public async Task<IActionResult> HasPassword()
{
var pid = this.CurrentPlayerId() ?? 0;
if (pid == 0) return Content("false", "application/json");
var player = await playerService.GetByIdAsync(pid);
var has = player?.PasswordHash is { Length: > 0 };
return Content(has ? "true" : "false", "application/json");
}
// Login.CreatePassword / ChangePassword / ResetPassword on auth.*
// already do the real bcrypt write — they're wired in
// AuthAccountController. The api.* path duplicates the route
// without persisting; rather than mirror the full logic here,
// delegate to AuthService.ChangePassword for consistency.
[HttpPost("password")]
[HttpPut("password")]
public async Task<IActionResult> SetPassword([FromForm(Name = "newPassword")] string? newPassword,
[FromForm(Name = "password")] string? legacy)
{
var pid = this.CurrentPlayerId() ?? 0;
if (pid == 0) return Unauthorized();
var pw = newPassword ?? legacy ?? string.Empty;
if (pw.Length < 6) return Ok(new RecNetResult { Success = false, Error = "password_too_short" });
var player = await db.Players.FirstOrDefaultAsync(p => p.Id == pid);
if (player is null) return NotFound();
player.PasswordHash = BCrypt.Net.BCrypt.HashPassword(pw, workFactor: 11);
await db.SaveChangesAsync();
return Ok(new RecNetResult { Success = true, Error = string.Empty });
}
[HttpPost("resetpassword")]
public IActionResult ResetPassword()
{
// Real password reset would email a token; on a private
// server with no SMTP this is an explicit no-op (the admin
// SPA "change password" tool is the supported path).
return Ok(new RecNetResult { Success = false, Error = "reset_not_supported" });
}
}
@@ -0,0 +1,117 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using DorkNet.Models.Notification;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.API.PlayerCheer;
/// <summary>
/// api.{rec.net,localhost}/api/PlayerCheer/v1/* — peer cheer flow.
/// Cheers between players are stored as <see cref="CheerEntity"/>
/// rows keyed by <c>TargetPlayerId</c>. The "selected cheer" badge
/// each player can pin to their profile is stored as a
/// <see cref="PlayerSettingEntity"/> with key <c>SelectedCheer</c>.
/// </summary>
[ApiController]
[Authorize]
public class PlayerCheerController(
DorkNetDbContext db,
NotificationService notifications,
LevelService level) : ControllerBase
{
private long Me => this.RequireCurrentPlayerId();
/// <summary>POST <c>api/PlayerCheer/v1/create</c> — caller cheers
/// the target player. <c>Type</c> matches the same enum as
/// <c>CheerEntity.Type</c>. Idempotent on (caller, target, type)
/// to match <c>PlayerStateController.CheerPlayer</c>, so the same
/// category click doesn't inflate the count.</summary>
[HttpPost("api/PlayerCheer/v1/create")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
public async Task<IActionResult> Create(
[FromForm(Name = "TargetAccountId")] long? targetAccountId,
[FromForm(Name = "Type")] int type = 0)
{
if (targetAccountId is not long target || target <= 0)
return Ok(new { success = false, error = "missing_target" });
if (Me == target)
return Ok(new { success = false, error = "cannot_cheer_self" });
var existing = await db.Cheers.FirstOrDefaultAsync(c =>
c.FromPlayerId == Me && c.TargetPlayerId == target &&
c.TargetRoomId == 0 && c.Type == type);
if (existing is not null) return Ok(new { success = true, already_cheered = true });
db.Cheers.Add(new CheerEntity
{
FromPlayerId = Me,
TargetPlayerId = target,
Type = type,
});
await db.SaveChangesAsync();
await level.AwardXpAsync(target, LevelService.CheerReceivedXp, $"cheer_from:{Me}");
await notifications.NotifyAsync(target,
PushNotificationId.SubscriptionUpdateProfile,
new { Reason = "CheerReceived", From = Me, Type = type });
return Ok(new { success = true, error = "" });
}
/// <summary>POST <c>api/PlayerCheer/v1/SetSelectedCheer</c> — pin
/// a cheer category as the caller's selected badge. Stored on the
/// PlayerSetting key <c>SelectedCheer</c> (string-form int).
///
/// Form field is <c>CheerCategory</c> — NOT <c>Cheer</c>. The 2020.12
/// watch's AIGGBBFKAKL.SetSelectedCheer (at
/// <c>AIGGBBFKAKL.txt:1083-1084</c>) does
/// <c>AddField("CheerCategory", value)</c> when POSTing to
/// <c>api/PlayerCheer/v1/SetSelectedCheer</c>. A mismatched binding
/// silently parses to 0, which the watch reads back as "no badge",
/// so the player's pinned cheer reverts every time.
///
/// The posted value is the watch's PIDFCOGLFHN category enum (e.g.
/// <c>9000</c> for a specific category) — we store it verbatim and
/// round-trip through <see cref="Reputation.SelectedCheer"/>; the
/// watch maps it back to the visual badge.</summary>
[HttpPost("api/PlayerCheer/v1/SetSelectedCheer")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
public async Task<IActionResult> SetSelectedCheer(
[FromForm(Name = "CheerCategory")] int? cheerCategory = null,
[FromForm(Name = "Cheer")] int? legacyCheer = null)
{
// Accept either field name — `CheerCategory` for 2020.12, `Cheer`
// as a defensive fallback for older / re-skinned clients.
var value = cheerCategory ?? legacyCheer ?? 0;
var existing = await db.PlayerSettings
.FirstOrDefaultAsync(s => s.PlayerId == Me && s.Key == "SelectedCheer");
if (existing is null)
db.PlayerSettings.Add(new PlayerSettingEntity { PlayerId = Me, Key = "SelectedCheer", Value = value.ToString() });
else
existing.Value = value.ToString();
await db.SaveChangesAsync();
return Ok(new { success = true, error = "" });
}
/// <summary>GET <c>api/PlayerCheer/v1/all</c> — every cheer the
/// caller has received. Drives the profile's "X cheers" badge.</summary>
[HttpGet("api/PlayerCheer/v1/all")]
public async Task<IActionResult> All()
{
var rows = await db.Cheers
.Where(c => c.TargetPlayerId == Me)
.OrderByDescending(c => c.CheeredAt)
.Take(200)
.Select(c => new
{
Id = c.Id,
FromPlayerId = c.FromPlayerId,
Type = c.Type,
CheeredAt = c.CheeredAt,
})
.ToListAsync();
return Ok(rows);
}
}
@@ -0,0 +1,69 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
namespace DorkNet.Server.Controllers.API.PlayerElo;
/// <summary>
/// api.rec.net/api/PlayerElo/* — match-result Elo deltas. The 2020
/// client posts after each ranked match (Royale, Paintball, Soccer)
/// with a delta the server applies to the per-player Elo row.
///
/// Wire request matches <c>RecNet.Elo.ReportPlayerElo</c>:
/// <c>{GameMode(int), EloDelta(int), Won(bool)}</c>
/// (we tolerate a few synonym fields). Response: the new Elo as a
/// JSON primitive int.
/// </summary>
[ApiController]
public class PlayerEloController(DorkNetDbContext db) : ControllerBase
{
private long Me => this.RequireCurrentPlayerId();
public sealed class ReportEloRequest
{
public int GameMode { get; set; }
public int EloDelta { get; set; }
public bool Won { get; set; }
}
[HttpPost("api/PlayerElo/v1/reportPlayerElo")]
[Authorize]
public async Task<IActionResult> ReportPlayerElo([FromBody] ReportEloRequest req)
{
var pid = Me;
var row = await db.PlayerElo.FirstOrDefaultAsync(p =>
p.PlayerId == pid && p.GameMode == req.GameMode);
if (row is null)
{
row = new PlayerEloEntity
{
PlayerId = pid,
GameMode = req.GameMode,
};
db.PlayerElo.Add(row);
}
// Clamp Elo to a reasonable range to avoid overflow exploits.
row.Elo = Math.Clamp(row.Elo + req.EloDelta, 0, 5000);
row.MatchesPlayed += 1;
if (req.Won) row.Wins += 1; else row.Losses += 1;
row.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return Ok(row.Elo);
}
[HttpGet("api/PlayerElo/v1/get")]
public async Task<IActionResult> Get(
[FromQuery] long? playerId, [FromQuery] int gameMode = 0)
{
var pid = playerId ?? this.CurrentPlayerId() ?? 0;
if (pid == 0) return Ok(new { Elo = 1000, Wins = 0, Losses = 0 });
var row = await db.PlayerElo.FirstOrDefaultAsync(p =>
p.PlayerId == pid && p.GameMode == gameMode);
return Ok(row is null
? new { Elo = 1000, Wins = 0, Losses = 0 }
: new { row.Elo, row.Wins, row.Losses });
}
}
@@ -0,0 +1,330 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using DorkNet.Models.Notification;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.API.PlayerEvents;
/// <summary>
/// api.rec.net/api/playerevents/v1/* — create, list, RSVP for
/// player-scheduled gatherings.
/// </summary>
[ApiController]
[Authorize]
public class PlayerEventsController(
DorkNetDbContext db,
NotificationService notifications) : ControllerBase
{
private long Me => this.RequireCurrentPlayerId();
// ── Browse (v2) ──────────────────────────────────────────────────────
/// <summary>GET <c>api/playerevents/v2</c> — upcoming events list.
/// Returns the next 50 events ordered by start time. Wire shape
/// is the standard PlayerEvent (Id, CreatorPlayerId, RoomId,
/// Title, Description, StartsAt, EndsAt, Capacity).</summary>
[AllowAnonymous]
[HttpGet("api/playerevents/v2")]
public async Task<ActionResult> ListUpcoming(
[FromQuery] int skip = 0, [FromQuery] int take = 50)
{
skip = Math.Max(0, skip);
take = Math.Clamp(take, 1, 100);
var rows = await db.PlayerEvents
.Where(e => e.EndsAt > DateTime.UtcNow)
.OrderBy(e => e.StartsAt)
.Skip(skip)
.Take(take)
.ToListAsync();
return Ok(rows.Select(ToWire));
}
[AllowAnonymous]
[HttpGet("api/playerevents/v2/{eventId:long}")]
public async Task<ActionResult> GetOne(long eventId)
{
var ev = await db.PlayerEvents.FirstOrDefaultAsync(e => e.Id == eventId);
if (ev is null) return NotFound();
return Ok(ToWire(ev));
}
/// <summary>GET <c>api/playerevents/v1/all</c> — the watch's
/// <c>LocalPlayerEventInfo</c> fetch (caller's created + RSVP'd
/// events). Response keys (both required, via
/// <c>Util.GetObjectListKey</c>): <c>Created</c>, <c>Responses</c>.</summary>
[AllowAnonymous]
[HttpGet("api/playerevents/v1/all")]
public async Task<ActionResult> AllPlayerEvents()
{
var pid = this.CurrentPlayerId();
if (pid is not long me)
return Ok(new { Created = Array.Empty<object>(), Responses = Array.Empty<object>() });
var created = await db.PlayerEvents
.Where(e => e.CreatorPlayerId == me)
.OrderByDescending(e => e.StartsAt)
.Select(e => new
{
e.Id,
e.Title,
e.Description,
e.RoomId,
e.StartsAt,
e.EndsAt,
e.Capacity,
})
.ToListAsync();
var responses = await db.PlayerEventResponses
.Where(r => r.PlayerId == me)
.Select(r => new
{
r.Id,
r.EventId,
r.Response,
r.CreatedAt,
})
.ToListAsync();
return Ok(new { Created = created, Responses = responses });
}
/// <summary>POST <c>api/playerevents/v1/{eventId}/responses</c> —
/// alternate RSVP route the watch uses for the response-list
/// flow (older / sub-room view). Body is the same RsvpRequest
/// as <c>v1/{id}/rsvp</c>; behaves identically.</summary>
[HttpPost("api/playerevents/v1/{eventId:long}/responses")]
public Task<ActionResult> RespondList(long eventId, [FromBody] RsvpRequest body)
=> Rsvp(eventId, body);
/// <summary>Wire shape verified against
/// <c>Cpp2IL_ISIL/.../RecNet/PlayerEvent.txt:769-815</c>:
/// PascalCase keys <c>PlayerEventId, Name, Description,
/// StartTime, EndTime, CreatorPlayerId, AttendeeCount, RoomId,
/// ImageName, Accessibility</c>. Server-side we store these
/// under different names (Title, StartsAt, EndsAt, Capacity) —
/// remap explicitly here so the watch's
/// <c>PlayerEvent.Deserialize</c> picks them up.</summary>
private static object ToWire(PlayerEventEntity ev) => new
{
PlayerEventId = ev.Id,
Name = ev.Title,
ev.Description,
StartTime = ev.StartsAt,
EndTime = ev.EndsAt,
CreatorPlayerId = (int)ev.CreatorPlayerId,
AttendeeCount = 0, // computed at query time when needed
ev.RoomId,
ImageName = string.Empty,
Accessibility = 1, // Public
};
public sealed record CreateEventRequest(
string Title, string? Description, long RoomId,
DateTime StartsAt, DateTime EndsAt, int Capacity);
[HttpPost("api/playerevents/v1/create")]
[HttpPost("api/playerevents/v1")]
public async Task<ActionResult> Create([FromBody] CreateEventRequest req)
{
if (string.IsNullOrWhiteSpace(req.Title))
return BadRequest("missing_title");
var ev = new PlayerEventEntity
{
CreatorPlayerId = Me,
RoomId = req.RoomId,
Title = req.Title.Trim(),
Description = (req.Description ?? string.Empty).Trim(),
StartsAt = req.StartsAt,
EndsAt = req.EndsAt > req.StartsAt ? req.EndsAt : req.StartsAt.AddHours(1),
Capacity = Math.Max(0, req.Capacity),
};
db.PlayerEvents.Add(ev);
await db.SaveChangesAsync();
return Ok(new
{
ev.Id, ev.Title, ev.Description, ev.RoomId,
ev.StartsAt, ev.EndsAt, ev.Capacity,
});
}
public sealed record RsvpRequest(int Response);
[HttpPost("api/playerevents/v1/{eventId:long}/rsvp")]
public async Task<ActionResult> Rsvp(long eventId, [FromBody] RsvpRequest body)
{
if (body.Response < 0 || body.Response > 2)
return BadRequest("invalid_response");
var ev = await db.PlayerEvents.FirstOrDefaultAsync(e => e.Id == eventId);
if (ev is null) return NotFound();
var row = await db.PlayerEventResponses
.FirstOrDefaultAsync(r => r.EventId == eventId && r.PlayerId == Me);
if (row is null)
{
row = new PlayerEventResponseEntity { EventId = eventId, PlayerId = Me };
db.PlayerEventResponses.Add(row);
}
row.Response = body.Response;
await db.SaveChangesAsync();
// Tell the creator someone RSVP'd. They can ignore but the
// watch will refresh their event detail card.
if (ev.CreatorPlayerId != Me)
await notifications.NotifyAsync(ev.CreatorPlayerId,
PushNotificationId.PlayerEventResponseChanged,
new { ev.Id, From = Me, body.Response });
return Ok(new { row.EventId, row.PlayerId, row.Response });
}
/// <summary>POST <c>api/playerevents/v1/respond</c> — set the
/// caller's RSVP state for an event. <c>Response</c> is the
/// <c>PlayerEventResponseType</c> enum (0=Going, 1=Maybe, 2=Pass).
/// Idempotent — re-responding overwrites.
///
/// Wire return is a BARE int (<c>DGOPHENCPOC</c> enum value:
/// 0=Success, plus various error codes). Returning an object
/// here throws InvalidCastException on the 2020.12 deserialiser
/// — verified at <c>recroom-2020-client-response-contracts.md</c>
/// under the playerevents/v1/respond section.</summary>
[HttpPost("api/playerevents/v1/respond")]
public async Task<IActionResult> RespondForm(
[FromForm(Name = "EventId")] long? eventId,
[FromForm(Name = "Response")] int response = 0)
{
if (eventId is not long evt || evt <= 0) return Ok(2 /*NoSuchEvent*/);
var existing = await db.PlayerEventResponses
.FirstOrDefaultAsync(r => r.PlayerId == Me && r.EventId == evt);
if (existing is null)
db.PlayerEventResponses.Add(new PlayerEventResponseEntity { PlayerId = Me, EventId = evt, Response = response });
else
existing.Response = response;
await db.SaveChangesAsync();
return Ok(0 /*Success*/);
}
/// <summary>POST <c>api/playerevents/v1/deleteResponse</c> — drop
/// the caller's RSVP row. Same bare-int wire shape as
/// <see cref="RespondForm"/>.</summary>
[HttpPost("api/playerevents/v1/deleteResponse")]
public async Task<IActionResult> DeleteResponseForm([FromForm(Name = "EventId")] long? eventId)
{
if (eventId is not long evt) return Ok(2 /*NoSuchEvent*/);
await db.PlayerEventResponses
.Where(r => r.PlayerId == Me && r.EventId == evt)
.ExecuteDeleteAsync();
return Ok(0 /*Success*/);
}
/// <summary>DELETE <c>api/playerevents/v2/delete/{id}</c> — hard
/// delete an event the caller created.</summary>
[HttpDelete("api/playerevents/v2/delete/{id:long}")]
public async Task<IActionResult> DeleteByPath(long id)
{
var evt = await db.PlayerEvents.FirstOrDefaultAsync(e => e.Id == id && e.CreatorPlayerId == Me);
if (evt is null) return NotFound();
db.PlayerEvents.Remove(evt);
await db.PlayerEventResponses.Where(r => r.EventId == id).ExecuteDeleteAsync();
await db.SaveChangesAsync();
return Ok(new { success = true, error = "" });
}
[HttpDelete("api/playerevents/v1/{eventId:long}")]
public async Task<ActionResult> Delete(long eventId)
{
var ev = await db.PlayerEvents.FirstOrDefaultAsync(e => e.Id == eventId);
if (ev is null) return NotFound();
if (ev.CreatorPlayerId != Me) return Forbid();
db.PlayerEvents.Remove(ev);
// Cascade-clean the responses; SQLite has no FK cascade
// configured here so do it manually.
var responses = db.PlayerEventResponses.Where(r => r.EventId == eventId);
db.PlayerEventResponses.RemoveRange(responses);
await db.SaveChangesAsync();
return Ok();
}
// ── Bulk-invite (Phase 8) ────────────────────────────────────────────
public sealed class BulkInviteRequest
{
public long PlayerEventId { get; set; }
public List<int>? InvitedPlayerIds { get; set; }
}
/// <summary>POST <c>/api/playerevents/v1/bulkInvite</c> — wire
/// type per agent ISIL extraction: <c>BulkInviteRequest{PlayerEventId,
/// InvitedPlayerIds:List&lt;int&gt;}</c>; response
/// <c>BulkInviteResponse{FailedInvites,Result(CreateModifyPlayerEventStatus)}</c>.
/// We insert one MessageEntity per recipient so the watch's inbox
/// shows the invite, and push a per-recipient notification.</summary>
[HttpPost("api/playerevents/v1/bulkInvite")]
public async Task<IActionResult> BulkInvite([FromBody] BulkInviteRequest req)
{
if (req?.InvitedPlayerIds is null || req.InvitedPlayerIds.Count == 0)
return Ok(new { FailedInvites = Array.Empty<object>(), Result = 0 });
var ev = await db.PlayerEvents.FirstOrDefaultAsync(e => e.Id == req.PlayerEventId);
if (ev is null) return NotFound();
if (ev.CreatorPlayerId != Me) return Forbid();
var failed = new List<object>();
var sender = Me;
var inviteText = $"You're invited to '{ev.Title}' starting {ev.StartsAt:u}.";
foreach (var rid in req.InvitedPlayerIds.Distinct())
{
var exists = await db.Players.AnyAsync(p => p.Id == rid);
if (!exists)
{
failed.Add(new { PlayerId = rid, Error = "unknown player" });
continue;
}
db.Messages.Add(new MessageEntity
{
SenderPlayerId = sender,
RecipientPlayerId = rid,
Body = inviteText,
});
await notifications.NotifyAsync(rid,
PushNotificationId.PlayerEventCreated,
new { ev.Id, ev.Title, ev.StartsAt, From = sender });
}
await db.SaveChangesAsync();
return Ok(new { FailedInvites = failed, Result = 0 });
}
// ── Report (Phase 8) ─────────────────────────────────────────────────
public sealed class PlayerEventReportRequest
{
public int ReportCategory { get; set; }
public long PlayerEventId { get; set; }
public string? Details { get; set; }
}
/// <summary>POST <c>api/playerevents/v1/report</c> — file a
/// moderation report against an event. Wire response is
/// <c>KLAMKCBENEA{Success, Message}</c>; returning a bare
/// <c>{Reported:true}</c> throws on the strict deserialiser.</summary>
[HttpPost("api/playerevents/v1/report")]
public async Task<IActionResult> Report([FromBody] PlayerEventReportRequest req)
{
var ev = await db.PlayerEvents.FirstOrDefaultAsync(e => e.Id == req.PlayerEventId);
if (ev is null) return Ok(new { Success = false, Message = "no_such_event" });
var msg = (req.Details ?? string.Empty).Trim();
db.Reports.Add(new ReportEntity
{
ReporterPlayerId = Me,
TargetPlayerId = ev.CreatorPlayerId,
TargetEventId = req.PlayerEventId,
Category = req.ReportCategory,
Message = msg[..Math.Min(1000, msg.Length)],
});
await db.SaveChangesAsync();
return Ok(new { Success = true, Message = string.Empty });
}
}
@@ -0,0 +1,221 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using System.Text.Json;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
namespace DorkNet.Server.Controllers.API.PlayerReporting;
/// <summary>
/// api.rec.net/api/PlayerReporting/v1/* — real player report flow.
/// The 2020 client's "report player" UI in the watch posts here.
/// Form fields the watch sends (per the in-game send_report dialog):
/// <c>TargetPlayerId</c> — int account id of the offender
/// <c>Type</c> — RoomieReportCategory enum (1..5)
/// <c>Message</c> — free-form description (capped at 1000)
/// <c>GameSessionId</c> — optional, current session for context
/// <c>RoomId</c> — optional, room the offence happened in
///
/// Anyone authenticated can file a report. Admins read the queue
/// from <c>GET api/admin/v1/reports</c>.
/// </summary>
[ApiController]
[Authorize]
public class ReportsController(DorkNetDbContext db) : ControllerBase
{
[HttpGet("api/PlayerReporting/v1/voteToKickReasons")]
[AllowAnonymous]
public IActionResult VoteToKickReasons() => Ok(new[]
{
new { ReportCategory = 1, Reason = "Harassment" },
new { ReportCategory = 2, Reason = "Inappropriate behavior" },
new { ReportCategory = 3, Reason = "Cheating" },
new { ReportCategory = 4, Reason = "Spam" },
new { ReportCategory = 5, Reason = "Other" },
});
[HttpPost("api/PlayerReporting/v1/report")]
[HttpPost("api/PlayerReporting/v1/playerreport")]
[HttpPost("api/PlayerReporting/v2/report")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
public async Task<IActionResult> SubmitReport(
[FromForm(Name = "TargetPlayerId")] long? targetPlayerId,
[FromForm(Name = "Type")] int type = 0,
[FromForm(Name = "Message")] string? message = null,
[FromForm(Name = "GameSessionId")] long gameSessionId = 0,
[FromForm(Name = "RoomId")] long roomId = 0)
{
if (targetPlayerId is not long target || target <= 0)
return BadRequest(new { success = false, error = "missing_target" });
var reporter = this.RequireCurrentPlayerId();
if (reporter == target)
return BadRequest(new { success = false, error = "cannot_report_self" });
// Cap message length to match the watch UI's enforcement +
// the column max — anything longer is almost certainly junk.
var trimmed = (message ?? string.Empty).Trim();
if (trimmed.Length > 1000) trimmed = trimmed[..1000];
db.Reports.Add(new ReportEntity
{
ReporterPlayerId = reporter,
TargetPlayerId = target,
Category = type,
Message = trimmed,
GameSessionId = gameSessionId,
RoomId = roomId,
});
await db.SaveChangesAsync();
return Ok(new { success = true, error = "" });
}
/// <summary>GET <c>api/PlayerReporting/v2/detail?playerId={id}</c>
/// — moderator detail panel. Lists recent reports against the
/// target player. Public — admin-only mutations are elsewhere.</summary>
[HttpGet("api/PlayerReporting/v2/detail")]
public async Task<IActionResult> ReportDetail(
[FromQuery] long? playerId, [FromQuery] long? accountId)
{
var pid = playerId ?? accountId ?? 0;
if (pid <= 0) return Ok(new { PlayerId = 0L, OpenReports = 0, RecentReports = Array.Empty<object>() });
var reports = await db.Reports
.Where(r => r.TargetPlayerId == pid)
.OrderByDescending(r => r.CreatedAt)
.Take(20)
.ToListAsync();
return Ok(new
{
PlayerId = pid,
OpenReports = reports.Count(r => r.ResolvedAt == null),
RecentReports = reports.Select(r => new
{
r.Id, r.Category, r.Message,
r.ReporterPlayerId, r.RoomId, r.CreatedAt, r.ResolvedAt,
}),
});
}
public sealed class V2BanRequest
{
public long PlayerId { get; set; }
public int? Category { get; set; }
public string? Reason { get; set; }
public DateTime? Until { get; set; }
}
/// <summary>POST <c>api/PlayerReporting/v2/ban</c> — admin-only.
/// Sets <see cref="PlayerEntity.BannedUntil"/> + appends a
/// resolved <see cref="ReportEntity"/> for the audit trail.</summary>
[HttpPost("api/PlayerReporting/v2/ban")]
[Authorize]
public async Task<IActionResult> V2Ban([FromBody] V2BanRequest req)
{
var me = this.RequireCurrentPlayerId();
if (req.PlayerId <= 0) return BadRequest(new { error = "missing playerId" });
var isAdmin = await db.Players
.Where(p => p.Id == me)
.Select(p => p.IsAdmin)
.FirstOrDefaultAsync();
if (!isAdmin) return Forbid();
var target = await db.Players.FirstOrDefaultAsync(p => p.Id == req.PlayerId);
if (target is null) return NotFound();
target.BannedUntil = req.Until ?? DateTime.UtcNow.AddYears(100);
var reason = req.Reason ?? string.Empty;
if (reason.Length > 1000) reason = reason[..1000];
db.Reports.Add(new ReportEntity
{
ReporterPlayerId = me,
TargetPlayerId = req.PlayerId,
Category = req.Category ?? 5,
Message = reason,
ResolvedAt = DateTime.UtcNow,
ResolverAdminId = me,
ResolutionNote = "v2/ban",
});
await db.SaveChangesAsync();
return Ok(new { Banned = true, target.Id, target.BannedUntil });
}
public sealed class HileRequest
{
public string? Detail { get; set; }
public string? Body { get; set; }
public string? Message { get; set; }
public int? Type { get; set; }
public long? GameSessionId { get; set; }
}
/// <summary>POST <c>api/PlayerReporting/v1/hile</c> — anti-cheat
/// heartbeat. Stored as a <see cref="BugReportEntity"/> tagged
/// <c>hile</c> so admins can audit the stream.</summary>
[HttpPost("api/PlayerReporting/v1/hile")]
public async Task<IActionResult> Hile()
{
if (Request.HasFormContentType)
{
var form = await Request.ReadFormAsync();
return await SaveHileAsync(
form["Message"].FirstOrDefault() ??
form["Detail"].FirstOrDefault() ??
form["Body"].FirstOrDefault(),
int.TryParse(form["Type"].FirstOrDefault(), out var t) ? t : (int?)null,
long.TryParse(form["GameSessionId"].FirstOrDefault(), out var g) ? g : (long?)null);
}
HileRequest? body = null;
try
{
body = await JsonSerializer.DeserializeAsync<HileRequest>(
Request.Body,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
}
catch (JsonException) { }
return await SaveHileAsync(
body?.Message ?? body?.Detail ?? body?.Body,
body?.Type,
body?.GameSessionId);
}
/// <summary>POST <c>api/PlayerReporting/v1/log</c> — bare client
/// telemetry log. Persisted as a low-priority BugReport for admin
/// visibility but never acted on automatically.</summary>
[HttpPost("api/PlayerReporting/v1/log")]
public async Task<IActionResult> ReportLog()
{
if (Request.HasFormContentType)
{
var form = await Request.ReadFormAsync();
await SaveHileAsync(
form["Message"].FirstOrDefault() ?? form["Body"].FirstOrDefault(),
null, null);
}
return Ok(new { success = true, error = "" });
}
private async Task<IActionResult> SaveHileAsync(string? rawDetail, int? type, long? gameSessionId)
{
var pid = this.CurrentPlayerId();
if (pid is not long me) return Ok(new { success = true });
var detail = (rawDetail ?? string.Empty).Trim();
if (type is int hileType)
detail = detail.Length == 0 ? $"Type={hileType}" : $"Type={hileType}; {detail}";
if (detail.Length > 4000) detail = detail[..4000];
db.BugReports.Add(new BugReportEntity
{
ReporterPlayerId = me,
Title = "hile heartbeat",
Body = detail,
GameSessionId = gameSessionId ?? 0,
Category = "hile",
});
await db.SaveChangesAsync();
return Ok(new { success = true });
}
}
@@ -0,0 +1,227 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using DorkNet.Models.Notification;
using DorkNet.Models.Players;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.API.PlayerState;
/// <summary>
/// api.rec.net endpoints that return player-state objects whose Deserialize
/// methods do strict required-key reads via Util.GetKey&lt;T&gt;. The previous
/// catch-all returned {} or {"Success":true} for these, which crashed the
/// client with KeyNotFoundException at:
/// RecNet.Progression.Deserialize → "PlayerId", "Level", "XP"
/// RecNet.Reputation.Deserialize → "AccountId", "Noteriety" (sic), Cheer*, Sub*
/// RecNet.ModerationBlockDetail.Deserialize → "ReportCategory", "Duration", "GameSessionId", "Message"
///
/// Each endpoint here returns the minimum-viable shape (zeros / empty
/// strings) that satisfies every required key. Real values can be plugged
/// in later when persistence is added for these.
///
/// Specific routes here win over the api/PlayerReporting/{*path} and
/// api.rec.net catch-alls.
/// </summary>
[ApiController]
public class PlayerStateController(
DorkNetDbContext db,
NotificationService notifications,
LevelService level) : ControllerBase
{
// RecNet.Progressions.GetProgressionById / GetMyProgression — real
// values from PlayerEntity.Level / .XP. Default 1/0 when the
// account hasn't been seen yet (synthetic ids passed in queries).
[HttpGet("api/players/v1/progression/{accountId:long}")]
public async Task<ActionResult<Progression>> GetProgression(long accountId)
{
var p = await db.Players
.Where(p => p.Id == accountId)
.Select(p => new { p.Id, p.Level, p.XP })
.FirstOrDefaultAsync();
return Ok(new Progression
{
PlayerId = (int)(p?.Id ?? accountId),
Level = p?.Level ?? 1,
XP = p?.XP ?? 0,
});
}
[HttpPost("api/players/v1/progression/bulk")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data")]
public async Task<ActionResult<List<Progression>>> GetProgressionBulk(
[FromForm(Name = "Ids")] string? ids)
{
var idList = ParseIds(ids).Select(i => (long)i).ToList();
if (idList.Count == 0) return Ok(new List<Progression>());
var rows = await db.Players
.Where(p => idList.Contains(p.Id))
.Select(p => new Progression
{
PlayerId = (int)p.Id,
Level = p.Level,
XP = p.XP,
})
.ToListAsync();
// Synthesise zeros for any ids the watch asked about that
// don't exist in our DB — keeps array length stable.
var found = rows.Select(r => r.PlayerId).ToHashSet();
rows.AddRange(idList
.Where(id => !found.Contains((int)id))
.Select(id => new Progression { PlayerId = (int)id }));
return Ok(rows);
}
// RecNet.Reputations.GetReputationById — aggregates CheerEntity
// by category. Each Cheer* count is the sum of distinct cheers
// received from other players in that category.
[HttpGet("api/playerReputation/v1/{accountId:long}")]
public async Task<ActionResult<Reputation>> GetReputation(long accountId) =>
Ok(await BuildReputationAsync(accountId));
[HttpPost("api/playerReputation/v1/bulk")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data")]
public async Task<ActionResult<List<Reputation>>> GetReputationBulk(
[FromForm(Name = "Ids")] string? ids)
{
var idList = ParseIds(ids).Select(i => (long)i).ToList();
var result = new List<Reputation>(idList.Count);
foreach (var id in idList)
result.Add(await BuildReputationAsync(id));
return Ok(result);
}
/// <summary>POST api/playerReputation/v1/cheer/{playerId} — cheer
/// another player. Idempotent on (caller, target, type) so
/// repeat-clicking the same category doesn't inflate the count.
/// Pushes a presence-update notification so the target's watch
/// refreshes their cheer total.</summary>
[HttpPost("api/playerReputation/v1/cheer/{playerId:long}")]
[Authorize]
public async Task<ActionResult> CheerPlayer(long playerId, [FromQuery] int type = 0)
{
var me = this.RequireCurrentPlayerId();
if (me == playerId) return BadRequest("cannot_cheer_self");
var existing = await db.Cheers.FirstOrDefaultAsync(c =>
c.FromPlayerId == me && c.TargetPlayerId == playerId &&
c.TargetRoomId == 0 && c.Type == type);
if (existing is not null) return Ok(new { already_cheered = true });
db.Cheers.Add(new CheerEntity
{
FromPlayerId = me,
TargetPlayerId = playerId,
Type = type,
});
await db.SaveChangesAsync();
// Reward the cheered player with a small XP bump.
await level.AwardXpAsync(playerId, LevelService.CheerReceivedXp, $"cheer_from:{me}");
await notifications.NotifyAsync(playerId,
PushNotificationId.SubscriptionUpdateProfile,
new { Reason = "CheerReceived", From = me, Type = type });
return Ok();
}
/// <summary>POST api/rooms/v1/cheer/{roomId} — cheer a room.
/// Same idempotency rules as player cheers; bumps
/// <see cref="RoomEntity.CheerCount"/> as a denormalised counter
/// so the watch's "Hot" rooms feed doesn't have to JOIN-aggregate
/// on every request.</summary>
[HttpPost("api/rooms/v1/cheer/{roomId:long}")]
[Authorize]
public async Task<ActionResult> CheerRoom(long roomId, [FromQuery] int type = 0)
{
var me = this.RequireCurrentPlayerId();
var existing = await db.Cheers.FirstOrDefaultAsync(c =>
c.FromPlayerId == me && c.TargetRoomId == roomId &&
c.TargetPlayerId == 0 && c.Type == type);
if (existing is not null) return Ok(new { already_cheered = true });
db.Cheers.Add(new CheerEntity
{
FromPlayerId = me,
TargetRoomId = roomId,
Type = type,
});
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
if (room is not null)
{
room.CheerCount += 1;
// Cheers feed the Hot-tab ranking — weight 5x a first
// visit so the "Trending" feed reflects what players
// actually like, not just what they passed through.
room.HotScore += 10.0;
}
await db.SaveChangesAsync();
return Ok();
}
private async Task<Reputation> BuildReputationAsync(long accountId)
{
var rows = await db.Cheers
.Where(c => c.TargetPlayerId == accountId)
.GroupBy(c => c.Type)
.Select(g => new { Type = g.Key, Count = g.Count() })
.ToListAsync();
int Count(int type) => rows.FirstOrDefault(r => r.Type == type)?.Count ?? 0;
var subscriberCount = await db.Subscriptions
.CountAsync(s => s.TargetPlayerId == accountId);
var subscribedCount = await db.Subscriptions
.CountAsync(s => s.SubscriberPlayerId == accountId);
// SelectedCheer is the pinned badge category the player chose
// via PlayerCheerController.SetSelectedCheer. Stored as a
// PlayerSettingEntity with key "SelectedCheer" and Value = the
// category int as string. Null when unset — UI just shows no
// pinned badge.
var selected = await db.PlayerSettings
.Where(s => s.PlayerId == accountId && s.Key == "SelectedCheer")
.Select(s => s.Value)
.FirstOrDefaultAsync();
int? selectedCheer = int.TryParse(selected, out var sc) ? sc : null;
return new Reputation
{
AccountId = (int)accountId,
Notoriety = rows.Sum(r => r.Count),
CheerGeneral = Count(0),
CheerHelpful = Count(1),
CheerGreatHost = Count(2),
CheerSportsman = Count(3),
CheerCreative = Count(4),
CheerCredit = Count(5),
SelectedCheer = selectedCheer,
SubscriberCount = subscriberCount,
SubscribedCount = subscribedCount,
};
}
/// <summary>
/// "1,2,3" → [1, 2, 3]. The client formats Ids as a comma-separated
/// string when calling AddField on the form. Tolerant of empty input
/// and whitespace.
/// </summary>
private static IEnumerable<int> ParseIds(string? ids) =>
string.IsNullOrWhiteSpace(ids)
? Enumerable.Empty<int>()
: ids.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(s => int.TryParse(s, out var v) ? v : 0)
.Where(v => v != 0);
// RecNet.PlayerReporting.GetModerationBlockDetails
// IMPORTANT: returns a single object, not an array. Defaults indicate
// "user is not currently blocked / kicked".
// ASP.NET Core route matching is case-insensitive, so a single attribute
// covers both "PlayerReporting" and "playerreporting" callers.
[HttpGet("api/PlayerReporting/v1/moderationBlockDetails")]
public ActionResult<ModerationBlockDetail> GetModerationBlockDetails() =>
Ok(new ModerationBlockDetail());
}
@@ -0,0 +1,83 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using DorkNet.Models.Notification;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.API.PlayerSubscriptions;
/// <summary>
/// api.rec.net/api/playersubscriptions/v1/* — asymmetric follow
/// graph. The <c>my</c> endpoint returns who the caller follows;
/// subscribe/unsubscribe mutate the row directly. Reputation
/// aggregates pull SubscriberCount / SubscribedCount counts from
/// here.
/// </summary>
[ApiController]
[Route("api/[controller]/v1")]
[Authorize]
public class PlayerSubscriptionsController(
DorkNetDbContext db,
NotificationService notifications) : ControllerBase
{
private long Me => this.RequireCurrentPlayerId();
[HttpGet("my")]
public async Task<ActionResult> My()
{
var subs = await db.Subscriptions
.Where(s => s.SubscriberPlayerId == Me)
.Select(s => new { s.TargetPlayerId, s.CreatedAt })
.ToListAsync();
return Ok(subs);
}
[HttpGet("{playerId:long}/subscribers")]
public async Task<ActionResult> Subscribers(long playerId)
{
var subs = await db.Subscriptions
.Where(s => s.TargetPlayerId == playerId)
.Select(s => new { s.SubscriberPlayerId, s.CreatedAt })
.ToListAsync();
return Ok(subs);
}
[HttpPost("subscribe/{targetId:long}")]
public async Task<ActionResult> Subscribe(long targetId)
{
if (targetId == Me) return BadRequest("cannot_subscribe_to_self");
var existing = await db.Subscriptions.FirstOrDefaultAsync(s =>
s.SubscriberPlayerId == Me && s.TargetPlayerId == targetId);
if (existing is not null) return Ok(new { already_subscribed = true });
db.Subscriptions.Add(new SubscriptionEntity
{
SubscriberPlayerId = Me,
TargetPlayerId = targetId,
});
await db.SaveChangesAsync();
// Tell the target their subscriber count changed so the watch
// refreshes their profile page if open.
await notifications.NotifyAsync(targetId,
PushNotificationId.SubscriptionUpdateProfile,
new { Reason = "NewSubscriber", From = Me });
return Ok();
}
[HttpDelete("subscribe/{targetId:long}")]
[HttpPost("unsubscribe/{targetId:long}")]
public async Task<ActionResult> Unsubscribe(long targetId)
{
var row = await db.Subscriptions.FirstOrDefaultAsync(s =>
s.SubscriberPlayerId == Me && s.TargetPlayerId == targetId);
if (row is null) return NotFound();
db.Subscriptions.Remove(row);
await db.SaveChangesAsync();
return Ok();
}
}
@@ -0,0 +1,68 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using DorkNet.Models.Players;
using DorkNet.Server.Auth;
using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.API.Players.V2;
[ApiController]
[Route("api/[controller]/v2")]
[Authorize]
public class PlayersController(PlayerService playerService, NotificationService notificationService) : ControllerBase
{
private long CurrentPlayerId => this.RequireCurrentPlayerId();
[HttpGet("me")]
public async Task<ActionResult<PlayerProfile>> GetSelf()
{
var player = await playerService.GetByIdAsync(CurrentPlayerId);
if (player is null) return NotFound();
return Ok(PlayerService.ToProfile(player));
}
[HttpGet("{id:long}")]
public async Task<ActionResult<PlayerProfile>> GetById(long id)
{
var player = await playerService.GetByIdAsync(id);
if (player is null) return NotFound();
return Ok(PlayerService.ToProfile(player));
}
[HttpGet("search")]
public async Task<ActionResult<List<PlayerProfile>>> Search([FromQuery] string query)
{
if (string.IsNullOrWhiteSpace(query)) return Ok(new List<PlayerProfile>());
var results = await playerService.SearchAsync(query);
return Ok(results.Select(PlayerService.ToProfile).ToList());
}
[HttpPost("displayName")]
public async Task<ActionResult<PlayerProfile>> UpdateDisplayName([FromForm] string? DisplayName)
{
if (string.IsNullOrWhiteSpace(DisplayName))
return BadRequest("DisplayName is required");
var updated = await playerService.UpdateDisplayNameAsync(CurrentPlayerId, DisplayName);
if (!updated) return NotFound();
var player = await playerService.GetByIdAsync(CurrentPlayerId);
await notificationService.NotifyAsync(CurrentPlayerId, Models.Notification.PushNotificationId.SubscriptionUpdateProfile, PlayerService.ToProfile(player!));
return Ok(PlayerService.ToProfile(player!));
}
[HttpPut("{id:long}")]
public async Task<ActionResult<PlayerProfile>> UpdateProfile(long id, [FromBody] UpdateProfileRequest req)
{
if (id != CurrentPlayerId) return Forbid();
if (req.DisplayName is not null)
await playerService.UpdateDisplayNameAsync(id, req.DisplayName);
if (req.Bio is not null)
await playerService.UpdateBioAsync(id, req.Bio);
var player = await playerService.GetByIdAsync(id);
return Ok(PlayerService.ToProfile(player!));
}
}
@@ -0,0 +1,29 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using DorkNet.Server.Data;
namespace DorkNet.Server.Controllers.API.PlayersBanned;
/// <summary>
/// api.{rec.net,localhost}/api/PlayersBanned/v1/all — current global
/// ban list. The watch's mod panel queries this on open.
/// </summary>
[ApiController]
public class PlayersBannedController(DorkNetDbContext db) : ControllerBase
{
[HttpGet("api/PlayersBanned/v1/all")]
public async Task<IActionResult> All()
{
var now = DateTime.UtcNow;
var rows = await db.Players
.Where(p => p.BannedUntil != null && p.BannedUntil > now)
.Select(p => new
{
PlayerId = (int)p.Id,
p.Username,
BannedUntil = p.BannedUntil,
})
.ToListAsync();
return Ok(rows);
}
}
@@ -0,0 +1,304 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using DorkNet.Server.Auth;
using DorkNet.Server.Controllers.API.Rooms.V2;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.API.Playlists;
/// <summary>
/// 2020.12 playlist CRUD surface — every <c>playlists/*</c> route the
/// watch hits beyond the basic listing/detail reads in
/// <see cref="RoomsController"/>. URL templates are bare (no
/// <c>/roomserver/</c> prefix) because the 2020.12 client emits them
/// that way on the Rooms host; the same routes are NOT mirrored under
/// <c>/roomserver/</c> here to avoid duplicating definitions that already
/// exist on <c>RoomsController</c>.
///
/// Wire shape conventions:
/// • Single mutation responses use <c>BMFAGMFKODA</c> — the union
/// <c>KMKPEOGJDFK</c> entry + <c>Rooms</c> + <c>Tags</c> arrays
/// (same shape that <c>PlaylistDetails</c> in <see cref="RoomsController"/>
/// returns). See <see cref="BuildDetailsResponseAsync"/>.
/// • List responses (cheeredby/me, createdby/me, etc.) use bare
/// <c>List&lt;KMKPEOGJDFK&gt;</c> — the union entry alone.
/// • Bookmark / cheer toggles return <c>{success: true}</c>; the
/// watch's reads come back through <c>interactionby/me</c>.
/// </summary>
[ApiController]
public class PlaylistsController(
PlaylistService playlists,
DorkNetDbContext db) : ControllerBase
{
private long Me => this.RequireCurrentPlayerId();
// ── Create / delete ──────────────────────────────────────────────
public sealed class CreatePlaylistRequest
{
public string? Name { get; set; }
public string? Description { get; set; }
public string? ImageName { get; set; }
public string? Tags { get; set; }
}
[HttpPost("/playlists")]
[Authorize]
public async Task<IActionResult> Create([FromBody] CreatePlaylistRequest req)
{
if (string.IsNullOrWhiteSpace(req.Name)) return BadRequest(new { error = "missing_name" });
var created = await playlists.CreateAsync(Me, req.Name, req.Description, req.ImageName, req.Tags);
return await BuildDetailsResponseAsync(created.Id);
}
[HttpDelete("/playlists/{playlistId:long}")]
[Authorize]
public async Task<IActionResult> Delete(long playlistId)
{
try
{
var ok = await playlists.DeleteAsync(playlistId, Me);
return ok ? Ok() : NotFound();
}
catch (UnauthorizedAccessException) { return Forbid(); }
}
// ── Per-field mutations (each returns BMFAGMFKODA) ───────────────
public sealed class StringFieldRequest { public string? Value { get; set; } }
public sealed class IntFieldRequest { public int? Value { get; set; } }
public sealed class BoolFieldRequest { public bool? Value { get; set; } }
public sealed class TagsRequest { public string? Tags { get; set; } public List<string>? TagsList { get; set; } }
[HttpPost("/playlists/{playlistId:long}/name")]
[HttpPut("/playlists/{playlistId:long}/name")]
[Authorize]
public Task<IActionResult> PlaylistName(long playlistId, [FromBody] StringFieldRequest req,
[FromForm(Name = "Name")] string? formValue) =>
ApplyMutation(playlistId, p => p.Name = (req?.Value ?? formValue ?? p.Name).Trim());
[HttpPost("/playlists/{playlistId:long}/description")]
[HttpPut("/playlists/{playlistId:long}/description")]
[Authorize]
public Task<IActionResult> PlaylistDescription(long playlistId, [FromBody] StringFieldRequest req,
[FromForm(Name = "Description")] string? formValue) =>
ApplyMutation(playlistId, p => p.Description = req?.Value ?? formValue ?? p.Description);
[HttpPost("/playlists/{playlistId:long}/image")]
[HttpPut("/playlists/{playlistId:long}/image")]
[Authorize]
public Task<IActionResult> PlaylistImage(long playlistId, [FromBody] StringFieldRequest req,
[FromForm(Name = "ImageName")] string? formValue) =>
ApplyMutation(playlistId, p => p.ImageName = req?.Value ?? formValue ?? p.ImageName);
[HttpPost("/playlists/{playlistId:long}/tags")]
[HttpPut("/playlists/{playlistId:long}/tags")]
[Authorize]
public Task<IActionResult> PlaylistTags(long playlistId, [FromBody] TagsRequest? req,
[FromForm(Name = "Tags")] string? formValue) =>
ApplyMutation(playlistId, p =>
{
var csv = req?.Tags
?? (req?.TagsList is { Count: > 0 } list ? string.Join(',', list) : null)
?? formValue
?? p.TagsCsv;
p.TagsCsv = csv;
});
/// <summary>Accessibility / visibility / level-voting / restrictions /
/// warning are persisted on the playlist row even though the 2020
/// PlaylistEntity doesn't have dedicated columns for each — the wire
/// shape always reports the union-entry defaults, so for now the
/// mutation is a no-op acknowledgement that returns the unchanged
/// playlist. Real per-field columns can be added when we have a
/// playlist-settings UI to drive them.</summary>
[HttpPost("/playlists/{playlistId:long}/accessibility")]
[HttpPost("/playlists/{playlistId:long}/visibility")]
[HttpPost("/playlists/{playlistId:long}/levelvoting")]
[HttpPost("/playlists/{playlistId:long}/restrictions")]
[HttpPost("/playlists/{playlistId:long}/warning")]
[Authorize]
public Task<IActionResult> PlaylistAck(long playlistId) =>
ApplyMutation(playlistId, _ => { /* no-op: field not persisted yet */ });
private async Task<IActionResult> ApplyMutation(long playlistId, Action<PlaylistEntity> mutator)
{
try
{
var updated = await playlists.ModifyAsync(playlistId, Me, mutator);
if (updated is null) return NotFound();
return await BuildDetailsResponseAsync(playlistId);
}
catch (UnauthorizedAccessException) { return Forbid(); }
}
// ── Member-room mutations ────────────────────────────────────────
[HttpPut("/playlists/{playlistId:long}/rooms/{roomId:long}")]
[HttpPost("/playlists/{playlistId:long}/rooms/{roomId:long}")]
[Authorize]
public async Task<IActionResult> AddRoom(long playlistId, long roomId)
{
try
{
if (!await playlists.AddRoomAsync(playlistId, roomId, Me)) return NotFound();
return await BuildDetailsResponseAsync(playlistId);
}
catch (UnauthorizedAccessException) { return Forbid(); }
}
[HttpDelete("/playlists/{playlistId:long}/rooms/{roomId:long}")]
[Authorize]
public async Task<IActionResult> RemoveRoom(long playlistId, long roomId)
{
try
{
if (!await playlists.RemoveRoomAsync(playlistId, roomId, Me)) return NotFound();
return await BuildDetailsResponseAsync(playlistId);
}
catch (UnauthorizedAccessException) { return Forbid(); }
}
// ── Cheer / favorite toggles ─────────────────────────────────────
[HttpPost("/playlists/{playlistId:long}/interactionby/me/cheer")]
[HttpPut("/playlists/{playlistId:long}/interactionby/me/cheer")]
[Authorize]
public async Task<IActionResult> Cheer(long playlistId)
{
await playlists.SetInteractionAsync(playlistId, Me, cheered: true);
return Ok(new { success = true });
}
[HttpDelete("/playlists/{playlistId:long}/interactionby/me/cheer")]
[Authorize]
public async Task<IActionResult> Uncheer(long playlistId)
{
await playlists.SetInteractionAsync(playlistId, Me, cheered: false);
return Ok(new { success = true });
}
[HttpPost("/playlists/{playlistId:long}/interactionby/me/favorite")]
[HttpPut("/playlists/{playlistId:long}/interactionby/me/favorite")]
[Authorize]
public async Task<IActionResult> Favorite(long playlistId)
{
await playlists.SetInteractionAsync(playlistId, Me, favorited: true);
return Ok(new { success = true });
}
[HttpDelete("/playlists/{playlistId:long}/interactionby/me/favorite")]
[Authorize]
public async Task<IActionResult> Unfavorite(long playlistId)
{
await playlists.SetInteractionAsync(playlistId, Me, favorited: false);
return Ok(new { success = true });
}
// ── My-playlists tabs (bare-list KMKPEOGJDFK) ───────────────────
[HttpGet("/playlists/createdby/me")]
[Authorize]
public async Task<IActionResult> CreatedByMe()
{
var rows = await playlists.CreatedByAsync(Me);
return Ok(rows.Select(RoomsController.BuildPlaylistUnionEntry).ToList());
}
[HttpGet("/playlists/cheeredby/me")]
[Authorize]
public async Task<IActionResult> CheeredByMe()
{
var rows = await playlists.CheeredByAsync(Me);
return Ok(rows.Select(RoomsController.BuildPlaylistUnionEntry).ToList());
}
[HttpGet("/playlists/favoritedby/me")]
[Authorize]
public async Task<IActionResult> FavoritedByMe()
{
var rows = await playlists.FavoritedByAsync(Me);
return Ok(rows.Select(RoomsController.BuildPlaylistUnionEntry).ToList());
}
/// <summary>GET <c>/playlists/visitedby/me</c> — we don't track
/// per-player playlist visits yet (visits are tracked at the Room
/// level via RoomVisitEntity). Return empty so the watch's
/// playlists tab renders cleanly; populated once we add a
/// PlaylistVisitEntity or derive from member-room visits.</summary>
[HttpGet("/playlists/visitedby/me")]
[Authorize]
public IActionResult VisitedByMe() => Ok(Array.Empty<object>());
// ── Bulk lookup ──────────────────────────────────────────────────
/// <summary>GET <c>/playlists/bulk?id=A&amp;id=B</c> — bulk-by-id
/// lookup for the watch's playlist cache warmup. Bare list of
/// KMKPEOGJDFK entries, one per resolved id (missing ids
/// silently dropped).</summary>
[HttpGet("/playlists/bulk")]
public async Task<IActionResult> Bulk()
{
var ids = Request.Query["id"].Concat(Request.Query["ids"])
.SelectMany(v => (v ?? string.Empty).Split(',',
StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
.Select(s => long.TryParse(s, out var n) ? n : 0)
.Where(n => n != 0)
.ToList();
if (ids.Count == 0) return Ok(Array.Empty<object>());
var rows = await playlists.BulkAsync(ids);
return Ok(rows.Select(RoomsController.BuildPlaylistUnionEntry).ToList());
}
// ── Detail-response builder (BMFAGMFKODA shape) ─────────────────
/// <summary>
/// BMFAGMFKODA wire shape: KMKPEOGJDFK union entry + Rooms list
/// (List&lt;KLCOGEIGEBJ&gt;) + Tags list (List&lt;DPHPFLGAICI&gt;).
/// Mirrors the inline logic in <see cref="RoomsController.PlaylistDetails"/>
/// so the BARE-PATH mutation routes here return the same shape the
/// watch's <c>PlaylistDetails.Deserialize</c> reads.
/// </summary>
private async Task<IActionResult> BuildDetailsResponseAsync(long playlistId)
{
var p = await playlists.GetByIdAsync(playlistId);
if (p is null) return NotFound();
var roomIds = await playlists.RoomIdsAsync(playlistId);
List<RoomEntity> memberRooms = new();
Dictionary<long, IReadOnlyList<RoomSceneEntity>> scenesByRoom = new();
if (roomIds.Count > 0)
{
var roomRows = await db.Rooms.Where(r => roomIds.Contains(r.Id)).ToListAsync();
var byId = roomRows.ToDictionary(r => r.Id);
memberRooms = roomIds
.Select(rid => byId.TryGetValue(rid, out var r) ? r : null)
.Where(r => r is not null).Select(r => r!).ToList();
var sceneRows = await db.RoomScenes
.Where(s => roomIds.Contains(s.RoomId))
.OrderBy(s => s.OrderIndex).ToListAsync();
scenesByRoom = sceneRows.GroupBy(s => s.RoomId)
.ToDictionary(g => g.Key, g => (IReadOnlyList<RoomSceneEntity>)g.ToList());
}
var roomsWire = memberRooms
.Select(r => RoomsController.BuildRoomServerDetails(r, scenesByRoom.GetValueOrDefault(r.Id)))
.ToList();
var tagsWire = string.IsNullOrWhiteSpace(p.TagsCsv)
? new List<object>()
: p.TagsCsv
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(t => (object)new { Type = 0, Tag = t })
.ToList();
var result = RoomsController.BuildPlaylistUnionEntry(p);
result["Rooms"] = roomsWire;
result["Tags"] = tagsWire;
return Ok(result);
}
}
@@ -0,0 +1,592 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.API.ProgressionApi;
/// <summary>
/// api.rec.net/api/{challenge,checklist,objectives}/* — daily
/// challenges, onboarding checklist, and the full objectives surface
/// (myprogress, cleargroup, updateobjective, completegroup). All
/// concepts share the same persistence model:
/// <see cref="ObjectiveProgressEntity"/> rows keyed by a prefix:
/// <c>challenge:{id}</c>, <c>checklist:{id}</c>, <c>group:{id}</c>,
/// <c>obj:{group}:{index}</c>.
/// </summary>
[ApiController]
public class ProgressionController(
DorkNetDbContext db,
LevelService level,
ServerSettingsService serverSettings) : ControllerBase
{
private long Me => this.RequireCurrentPlayerId();
private long? MeOrNull => this.CurrentPlayerId();
private const int RecCenterTokens = 2;
// ── Challenges ───────────────────────────────────────────────────────
/// <summary>GET <c>api/challenge/v{1,2}/getCurrent</c> — returns
/// the active daily-challenge map (NOT a flat challenge object).
/// Verified against
/// <c>Cpp2IL_ISIL/.../RecNet/Challenges.txt:105-184</c>
/// (<c>GetCurrentChallenges</c> hits this URL and parses as
/// <c>ChallengeMap</c>). Wire shape per
/// <c>RecNet/ChallengeMap.txt:540-616</c>:
///
/// <c>ChallengeMapId</c> (int, required), <c>CompletedRequired</c>
/// (bool, required), <c>StartAt</c>/<c>EndAt</c>/<c>ServerTime</c>
/// (DateTime, required), <c>Challenges</c> (List&lt;RecNetChallenge&gt;,
/// required), <c>Gift</c> (ChallengeGift, required),
/// <c>ChallengeThemeString</c> (string, optional).
///
/// Each <c>RecNetChallenge</c> requires only <c>ChallengeId</c>;
/// <c>Name</c>/<c>Config</c>/<c>Description</c>/<c>Tooltip</c>/<c>Complete</c>
/// are optional. Each <c>ChallengeGift</c> requires <c>GiftDropId</c>,
/// <c>Xp</c>, <c>Level</c>, <c>GiftContext</c>, <c>GiftRarity</c>.
///
/// Previous flat-challenge response crashed
/// <c>ChallengeMap.Deserialize</c> at <c>Util.GetKey("ChallengeMapId")</c>.</summary>
[HttpGet("api/challenge/v1/getCurrent")]
[HttpGet("api/challenge/v2/getCurrent")]
public async Task<IActionResult> CurrentChallenge()
{
var pid = MeOrNull;
var now = DateTime.UtcNow;
var start = now.Date.AddDays(-(int)now.DayOfWeek);
var mapId = CurrentChallengeMapId();
var weekly = await serverSettings.GetWeeklyChallengesAsync();
var challengeDefs = weekly.Challenges;
var keys = challengeDefs.Select(c => ChallengeProgressKey(mapId, c.Index)).ToArray();
var rows = pid is long me
? await db.ObjectiveProgress
.Where(o => o.PlayerId == me && keys.Contains(o.Key))
.ToDictionaryAsync(o => o.Key, o => o.IsCompleted)
: new Dictionary<string, bool>();
bool Done(int idx) => rows.TryGetValue(keys[idx], out var v) && v;
return Ok(new
{
ChallengeMapId = mapId,
CompletedRequired = weekly.CompletedRequired,
StartAt = start,
EndAt = start.AddDays(7),
ServerTime = now,
// 2020.12 ChallengeMap.Deserialize (FNEEJMCEPOL.PPGFHEDFBEA) reads
// FallbackGiftName as a required key. Missing → KeyNotFoundException
// → "Received malformed RecNet response" → blocks the weekly-challenge
// promise chain at boot.
FallbackGiftName = string.Empty,
ChallengeThemeString = "Weekly",
Challenges = challengeDefs.Select(c => new
{
ChallengeId = ChallengeId(mapId, c.Index),
c.Name,
c.Config,
c.Description,
c.Tooltip,
Complete = Done(c.Index),
}),
Gift = new
{
GiftDropId = weekly.Reward.GiftDropId != 0 ? weekly.Reward.GiftDropId : mapId,
weekly.Reward.AvatarItemDesc,
weekly.Reward.ConsumableItemDesc,
weekly.Reward.EquipmentPrefabName,
weekly.Reward.EquipmentModificationGuid,
weekly.Reward.Xp,
weekly.Reward.Level,
weekly.Reward.GiftContext,
weekly.Reward.GiftRarity,
},
});
}
public sealed class UpdateChallengeRequest
{
public int Id { get; set; }
public int ChallengeMapId { get; set; }
public int ChallengeId { get; set; }
public int Progress { get; set; }
public string? Config { get; set; }
public bool? Complete { get; set; }
}
[HttpPost("api/challenge/v1/updateProgress")]
[HttpPost("api/challenge/v2/updateProgress")]
[Authorize]
public async Task<IActionResult> UpdateChallengeProgress([FromForm] UpdateChallengeRequest formReq)
{
var pid = Me;
var req = await ReadChallengeRequestAsync(formReq);
var mapId = req.ChallengeMapId != 0 ? req.ChallengeMapId : CurrentChallengeMapId();
var challengeId = req.ChallengeId != 0 ? req.ChallengeId : req.Id;
var weekly = await serverSettings.GetWeeklyChallengesAsync();
var challengeCount = Math.Max(1, weekly.Challenges.Count);
var index = ChallengeIndex(mapId, challengeId, challengeCount);
var key = ChallengeProgressKey(mapId, index);
var row = await db.ObjectiveProgress.FirstOrDefaultAsync(o => o.PlayerId == pid && o.Key == key);
if (row is null)
{
row = new ObjectiveProgressEntity { PlayerId = pid, Key = key };
db.ObjectiveProgress.Add(row);
}
var shouldComplete = req.Complete ?? true;
if (shouldComplete && !row.IsCompleted)
{
row.IsCompleted = true;
row.ClearedAt = DateTime.UtcNow;
}
await db.SaveChangesAsync();
var weeklyRewarded = false;
var allKeys = weekly.Challenges
.Select(c => c.Index)
.Select(i => ChallengeProgressKey(mapId, i))
.ToArray();
var completeCount = await db.ObjectiveProgress
.CountAsync(o => o.PlayerId == pid && allKeys.Contains(o.Key) && o.IsCompleted);
if (completeCount >= allKeys.Length)
{
weeklyRewarded = await GrantRewardOnceAsync(
pid,
$"weekly-challenge:{mapId}",
xp: weekly.Reward.Xp,
tokens: weekly.Reward.Tokens,
reason: $"weekly_challenge:{mapId}");
}
return Ok(new
{
ChallengeMapId = mapId,
ChallengeId = challengeId,
Config = req.Config ?? string.Empty,
Complete = row.IsCompleted,
Progress = Math.Max(req.Progress, row.IsCompleted ? 10 : 0),
IsCompleted = row.IsCompleted,
ClearedAt = row.ClearedAt,
Rewarded = weeklyRewarded,
});
}
// ── Checklist ────────────────────────────────────────────────────────
/// <summary>GET <c>api/checklist/v{1,2}/current</c> — returns
/// a flat <c>List&lt;ChecklistObjective&gt;</c>, NOT a wrapper
/// object. Wire shape per
/// <c>Cpp2IL_CS/.../RecNet/Checklist.cs</c>:
/// <c>Order(int), Objective(ObjectiveType int), Count(int),
/// CreditAmount(int)</c>. All four keys are required
/// (<c>Util.GetKey</c>, not <c>OrDefault</c>).
///
/// Completion state is tracked separately by the client via
/// <see cref="ObjectiveProgress"/> rows it cross-references — we
/// don't put a per-item "done" flag on the wire; the client
/// matches by Order. Returning an empty list ([]) is also valid
/// per the deserialiser (List wrapper accepts empty).</summary>
[HttpGet("api/checklist/v1/current")]
[HttpGet("api/checklist/v2/current")]
public IActionResult CurrentChecklist()
{
// Default NUX checklist for a brand-new account. Each row
// maps an in-game step to an ObjectiveType enum value
// (decompiled from ProgressionManager.cs).
return Ok(new[]
{
new { Order = 0, Objective = 38 /* SaveOutfitSlot */, Count = 1, CreditAmount = 25 },
new { Order = 1, Objective = 32 /* VisitACustomRoom */, Count = 1, CreditAmount = 25 },
new { Order = 2, Objective = 2 /* AddAFriend */, Count = 1, CreditAmount = 25 },
new { Order = 3, Objective = 30 /* GoToRecCenter */, Count = 1, CreditAmount = 25 },
new { Order = 4, Objective = 6 /* CheerAPlayer */, Count = 1, CreditAmount = 25 },
});
}
public sealed class CompleteChecklistRequest
{
public int Id { get; set; }
public int ItemIndex { get; set; }
}
[HttpPost("api/checklist/v1/complete")]
[HttpPost("api/checklist/v2/complete")]
[Authorize]
public async Task<IActionResult> CompleteChecklist([FromBody] CompleteChecklistRequest req)
{
var pid = Me;
var id = req.ItemIndex != 0 ? req.ItemIndex : req.Id;
var key = $"checklist:{id}";
var row = await db.ObjectiveProgress.FirstOrDefaultAsync(o => o.PlayerId == pid && o.Key == key);
if (row is null)
{
row = new ObjectiveProgressEntity { PlayerId = pid, Key = key };
db.ObjectiveProgress.Add(row);
}
row.IsCompleted = true;
row.ClearedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
var rewarded = await GrantRewardOnceAsync(
pid,
$"checklist:{id}",
xp: 25,
tokens: 25,
reason: $"checklist:{id}");
var balance = await level.GetBalanceAsync(pid, RecCenterTokens);
return Ok(BalanceUpdateResponse(balance, RecCenterTokens, rewarded ? 25 : 0, 303));
}
// ── Objectives bulk-complete (real persistence) ──────────────────────
/// <summary>POST <c>api/objectives/v1/completegroup</c> — replaces
/// the previous shape-stub in MissingEndpointsController. Now
/// upserts a real <see cref="ObjectiveProgressEntity"/> row keyed
/// by <c>group:{id}</c>.</summary>
public sealed class UpdateObjectiveRequest
{
public int Group { get; set; }
public int Index { get; set; }
public float Progress { get; set; }
public bool IsCompleted { get; set; }
}
/// <summary>POST <c>api/objectives/v1/updateobjective</c> — per-
/// objective progress update. Was previously caught by the
/// namespace wildcard returning <c>[]</c>; now persists to
/// <see cref="ObjectiveProgressEntity"/> keyed
/// <c>obj:{group}:{index}</c> so progress survives sessions.
/// Wire shape from <c>RecNet.Objectives.UpdateObjectiveProgress</c>.</summary>
[HttpPost("api/objectives/v1/updateobjective")]
[Authorize]
public async Task<IActionResult> UpdateObjective([FromBody] UpdateObjectiveRequest req)
{
var pid = Me;
var key = $"obj:{req.Group}:{req.Index}";
var row = await db.ObjectiveProgress.FirstOrDefaultAsync(o => o.PlayerId == pid && o.Key == key);
if (row is null)
{
row = new ObjectiveProgressEntity { PlayerId = pid, Key = key };
db.ObjectiveProgress.Add(row);
}
if (req.IsCompleted || req.Progress >= 1f)
{
row.IsCompleted = true;
row.ClearedAt ??= DateTime.UtcNow;
}
await db.SaveChangesAsync();
var rewardKey = $"obj:{req.Group}:{req.Index}";
var isRewarded = await IsRewardedAsync(pid, rewardKey);
return Ok(new
{
// 2020.12 NMEKJOABIFC.PPGFHEDFBEA: HasClaimedReward (not IsRewarded).
req.Index,
req.Group,
Progress = row.IsCompleted ? 1f : req.Progress,
VisualProgress = row.IsCompleted ? 1f : req.Progress,
IsCompleted = row.IsCompleted,
HasClaimedReward = isRewarded,
});
}
[HttpPost("api/objectives/v1/completegroup")]
[Authorize]
public async Task<IActionResult> CompleteGroup([FromBody] CompleteGroupBody body)
{
var pid = Me;
var key = $"group:{body.Group}";
var row = await db.ObjectiveProgress.FirstOrDefaultAsync(o => o.PlayerId == pid && o.Key == key);
if (row is null)
{
row = new ObjectiveProgressEntity { PlayerId = pid, Key = key };
db.ObjectiveProgress.Add(row);
}
row.IsCompleted = true;
row.ClearedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
var rewarded = await GrantObjectiveGroupRewardAsync(pid, body.Group);
return Ok(new
{
Group = body.Group,
IsCompleted = true,
ClearedAt = row.ClearedAt!.Value.ToString("o"),
RequiresCompleteOnServer = false,
IsRewarded = true,
Rewarded = rewarded,
});
}
public sealed class CompleteGroupBody { public int Group { get; set; } }
/// <summary>GET <c>api/objectives/v1/myprogress</c> +
/// <c>api/players/v2/objectives</c> — the watch's daily/weekly
/// objectives checklist. Returns
/// <c>{Objectives: [...], ObjectiveGroups: [...]}</c> per
/// <c>Objectives.MyProgress.Deserialize</c> (RVA 0x14510A0).
/// Empty lists are acceptable; both keys must be present.</summary>
[HttpGet("api/objectives/v1/myprogress")]
[HttpGet("api/players/v2/objectives")]
public async Task<IActionResult> MyObjectiveProgress()
{
var pid = this.CurrentPlayerId();
if (pid is not long me)
return Ok(new { Objectives = Array.Empty<object>(), ObjectiveGroups = Array.Empty<object>() });
var rows = await db.ObjectiveProgress
.Where(o => o.PlayerId == me)
.ToListAsync();
var rewardKeys = rows
.Where(o => o.Key.StartsWith("reward:"))
.Select(o => o.Key["reward:".Length..])
.ToHashSet(StringComparer.Ordinal);
var objectives = rows
.Where(o => o.Key.StartsWith("obj:"))
.Select(o =>
{
var (grp, idx) = ParseObjectiveKey(o.Key);
// 2020.12 NMEKJOABIFC.PPGFHEDFBEA reads Index, Group, Progress,
// VisualProgress, IsCompleted, HasClaimedReward (NOT IsRewarded).
// Missing HasClaimedReward → KeyNotFoundException → "Couldn't
// download progress data" → BootSequence kick to LogoutScene.
return new
{
Index = idx,
Group = grp,
Progress = o.IsCompleted ? 1f : 0f,
VisualProgress = o.IsCompleted ? 1f : 0f,
IsCompleted = o.IsCompleted,
HasClaimedReward = rewardKeys.Contains(o.Key),
};
});
var groups = rows
.Where(o => o.Key.StartsWith("group:"))
.Select(o => new
{
Group = int.TryParse(o.Key["group:".Length..], out var g) ? g : 0,
IsCompleted = o.IsCompleted,
ClearedAt = o.ClearedAt ?? new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc),
RequiresCompleteOnServer = false,
IsRewarded = rewardKeys.Contains(o.Key),
});
return Ok(new { Objectives = objectives, ObjectiveGroups = groups });
}
/// <summary>POST <c>api/objectives/v1/cleargroup</c> — flips a
/// group's <c>IsCompleted</c> flag, used by the checklist's
/// "claim group reward" button. Response is a single
/// <c>ObjectiveGroupProgress</c> with Group/IsCompleted/ClearedAt
/// required keys; <c>RequiresCompleteOnServer</c> is optional.</summary>
[HttpPost("api/objectives/v1/cleargroup")]
public async Task<IActionResult> ClearGroup([FromForm(Name = "group")] int? group)
{
var pid = this.CurrentPlayerId();
var key = $"group:{group ?? 0}";
var now = DateTime.UtcNow;
if (pid is long me)
{
var row = await db.ObjectiveProgress
.FirstOrDefaultAsync(o => o.PlayerId == me && o.Key == key);
if (row is null)
{
row = new ObjectiveProgressEntity { PlayerId = me, Key = key };
db.ObjectiveProgress.Add(row);
}
row.IsCompleted = true;
row.ClearedAt = now;
await db.SaveChangesAsync();
await GrantObjectiveGroupRewardAsync(me, group ?? 0);
}
return Ok(new
{
Group = group ?? 0,
IsCompleted = true,
ClearedAt = now.ToString("o"),
RequiresCompleteOnServer = false,
IsRewarded = true,
});
}
/// <summary>POST <c>api/players/v1/objectives</c> +
/// <c>api/players/v2/objectives</c> — batched ProgressionManager
/// completion records (the watch's
/// <c>ACJLCBNBJDK.EIFPPIPOIHB(List&lt;PIHHFPDKFAG&gt;)</c>; body is a
/// JSON array of <c>{objectiveType: int, additionalXp: int,
/// inParty: bool}</c>). Grants XP + RecCenter tokens via
/// <see cref="RewardForObjectiveType"/> and dedupes per UTC day
/// through <see cref="GrantRewardOnceAsync"/>, so a re-submission of
/// the same objective on the same day is a no-op. Returns the
/// granted XP / currency + post-grant balance so the watch UI can
/// refresh without a follow-up GET.</summary>
[HttpPost("api/players/v1/objectives")]
[HttpPost("api/players/v2/objectives")]
[Authorize]
public async Task<IActionResult> CompletePlayerObjectives([FromBody] List<PlayerObjectiveCompletionRecord>? records)
{
var pid = Me;
var grantedXp = 0;
var grantedTokens = 0;
foreach (var record in records ?? new())
{
var (xp, tokens, reason) = RewardForObjectiveType(record.ObjectiveType, record.AdditionalXp);
if (xp <= 0 && tokens <= 0) continue;
if (await GrantRewardOnceAsync(pid, $"{reason}:{DateTime.UtcNow:yyyyMMdd}", xp, tokens, reason))
{
grantedXp += xp;
grantedTokens += tokens;
}
}
var balance = await level.GetBalanceAsync(pid, RecCenterTokens);
return Ok(new
{
GrantedXp = grantedXp,
GrantedCurrency = grantedTokens,
CurrencyType = RecCenterTokens,
Balance = balance,
BalanceType = 0,
});
}
public sealed class PlayerObjectiveCompletionRecord
{
public int ObjectiveType { get; set; }
public bool InParty { get; set; }
public int AdditionalXp { get; set; }
}
private static (int Group, int Index) ParseObjectiveKey(string key)
{
const string prefix = "obj:";
if (!key.StartsWith(prefix)) return (-1, -1);
var rest = key[prefix.Length..];
var colon = rest.IndexOf(':');
if (colon <= 0) return (-1, -1);
var grp = int.TryParse(rest[..colon], out var g) ? g : -1;
var idx = int.TryParse(rest[(colon + 1)..], out var i) ? i : -1;
return (grp, idx);
}
private static int CurrentChallengeMapId() =>
(int)((DateTime.UtcNow - new DateTime(2020, 1, 1)).Days / 7 % 52 + 1);
private static string ChallengeProgressKey(int mapId, int index) => $"challenge:{mapId}:{index}";
private static int ChallengeId(int mapId, int index) => mapId * 10 + index;
private static int ChallengeIndex(int mapId, int challengeId, int challengeCount)
{
if (challengeId >= mapId * 10 && challengeId < mapId * 10 + 10)
return Math.Clamp(challengeId - mapId * 10, 0, challengeCount - 1);
return Math.Clamp(Math.Abs(challengeId % 10), 0, challengeCount - 1);
}
private async Task<UpdateChallengeRequest> ReadChallengeRequestAsync(UpdateChallengeRequest formReq)
{
if (Request.HasFormContentType || formReq.Id != 0 || formReq.ChallengeId != 0 || formReq.ChallengeMapId != 0)
return formReq;
try
{
var body = await System.Text.Json.JsonSerializer.DeserializeAsync<UpdateChallengeRequest>(
Request.Body,
new System.Text.Json.JsonSerializerOptions { PropertyNameCaseInsensitive = true });
return body ?? formReq;
}
catch
{
return formReq;
}
}
private async Task<bool> GrantObjectiveGroupRewardAsync(long playerId, int group)
{
var (xp, tokens, name) = group switch
{
0 => (150, 150, "daily-objectives"),
1 => (100, 100, "new-player-punch-card"),
2 => (75, 75, "orientation-checklist"),
_ => (50, 50, $"objective-group-{group}"),
};
return await GrantRewardOnceAsync(playerId, $"group:{group}", xp, tokens, name);
}
private static (int Xp, int Tokens, string Reason) RewardForObjectiveType(int objectiveType, int additionalXp)
{
var xp = Math.Max(additionalXp, 0);
return objectiveType switch
{
10 or 11 or 12 => (Math.Max(xp, 25), 25, $"daily-objective:{objectiveType}"),
13 => (Math.Max(xp, 150), 150, "all-daily-objectives"),
14 => (Math.Max(xp, 50), 50, "complete-any-daily"),
15 => (Math.Max(xp, 75), 75, "complete-any-weekly"),
25 => (Math.Max(xp, 25), 25, "nux-punchcard-objective"),
26 => (Math.Max(xp, 100), 100, "nux-all-punchcard-objectives"),
_ => (xp, 0, $"objective:{objectiveType}"),
};
}
private async Task<bool> IsRewardedAsync(long playerId, string rewardKey) =>
await db.ObjectiveProgress.AnyAsync(o =>
o.PlayerId == playerId && o.Key == $"reward:{rewardKey}" && o.IsCompleted);
private async Task<bool> GrantRewardOnceAsync(long playerId, string rewardKey, int xp, int tokens, string reason)
{
var sentinelKey = $"reward:{rewardKey}";
var exists = await db.ObjectiveProgress.AnyAsync(o =>
o.PlayerId == playerId && o.Key == sentinelKey && o.IsCompleted);
if (exists) return false;
db.ObjectiveProgress.Add(new ObjectiveProgressEntity
{
PlayerId = playerId,
Key = sentinelKey,
IsCompleted = true,
ClearedAt = DateTime.UtcNow,
});
await db.SaveChangesAsync();
if (xp > 0) await level.AwardXpAsync(playerId, xp, reason);
if (tokens > 0) await level.GrantCurrencyAsync(playerId, RecCenterTokens, tokens, reason);
return true;
}
private static object BalanceUpdateResponse(long balance, int currencyType, int delta, int balanceAddType) => new
{
Balance = balance,
CurrencyType = currencyType,
BalanceType = 0,
BalanceUpdates = new[]
{
new
{
UpdateResponse = 0,
Data = delta <= 0
? Array.Empty<object>()
: new object[]
{
new
{
BalanceAddType = balanceAddType,
BaseAward = delta,
BonusAward = 0,
RateLimit = 0,
CurrentCount = 1,
Total = delta,
BalanceType = 0,
BalanceInGiftBox = false,
},
},
},
},
};
}
@@ -0,0 +1,51 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using DorkNet.Server.Data;
namespace DorkNet.Server.Controllers.API.QuickPlay;
/// <summary>
/// api.rec.net/api/quickPlay/* — the watch's "Quick Play" tile picks
/// a popular open AG room when the player wants to jump in
/// somewhere social without browsing.
///
/// • <c>GET v{1,2}/getandclear</c> — returns the next match the
/// user was queued for, then clears it. We just synthesise a
/// "join hot AG room" target on each call (no real queue).
/// • <c>POST v1/set</c> — stores a desired-match tag. We log + ack;
/// no persistence needed for a single-region private server.
/// </summary>
[ApiController]
public class QuickPlayController(DorkNetDbContext db) : ControllerBase
{
/// <summary>GET <c>v{1,2}/getandclear</c> — the watch polls this
/// on every connect / navigation to check whether the user has a
/// pending quickplay queue. Returning a real {RoomId, RoomName}
/// makes the client treat it as "you queued, go there now" and
/// immediately teleports — that's WHY users were randomly
/// spawning in Paddleball etc.
///
/// Correct behaviour: return empty {} unless we actually have a
/// queued match for the caller. The /set endpoint is what
/// queues; getandclear is the consume.
///
/// We don't persist queue state on a single-region private
/// server (no real matchmaking pool), so always return empty.</summary>
[HttpGet("api/quickPlay/v1/getandclear")]
[HttpGet("api/quickPlay/v2/getandclear")]
public IActionResult GetAndClear() => Ok(new { });
public sealed class SetQuickPlayRequest
{
public string? Tag { get; set; }
}
[HttpPost("api/quickPlay/v1/set")]
[Authorize]
public IActionResult Set([FromBody] SetQuickPlayRequest? req)
{
// Preference is per-session; we don't persist (no benefit in
// a small private deployment). Just ack.
return Ok(new { Tag = req?.Tag ?? string.Empty, Set = true });
}
}
@@ -0,0 +1,367 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
using DorkNet.Server.Services;
// Storage-side enum aliased so the wire mapping stays explicit at
// every callsite. Wire enum values (RelationshipType /
// ReciprocalStatus) live in this file as constants — they're tied
// to the disassembly and not used elsewhere.
using EntityStatus = DorkNet.Server.Data.Entities.RelationshipStatus;
namespace DorkNet.Server.Controllers.API.Relationships.V2;
/// <summary>
/// api/relationships/v2 — friendship + reciprocal-state endpoints.
///
/// All routes / wire shapes verified against the patched 2020 client:
/// Cpp2IL_ISIL/IsilDump/Assembly-CSharp/RecNet/Relationships.txt
/// Cpp2IL_ISIL/IsilDump/Assembly-CSharp/RecNet/Relationship.txt
///
/// Wire response = a Relationship object (or List of them for /get).
/// Relationship.Deserialize strict-reads via Util.GetKey&lt;T&gt;:
/// "PlayerID" (int) — REQUIRED — the OTHER account's id
/// "RelationshipType" (int) — REQUIRED — 0..3
/// "Muted" (int) — REQUIRED — ReciprocalStatus 0..3
/// "Ignored" (int) — REQUIRED — ReciprocalStatus 0..3
/// "Favorited" (int) — optional (Util.GetKeyOrDefault, defaults 0)
///
/// RelationshipType (from caller's perspective):
/// 0 None, 1 FriendRequestSent, 2 FriendRequestReceived, 3 Friend
/// ReciprocalStatus (symmetric flag — who set it):
/// 0 None, 1 Local (just me), 2 Remote (just them), 3 Mutual
///
/// URLs called from the watch (Relationships.txt, all under
/// "api/relationships/"):
/// POST v2/addfriend?id={N} AddFriend
/// POST v2/removefriend?id={N} RemoveFriend / cancel-pending
/// POST v2/sendfriendrequest?id={N} SendFriendRequest
/// POST v2/acceptfriendrequest?id={N} AcceptFriendRequest
/// POST v1/favorite?id={N} FavoritePlayer
/// POST v1/unfavorite?id={N} UnfavoritePlayer
/// POST v1/mute / unmute / ignore / unignore (PostPreferenceChange)
/// GET v2/get GetMyRelationships
/// All POST URLs return one Relationship; GET returns List&lt;Relationship&gt;.
/// </summary>
[ApiController]
[Authorize]
public class RelationshipsController(DorkNetDbContext db, NotificationService notifications) : ControllerBase
{
// RelationshipType — int values match Relationship.RelationshipType
// in the patched dump.cs.
private const int RT_None = 0;
private const int RT_FriendRequestSent = 1;
private const int RT_FriendRequestReceived = 2;
private const int RT_Friend = 3;
// ReciprocalStatus values — also match the dump.cs enum.
private const int RS_None = 0;
private const int RS_Local = 1;
private const int RS_Remote = 2;
private const int RS_Mutual = 3;
private long CurrentPlayerId => this.RequireCurrentPlayerId();
// ── GET /api/relationships/v2/get ────────────────────────────────
// Watch URL: "api/relationships/v2/get". Returns the caller's full
// relationship list as List<Relationship>.
[HttpGet("api/relationships/v2/get")]
public async Task<ActionResult> GetMyRelationships()
{
var me = CurrentPlayerId;
var rels = await db.Relationships
.Where(r => r.RequesterId == me || r.TargetId == me)
.ToListAsync();
var wire = rels.Select(r => BuildRelationship(r, me)).ToList();
return Ok(wire);
}
/// <summary>GET <c>api/relationships/v2/personaldetails/{playerId}</c>
/// — single relationship row from the caller's perspective. Used
/// by the watch's profile-card view to determine whether to show
/// the "Add Friend" or "Remove Friend" affordance. Returns the
/// same Relationship wire shape as <c>v2/get</c>'s list elements.</summary>
[HttpGet("api/relationships/v2/personaldetails/{playerId:long}")]
public async Task<ActionResult> PersonalDetails(long playerId)
{
var me = CurrentPlayerId;
if (playerId <= 0 || playerId == me) return Ok(EmptyRelationship(playerId));
var row = await FindAsync(me, playerId);
return Ok(row is null ? EmptyRelationship(playerId) : BuildRelationship(row, me));
}
// ── /api/relationships/v2/sendfriendrequest?id={N} ───────────────
// Watch URL from Relationships.txt: "{0}v2/sendfriendrequest?id={1}".
// The ISIL labels the call site Core.Post but the live trace shows
// the watch sending GET — accepting both is harmless and the
// safest cross-build choice.
[HttpGet("api/relationships/v2/sendfriendrequest")]
[HttpPost("api/relationships/v2/sendfriendrequest")]
public async Task<ActionResult> SendFriendRequest([FromQuery] long id)
{
var me = CurrentPlayerId;
if (id <= 0 || id == me)
return Ok(EmptyRelationship(id));
var existing = await FindAsync(me, id);
if (existing is not null)
return Ok(BuildRelationship(existing, me));
var row = new RelationshipEntity
{
RequesterId = me,
TargetId = id,
Status = EntityStatus.PendingSent,
};
db.Relationships.Add(row);
await db.SaveChangesAsync();
await notifications.FriendRequestReceived(id, me);
return Ok(BuildRelationship(row, me));
}
// ── /api/relationships/v2/acceptfriendrequest?id={N} ────────────
[HttpGet("api/relationships/v2/acceptfriendrequest")]
[HttpPost("api/relationships/v2/acceptfriendrequest")]
public async Task<ActionResult> AcceptFriendRequest([FromQuery] long id)
{
var me = CurrentPlayerId;
if (id <= 0)
return Ok(EmptyRelationship(id));
// Only an incoming pending row can be accepted. The other side
// must have requested us — RequesterId=them, TargetId=me.
var rel = await db.Relationships.FirstOrDefaultAsync(r =>
r.RequesterId == id && r.TargetId == me &&
r.Status == EntityStatus.PendingSent);
if (rel is null)
return Ok(EmptyRelationship(id));
rel.Status = EntityStatus.Friend;
rel.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
await notifications.FriendRequestAccepted(id, me);
return Ok(BuildRelationship(rel, me));
}
// ── /api/relationships/v2/removefriend?id={N} ────────────────────
// Symmetrical: undoes a friendship OR cancels a pending request OR
// declines an incoming one. Block rows are left alone — the watch
// has a separate explicit unblock path. Live trace shows the watch
// sending GET (BestHTTP queueing oddity vs the ISIL Core.Post call);
// accept both verbs.
[HttpGet("api/relationships/v2/removefriend")]
[HttpPost("api/relationships/v2/removefriend")]
public async Task<ActionResult> RemoveFriend([FromQuery] long id)
{
var me = CurrentPlayerId;
if (id <= 0)
return Ok(EmptyRelationship(id));
var rel = await FindAsync(me, id);
if (rel is null)
return Ok(EmptyRelationship(id));
if (rel.Status == EntityStatus.Blocked)
return Ok(BuildRelationship(rel, me));
db.Relationships.Remove(rel);
await db.SaveChangesAsync();
await notifications.FriendRemoved(id, me);
return Ok(EmptyRelationship(id));
}
// ── /api/relationships/v2/addfriend?id={N} ───────────────────────
// Per the disassembly, AddFriend is its own endpoint distinct from
// SendFriendRequest. Live trace shows the watch sending GET; ISIL
// says Core.Post. Accept both verbs.
[HttpGet("api/relationships/v2/addfriend")]
[HttpPost("api/relationships/v2/addfriend")]
public async Task<ActionResult> AddFriend([FromQuery] long id)
{
var me = CurrentPlayerId;
if (id <= 0 || id == me)
return Ok(EmptyRelationship(id));
var existing = await FindAsync(me, id);
if (existing is null)
{
existing = new RelationshipEntity
{
RequesterId = me,
TargetId = id,
Status = EntityStatus.Friend,
};
db.Relationships.Add(existing);
}
else if (existing.Status != EntityStatus.Blocked)
{
existing.Status = EntityStatus.Friend;
existing.UpdatedAt = DateTime.UtcNow;
}
await db.SaveChangesAsync();
return Ok(BuildRelationship(existing, me));
}
// ── v1 favorite / unfavorite ─────────────────────────────────────
// Watch URLs: "api/relationships/v1/favorite?id={N}" and
// "api/relationships/v1/unfavorite?id={N}". These return a
// boolean-ish RecNetResult per the FavoritePlayer/UnfavoritePlayer
// disassembly (Core.Post … ExpectHttpStatusSuccess), not a
// Relationship — they don't surface any state change in the
// 2020 storage model so we just acknowledge.
[HttpPost("api/relationships/v1/favorite")]
public Task<IActionResult> Favorite([FromQuery] long id) => SetPreference(id, favorited: true);
[HttpPost("api/relationships/v1/unfavorite")]
public Task<IActionResult> Unfavorite([FromQuery] long id) => SetPreference(id, favorited: false);
[HttpPost("api/relationships/v1/mute")]
public Task<IActionResult> Mute([FromQuery] long id, [FromForm(Name = "PlayerId")] long? formId)
=> SetPreference(id != 0 ? id : formId ?? 0, muted: true);
[HttpPost("api/relationships/v1/unmute")]
public Task<IActionResult> Unmute([FromQuery] long id, [FromForm(Name = "PlayerId")] long? formId)
=> SetPreference(id != 0 ? id : formId ?? 0, muted: false);
[HttpPost("api/relationships/v1/ignore")]
public Task<IActionResult> Ignore([FromQuery] long id, [FromForm(Name = "PlayerId")] long? formId)
=> SetPreference(id != 0 ? id : formId ?? 0, ignored: true);
[HttpPost("api/relationships/v1/unignore")]
public Task<IActionResult> Unignore([FromQuery] long id, [FromForm(Name = "PlayerId")] long? formId)
=> SetPreference(id != 0 ? id : formId ?? 0, ignored: false);
/// <summary>Update favorited/muted/ignored on the caller's row.
/// Creates a placeholder row when none exists (so an "ignore"
/// before any friend request is still recorded).</summary>
private async Task<IActionResult> SetPreference(long otherId,
bool? favorited = null, bool? muted = null, bool? ignored = null)
{
var me = this.RequireCurrentPlayerId();
if (otherId <= 0 || otherId == me)
return Ok(new Models.Auth.RecNetResult { Success = false, Error = "invalid_id" });
var row = await db.Relationships.FirstOrDefaultAsync(r =>
r.RequesterId == me && r.TargetId == otherId);
if (row is null)
{
row = new RelationshipEntity
{
RequesterId = me,
TargetId = otherId,
Status = EntityStatus.Friend, // placeholder; favorite/mute/ignore don't require friend status
};
db.Relationships.Add(row);
}
if (favorited is bool f) row.Favorited = f;
if (muted is bool m) row.Muted = m;
if (ignored is bool i) row.Ignored = i;
row.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return Ok(new Models.Auth.RecNetResult { Success = true, Error = string.Empty });
}
public sealed class BulkIgnoreRequest
{
public int Platform { get; set; }
public List<string>? PlatformUserIds { get; set; }
}
/// <summary>POST <c>api/relationships/v1/bulkignoreplatformusers</c>
/// — bulk-import Steam/Oculus blocks. Persists to
/// <see cref="PlatformIgnoreEntity"/> so when those users sign
/// up later we can auto-set their relationship to Ignored.</summary>
[HttpPost("api/relationships/v1/bulkignoreplatformusers")]
[Authorize]
public async Task<IActionResult> BulkIgnorePlatformUsers([FromBody] BulkIgnoreRequest req)
{
var me = this.RequireCurrentPlayerId();
if (req?.PlatformUserIds is null || req.PlatformUserIds.Count == 0)
return Ok(new { Imported = 0 });
var added = 0;
foreach (var puid in req.PlatformUserIds.Distinct())
{
if (string.IsNullOrWhiteSpace(puid)) continue;
var exists = await db.PlatformIgnores.AnyAsync(i =>
i.PlayerId == me && i.Platform == req.Platform && i.PlatformUserId == puid);
if (exists) continue;
db.PlatformIgnores.Add(new PlatformIgnoreEntity
{
PlayerId = me,
Platform = req.Platform,
PlatformUserId = puid,
});
added++;
}
await db.SaveChangesAsync();
return Ok(new { Imported = added });
}
// ── helpers ──────────────────────────────────────────────────────
private Task<RelationshipEntity?> FindAsync(long me, long other) =>
db.Relationships.FirstOrDefaultAsync(r =>
(r.RequesterId == me && r.TargetId == other) ||
(r.RequesterId == other && r.TargetId == me));
/// <summary>Map a storage row to the watch's wire RelationshipType
/// from <paramref name="me"/>'s perspective. PendingSent flips
/// direction depending on which side originated.</summary>
private static int MapType(RelationshipEntity r, long me) => r.Status switch
{
EntityStatus.Friend => RT_Friend,
EntityStatus.Blocked => RT_None, // Blocked is not a friendship; the watch reads block state via a separate flag we don't track yet.
EntityStatus.PendingSent => r.RequesterId == me ? RT_FriendRequestSent : RT_FriendRequestReceived,
EntityStatus.PendingReceived => r.RequesterId == me ? RT_FriendRequestReceived : RT_FriendRequestSent,
_ => RT_None,
};
/// <summary>
/// Build the wire object the patched <c>Relationship.Deserialize</c>
/// reads. Keys are case-sensitive (Util.GetKey is a Dictionary
/// TryGetValue) — we ship exactly what's in the disassembly.
/// Implemented as a Dictionary because System.Text.Json's
/// case-insensitive collision check rejects anonymous types with
/// PascalCase property names like "PlayerID" + "Muted" if any
/// other property could collide on lowercase — using a Dictionary
/// avoids that whole class of trouble.
/// </summary>
private static Dictionary<string, object> BuildRelationship(RelationshipEntity r, long me)
{
var other = r.RequesterId == me ? r.TargetId : r.RequesterId;
// ReciprocalStatus enum: 0=None, 1=ByMe, 2=ByThem, 3=Both.
// Our schema only tracks "by me" (the requester's perspective)
// so we can answer ByMe / None; ByThem / Both would require
// looking up the OTHER direction's row too. Cheap extra query
// omitted — the watch's UI only consumes the ByMe bit.
var mineDir = r.RequesterId == me;
var muted = mineDir && r.Muted ? 1 : 0;
var ignored = mineDir && r.Ignored ? 1 : 0;
var favored = mineDir && r.Favorited ? 1 : 0;
return new()
{
["PlayerID"] = (int)other,
["RelationshipType"] = MapType(r, me),
["Muted"] = muted,
["Ignored"] = ignored,
["Favorited"] = favored,
};
}
/// <summary>"None" relationship for a target the caller has nothing
/// on file with — used as a no-op success response so the watch's
/// Relationship.Deserialize stays satisfied.</summary>
private static Dictionary<string, object> EmptyRelationship(long other) => new()
{
["PlayerID"] = (int)other,
["RelationshipType"] = RT_None,
["Muted"] = RS_None,
["Ignored"] = RS_None,
["Favorited"] = RS_None,
};
}
@@ -0,0 +1,90 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using DorkNet.Server.Data;
namespace DorkNet.Server.Controllers.API.Role;
/// <summary>
/// api.rec.net/api/role/* — role / privilege probes the watch consults
/// when deciding whether to surface dev-only UI (the in-watch debug
/// console button, hidden admin-tool tab, etc).
///
/// URL verified against
/// Cpp2IL_ISIL/IsilDump/Assembly-CSharp/RecNet/Accounts.txt:5168
/// (<c>"role/developer/{0}"</c>) called from
/// <c>RecNet.Accounts.GetAccountIsDeveloper</c>. Response shape is a
/// bare JSON boolean — the watch wraps the call with
/// <c>PromiseExtensions.ExpectPrimitiveResponse&lt;bool&gt;</c>, so an
/// array or object response throws InvalidCastException and silently
/// disables the dev UI.
///
/// To make a player a developer (and unlock the watch's
/// debug-console button), set <c>Players.IsDeveloper = 1</c> in the
/// SQLite DB or via the admin UI's player editor.
/// </summary>
[ApiController]
public class RoleController(DorkNetDbContext db) : ControllerBase
{
// Service-routed call from Accounts.GetAccountIsDeveloper. The
// 2020 dump's SendRequest carries `Service = 0` (Auth) — verified
// at Cpp2IL_ISIL/.../RecNet/Accounts.txt:5183-5186 against the
// RecNet.Service enum in dump.cs:586523-586538 (Auth=0). Service.Auth
// maps to <c>auth.{apex}</c> in ConfigService's ServiceUrls, and
// the path the watch appends has NO `/api/` prefix — it's just
// <c>role/developer/{id}</c>. The previous <c>/api/role/...</c>-only
// route never matched, so every nameplate probe came back 404 and
// the watch logged "Failed to GetIsDeveloper for accountId X: HTTP
// Error 404" once per remote player per scene load.
[HttpGet("/role/developer/{accountId:long}")]
[HttpGet("/api/role/developer/{accountId:long}")]
[AllowAnonymous]
public async Task<ActionResult<bool>> IsDeveloper(long accountId)
{
// OR IsCommunityTeam into the answer so the watch unlocks the
// in-settings "Developer Display Mode" slider for community-team
// members too — the slider's positions render the overhead
// badge as "Community Team" / "Developer" (see
// Cpp2IL_ISIL/.../PlayerUI.txt:9085-9099). The 2020 watch never
// probes a separate community-team role, so collapsing both
// flags into this one endpoint is the only way to reach the
// badge UI without patching the binary.
var isDev = await db.Players
.Where(p => p.Id == accountId)
.Select(p => (bool?)(p.IsDeveloper || p.IsCommunityTeam || p.IsAdmin))
.FirstOrDefaultAsync();
return Ok(isDev ?? false);
}
/// <summary>Mirror endpoint for the admin role — same shape, in case
/// later builds added an <c>/api/role/admin/{id}</c> probe. The 2020
/// dump only references the developer one but admins also get all
/// dev privileges, so the result is unioned.</summary>
[HttpGet("/role/admin/{accountId:long}")]
[HttpGet("/api/role/admin/{accountId:long}")]
[AllowAnonymous]
public async Task<ActionResult<bool>> IsAdmin(long accountId)
{
var isAdmin = await db.Players
.Where(p => p.Id == accountId)
.Select(p => (bool?)p.IsAdmin)
.FirstOrDefaultAsync();
return Ok(isAdmin ?? false);
}
/// <summary>Future-proofing for a hypothetical
/// <c>role/communityteam/{id}</c> probe — not referenced by the 2020
/// build, but mirrors the developer shape so a patched watch can
/// distinguish the two roles without a server change.</summary>
[HttpGet("/role/communityteam/{accountId:long}")]
[HttpGet("/api/role/communityteam/{accountId:long}")]
[AllowAnonymous]
public async Task<ActionResult<bool>> IsCommunityTeam(long accountId)
{
var isCt = await db.Players
.Where(p => p.Id == accountId)
.Select(p => (bool?)p.IsCommunityTeam)
.FirstOrDefaultAsync();
return Ok(isCt ?? false);
}
}
@@ -0,0 +1,280 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using System.Text.Json;
using System.Text.Json.Serialization;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
namespace DorkNet.Server.Controllers.API.RoomKeys;
[ApiController]
[Authorize]
public class RoomKeysController(DorkNetDbContext db) : ControllerBase
{
private long Me => this.RequireCurrentPlayerId();
// Boot-sequence call — the 2020.12 client hits this with roomId=-1
// before the user has authenticated. Class-level [Authorize] would
// 401 and the client treats that as fatal, kicking back to LogoutScene.
[AllowAnonymous]
[HttpGet("api/roomkeys/v1/room")]
public async Task<IActionResult> ForRoom([FromQuery] long roomId)
{
if (roomId <= 0) return Ok(Array.Empty<object>());
var rows = await db.RoomKeys
.Where(k => k.RoomId == roomId && !k.IsDeleted)
.OrderBy(k => k.Price)
.ThenBy(k => k.Name)
.ToListAsync();
return Ok(rows.Select(ToWire));
}
[HttpGet("api/roomkeys/v1/mine")]
public async Task<IActionResult> Mine()
{
var pid = Me;
var rows = await db.RoomKeys
.Where(k => k.CreatorPlayerId == pid && !k.IsDeleted)
.OrderByDescending(k => k.UpdatedAt)
.ToListAsync();
return Ok(rows.Select(ToWire));
}
[HttpPost("api/roomkeys/v1/create")]
public async Task<IActionResult> Create()
{
var req = await ReadBodyAsync<NewRoomKeyRequest>();
var status = ValidateText(req.Name, req.Description, req.Price);
if (status != RoomKeyStatus.Success)
return Ok(RoomKeyResponse(status, null));
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == req.RoomId);
if (room is null) return Ok(RoomKeyResponse(RoomKeyStatus.RoomDoesNotExist, null));
if (!await CanManageRoomKeysAsync(room, Me))
return Ok(RoomKeyResponse(RoomKeyStatus.PermissionDenied, null));
var activeCount = await db.RoomKeys.CountAsync(k => k.RoomId == room.Id && !k.IsDeleted);
if (activeCount >= 100)
return Ok(RoomKeyResponse(RoomKeyStatus.RoomKeyLimitReached, null));
var duplicate = await db.RoomKeys.AnyAsync(k =>
k.RoomId == room.Id && !k.IsDeleted && k.Name == req.Name!.Trim());
if (duplicate) return Ok(RoomKeyResponse(RoomKeyStatus.DuplicateName, null));
var key = new RoomKeyEntity
{
RoomId = room.Id,
CreatorPlayerId = Me,
Name = req.Name!.Trim(),
Description = req.Description!.Trim(),
Price = req.Price,
ReplicationId = Guid.NewGuid().ToString("D"),
};
db.RoomKeys.Add(key);
await db.SaveChangesAsync();
return Ok(RoomKeyResponse(RoomKeyStatus.Success, key));
}
[HttpPost("api/roomkeys/v1/update")]
public async Task<IActionResult> Update()
{
var req = await ReadBodyAsync<UpdateRoomKeyRequest>();
var key = await db.RoomKeys.FirstOrDefaultAsync(k => k.Id == req.RoomKeyId && !k.IsDeleted);
if (key is null) return Ok(RoomKeyResponse(RoomKeyStatus.DoesNotExist, null));
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == key.RoomId);
if (room is null) return Ok(RoomKeyResponse(RoomKeyStatus.RoomDoesNotExist, null));
if (!await CanManageRoomKeysAsync(room, Me))
return Ok(RoomKeyResponse(RoomKeyStatus.PermissionDenied, key));
var nextName = req.Name?.Trim() ?? key.Name;
var nextDescription = req.Description?.Trim() ?? key.Description;
var nextPrice = req.Price ?? key.Price;
var status = ValidateText(nextName, nextDescription, nextPrice);
if (status != RoomKeyStatus.Success)
return Ok(RoomKeyResponse(status, key));
var duplicate = await db.RoomKeys.AnyAsync(k =>
k.Id != key.Id && k.RoomId == key.RoomId && !k.IsDeleted && k.Name == nextName);
if (duplicate) return Ok(RoomKeyResponse(RoomKeyStatus.DuplicateName, key));
key.Name = nextName;
key.Description = nextDescription;
key.Price = nextPrice;
key.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return Ok(RoomKeyResponse(RoomKeyStatus.Success, key));
}
[HttpDelete("api/roomkeys/v1/delete/{roomKeyId:long}")]
[HttpPost("api/roomkeys/v1/delete/{roomKeyId:long}")]
public async Task<IActionResult> Delete(long roomKeyId)
{
var key = await db.RoomKeys.FirstOrDefaultAsync(k => k.Id == roomKeyId && !k.IsDeleted);
if (key is null) return Content(((int)RoomKeyStatus.DoesNotExist).ToString(), "application/json");
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == key.RoomId);
if (room is null) return Content(((int)RoomKeyStatus.RoomDoesNotExist).ToString(), "application/json");
if (!await CanManageRoomKeysAsync(room, Me))
return Content(((int)RoomKeyStatus.PermissionDenied).ToString(), "application/json");
key.IsDeleted = true;
key.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return Content(((int)RoomKeyStatus.Success).ToString(), "application/json");
}
[HttpGet("api/roomkeys/v1/purchased/{roomKeyId:long}")]
public async Task<IActionResult> Purchased(long roomKeyId)
{
var pid = Me;
var owns = await db.RoomKeyPurchases.AnyAsync(p => p.RoomKeyId == roomKeyId && p.PlayerId == pid);
return Content(owns ? "true" : "false", "application/json");
}
public static object RoomKeyResponse(RoomKeyStatus status, RoomKeyEntity? key) => new
{
Status = (int)status,
RoomKey = key is null ? null : ToWire(key),
};
public static object ToWire(RoomKeyEntity key) => new
{
RoomKeyId = key.Id,
ReplicationId = Guid.TryParse(key.ReplicationId, out var guid)
? guid
: Guid.Empty,
key.RoomId,
key.Name,
key.Description,
key.Price,
};
private static RoomKeyStatus ValidateText(string? name, string? description, int price)
{
if (string.IsNullOrWhiteSpace(name) || name.Trim().Length < 3) return RoomKeyStatus.NameTooShort;
if (name.Trim().Length > 40) return RoomKeyStatus.NameTooLong;
if (string.IsNullOrWhiteSpace(description) || description.Trim().Length < 10) return RoomKeyStatus.DescriptionTooShort;
if (description.Trim().Length > 174) return RoomKeyStatus.DescriptionTooLong;
if (price < 10) return RoomKeyStatus.PriceTooLow;
if (price > 10000) return RoomKeyStatus.PriceTooHigh;
return RoomKeyStatus.Success;
}
private async Task<bool> CanManageRoomKeysAsync(RoomEntity room, long playerId)
{
if (room.CreatorPlayerId == playerId) return true;
var isCoOwner = await db.RoomRoles.AnyAsync(r =>
r.RoomId == room.Id && r.PlayerId == playerId && r.Role == 0 && r.Accepted);
if (isCoOwner) return true;
return await db.Players
.Where(p => p.Id == playerId)
.Select(p => p.IsAdmin)
.FirstOrDefaultAsync();
}
private async Task<T> ReadBodyAsync<T>() where T : new()
{
if (Request.HasFormContentType)
{
var form = await Request.ReadFormAsync();
if (typeof(T) == typeof(NewRoomKeyRequest))
{
object req = new NewRoomKeyRequest
{
RoomId = long.TryParse(form["roomId"].FirstOrDefault()
?? form["RoomId"].FirstOrDefault(), out var roomId)
? roomId
: 0,
Name = form["name"].FirstOrDefault() ?? form["Name"].FirstOrDefault(),
Description = form["description"].FirstOrDefault() ?? form["Description"].FirstOrDefault(),
Price = int.TryParse(form["price"].FirstOrDefault()
?? form["Price"].FirstOrDefault(), out var price)
? price
: 0,
};
return (T)req;
}
if (typeof(T) == typeof(UpdateRoomKeyRequest))
{
object req = new UpdateRoomKeyRequest
{
RoomKeyId = long.TryParse(form["roomKeyId"].FirstOrDefault()
?? form["RoomKeyId"].FirstOrDefault(), out var roomKeyId)
? roomKeyId
: 0,
Name = form["name"].FirstOrDefault() ?? form["Name"].FirstOrDefault(),
Description = form["description"].FirstOrDefault() ?? form["Description"].FirstOrDefault(),
Price = int.TryParse(form["price"].FirstOrDefault()
?? form["Price"].FirstOrDefault(), out var price)
? price
: null,
};
return (T)req;
}
return new T();
}
try
{
return await JsonSerializer.DeserializeAsync<T>(Request.Body, JsonOptions) ?? new T();
}
catch (JsonException)
{
return new T();
}
}
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true,
};
public sealed class NewRoomKeyRequest
{
[JsonPropertyName("roomId")] public long RoomId { get; set; }
[JsonPropertyName("name")] public string? Name { get; set; }
[JsonPropertyName("description")] public string? Description { get; set; }
[JsonPropertyName("price")] public int Price { get; set; }
}
public sealed class UpdateRoomKeyRequest
{
[JsonPropertyName("roomKeyId")] public long RoomKeyId { get; set; }
[JsonPropertyName("name")] public string? Name { get; set; }
[JsonPropertyName("description")] public string? Description { get; set; }
[JsonPropertyName("price")] public int? Price { get; set; }
}
}
public enum RoomKeyStatus
{
Success = 0,
InvalidParameters = 1,
DoesNotExist = 2,
NameTooShort = 3,
NameTooLong = 4,
DuplicateName = 5,
InappropriateName = 6,
DescriptionTooShort = 7,
DescriptionTooLong = 8,
InappropriateDescription = 9,
PriceTooLow = 10,
PriceTooHigh = 11,
PermissionDenied = 12,
PlayerHasRoomUnderModerationReview = 13,
JuniorStatusFail = 14,
PlayerIsNotCoOwner = 15,
RoomKeyLimitReached = 16,
PlayerAlreadyOwns = 17,
RoomUnderModerationReview = 18,
PurchaseFailed = 19,
RoomDoesNotExist = 20,
PaidKeyPurchasingDisabled = 21,
CreateOrModifyKeysDisabled = 22,
RoomKeyUnderModerationReview = 23,
PlayerRestrictedFromP2PSelling = 24,
PlayerNotRecRoomPlusMember = 25,
}
@@ -0,0 +1,2468 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using DorkNet.Models.Notification;
using DorkNet.Server.Auth;
using DorkNet.Server.Controllers.Match;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.API.Rooms.V2;
/// <summary>
/// api.rec.net/api/rooms/* — DB-backed implementation of the watch's
/// Rooms tab.
///
/// Replaces the previous stubs in MiscStubsController for:
/// v1/hot, v1/filters, v1/tags, v1/search, v2/baserooms, v2/myrooms,
/// v2/mybookmarks, v2/mymoderated, v2/mysubscribed, v2/myrecent,
/// v2/name/{name}, v2/{id}, v3/{id}, v3/details/{id}, v4/details/{id},
/// roomserver/rooms/{id}, v1/personaldetails/{id}, v2/personaldetails/{id}.
///
/// All endpoints serialize Rooms via RoomService.ToWireRoom which matches
/// Room.Deserialize at RVA 0x114E430 (PascalCase keys, all 16 required
/// fields plus optional VR-low / mobile / mic-mute flags).
/// </summary>
[ApiController]
public class RoomsController(
RoomService rooms,
PlaylistService playlists,
DorkNetDbContext db,
PlayerPresenceService presence,
OnlinePresenceService onlinePresence,
DomainConfig domain,
NotificationService notifications,
ILogger<RoomsController> logger) : ControllerBase
{
private long? CurrentPlayerId => ControllerBaseExtensions.CurrentPlayerId(this);
// ── Browse / Hot / Search ────────────────────────────────────────────
/// <summary>
/// `Rooms.GetHotRooms(roomScoreType, tags)` — the watch's "Trending" tab.
/// `roomScoreType` is ignored (we always sort by HotScore). `tags` is
/// passed through as a substring filter against the comma-separated
/// TagsCsv column (e.g. `#community`, `#recroomoriginal`).
/// </summary>
[HttpGet("api/rooms/v1/hot")]
[HttpGet("rooms/hot")]
public async Task<IActionResult> Hot(
[FromQuery] string? roomScoreType,
[FromQuery] string? tags)
=> Ok((await rooms.HotAsync(tags)).Select(RoomService.ToWireRoom).ToList());
/// <summary>
/// GET <c>/roomserver/rooms/recommendations</c> — the home-tab
/// "Recommended" carousel. <b>Wire shape is NOT a flat
/// <c>List&lt;Room&gt;</c></b>; the watch's
/// <c>EJDCNGBEICB.JDKLBNBIEAH</c> at <c>EJDCNGBEICB.txt:1349,1430</c>
/// returns <c>IPromise&lt;List&lt;ANNKHNFLMNP&gt;&gt;</c> and
/// <see cref="ANNKHNFLMNP.PPGFHEDFBEA"/> at
/// <c>ANNKHNFLMNP.txt:43-97</c> reads <c>SeedRoom</c> (sub-object,
/// <c>KLCOGEIGEBJ</c> i.e. full Room) and <c>Rooms</c> (list of
/// Rooms) as required keys. Returning <c>[{room}, …]</c> raised
/// <c>KeyNotFoundException: Failed to find key 'Rooms' when
/// deserializing object of type KLCOGEIGEBJ</c>.
///
/// We group the hot list into one "card" per room — that gives the
/// watch one row per featured room with that room as its own
/// SeedRoom and a single-element Rooms list. Cheap, semantically
/// correct, and avoids the deserializer-crashing empty-Rooms
/// edge case the watch hits when a group has no rooms.
/// </summary>
[HttpGet("roomserver/rooms/recommendations")]
[HttpGet("rooms/recommendations")]
public async Task<IActionResult> Recommendations(
[FromQuery(Name = "splitTestId")] int? splitTestId,
[FromQuery(Name = "splitTestValue")] int? splitTestValue)
{
var hot = await rooms.HotAsync(null, take: 12);
if (hot.Count == 0) return Ok(Array.Empty<object>());
var roomIds = hot.Select(r => r.Id).ToList();
var sceneRows = await db.RoomScenes
.Where(s => roomIds.Contains(s.RoomId))
.OrderBy(s => s.OrderIndex)
.ToListAsync();
var scenesByRoom = sceneRows
.GroupBy(s => s.RoomId)
.ToDictionary(g => g.Key, g => (IReadOnlyList<RoomSceneEntity>)g.ToList());
var wireRooms = hot
.Select(r => BuildRoomServerDetails(r, scenesByRoom.GetValueOrDefault(r.Id)))
.ToList();
// One ANNKHNFLMNP entry per room: SeedRoom = the room itself,
// Rooms = [that room]. The watch enumerates the outer list to
// build the row list; each row shows its SeedRoom as headline.
var result = wireRooms.Select(room => new Dictionary<string, object>(StringComparer.Ordinal)
{
["SeedRoom"] = room,
["Rooms"] = new List<object> { room },
}).ToList();
return Ok(result);
}
/// <summary>
/// `Rooms.SearchForRooms(query)` — free-text room search.
/// </summary>
[HttpGet("api/rooms/v1/search")]
[HttpGet("api/rooms/v2/search")]
[HttpGet("rooms/search")]
public async Task<IActionResult> Search(
[FromQuery(Name = "query")] string? query,
[FromQuery(Name = "value")] string? value)
=> Ok((await rooms.SearchAsync(query ?? value ?? string.Empty))
.Select(RoomService.ToWireRoom).ToList());
/// <summary>GET <c>api/rooms/v2/live</c> — currently-active rooms
/// (rooms with at least one player presence). Same shape as Hot
/// — order by HotScore as a proxy for "live activity" since we
/// don't track instance population separately yet.</summary>
[HttpGet("api/rooms/v2/live")]
public async Task<IActionResult> Live([FromQuery] string? roomScoreType)
=> Ok((await rooms.HotAsync(null)).Select(RoomService.ToWireRoom).ToList());
/// <summary>
/// `Rooms.GetBaseRooms` — list of "base" rooms used by Rec Room Originals
/// to seed the room creation UI. Returns the same set as #recroomoriginal.
/// </summary>
[HttpGet("api/rooms/v2/baserooms")]
[HttpGet("api/rooms/v3/baserooms")]
[HttpGet("rooms/base")]
public async Task<IActionResult> BaseRooms()
=> Ok((await rooms.HotAsync("#recroomoriginal")).Select(RoomService.ToWireRoom).ToList());
/// <summary>
/// `Rooms.GetFilters` — pinned/popular tag chips shown above the room
/// list in the watch.
/// </summary>
// Tag wire format: NO leading '#'. The watch's RoomFilterChip prepends
// '#' at render time, so sending "#community" produces "##community" in
// the UI. Names stored in the DB also drop the prefix.
[HttpGet("api/rooms/v1/filters")]
public IActionResult Filters() => Ok(new
{
PinnedFilters = new[] { "community", "recroomoriginal", "featured", "quest" },
PopularFilters = new[] { "paintball", "dodgeball", "soccer", "lasertag", "discgolf" },
});
[HttpGet("api/rooms/v1/tags")]
public IActionResult Tags() => Ok(new[] {
"community", "recroomoriginal", "featured", "quest",
"paintball", "dodgeball", "soccer", "lasertag", "discgolf",
});
[HttpGet("api/rooms/v1/pinnedtags")]
public IActionResult PinnedTags() => Ok(new[] { "community", "recroomoriginal", "featured" });
[HttpGet("api/rooms/v1/populartags")]
public IActionResult PopularTags() => Ok(new[] { "paintball", "dodgeball", "soccer" });
// ── Single room lookups ──────────────────────────────────────────────
/// <summary>
/// `Rooms.GetByName(roomName)` — used when the client constructs the
/// share-link target or when /goto/room/{name} runs. Falls back to a
/// synthesised "Room_<name>" for unknown names so the deserializer
/// doesn't crash on 404.
/// </summary>
[HttpGet("api/rooms/v2/name/{roomName}")]
[HttpGet("api/rooms/v3/name/{roomName}")]
public async Task<IActionResult> ByName(string roomName)
{
var r = await rooms.GetByNameAsync(roomName);
return Ok(RoomService.ToWireRoom(r ?? Synthetic(roomName)));
}
/// <summary>
/// `Rooms.GetById(roomId)` — same shape as ByName.
/// </summary>
[HttpGet("api/rooms/v2/{roomId:long}")]
[HttpGet("api/rooms/v3/{roomId:long}")]
[HttpGet("rooms/{roomId:long}")]
public async Task<IActionResult> ById(long roomId)
{
var r = await rooms.GetByIdAsync(roomId);
return Ok(RoomService.ToWireRoom(r ?? Synthetic($"Room_{roomId}", roomId)));
}
// ── RoomDetails (the boot-critical one) ─────────────────────────────
//
// /v4/details/{roomId} is hit *during* the matchmaking goto flow.
// RoomDetails.Deserialize at RVA 0x114C580 expects PascalCase keys:
// Room (object), Scenes (list), CoOwners/InvitedCoOwners,
// Moderators/InvitedModerators, Hosts/InvitedHosts (all int lists),
// CheerCount, FavoriteCount, VisitCount, Tags.
//
// Scenes is populated for every room. The persistence coroutine
// (`RoomPersistenceManager+<DownloadRoomDataBlobCoroutine>`) runs
// unconditionally on scene load and dereferences
// `Rooms.LocalRoomScene.DataBlobName` on its first MoveNext — so
// returning Scenes=[] makes LocalRoomScene null and NPEs the boot.
//
// The historical "Scenes=[] short-circuits the persistence manager"
// comment that used to live here was the reverse of what actually
// happens in this build. Confirmed empirically with the [req] log:
// /v4/details/1 fires before scene load for the dorm too, so
// Rooms.LocalRoom is populated and the coroutine just needs a
// populated Scenes[0] to get a non-null LocalRoomScene.
/// <summary>GET <c>roomserver/rooms/{roomId}/subrooms/{subRoomId}/datahistory</c>
/// — drives the watch's "Restore to old version" UI in the room-
/// settings tab. Per-subroom (not per-room) — the watch builds the
/// URL from the template
/// <c>String.Format("rooms/{0}/subrooms/{1}/datahistory", roomId, subRoomId)</c>
/// (verified at <c>EJDCNGBEICB.txt:1190</c>); the <c>roomserver/</c>
/// prefix is added by RecNet's URL builder for everything on the
/// rooms host (api.localhost proxies it through the same prefix as the
/// rest of the rooms API — see all the other <c>roomserver/rooms/...</c>
/// routes in this controller).
///
/// <para><b>SubRoomId semantics:</b> the watch's 2020.12 build treats
/// "SubRoomId" as the <see cref="RoomSceneEntity.OrderIndex"/>, NOT
/// rec.net's long random Guid-like SubRoomId. The heartbeat reports
/// <c>subRoom=11</c> for the 12th scene in <c>RoomDetails.SubRooms[]</c>;
/// this endpoint must filter by OrderIndex to match. The zip-importer
/// stamps each <c>SubRooms/&lt;scene&gt;/History/</c> blob's
/// <see cref="RoomDataBlobEntity.SubRoomId"/> with the scene's
/// OrderIndex at insert time — the rec.net SubRoomId is captured in
/// the sidecar JSON for provenance but isn't what gets indexed.</para>
///
/// <para>Wire shape: <c>List&lt;RoomDataHistoryDTO&gt;</c>. The
/// watch's <c>DHOCPFIOKHD.PPGFHEDFBEA</c> deserializer reads EXACTLY
/// four PascalCase keys (<c>DHOCPFIOKHD.txt:136-150</c>):
/// <c>SubRoomId</c> (long, strict GetKey), <c>DataBlob</c> (string),
/// <c>SavedByAccountId</c> (long, strict GetKey), <c>CreatedAt</c>
/// (DateTime, GetKeyOrDefault). Missing strict keys throw
/// <c>KeyNotFoundException</c> in LitJson and the watch shows the
/// "Could not retrieve save history from server :-(" toast from
/// <c>UIRestoreRoomDialog.txt:1970</c>. Extra keys are ignored.</para>
///
/// <para>Pre-2026-05 rows (and any rows the live save flow inserts,
/// which doesn't populate SubRoomId yet) have a null SubRoomId and
/// surface under every sub-room's history list — back-compat.</para></summary>
[HttpGet("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/datahistory")]
[HttpGet("rooms/{roomId:long}/subrooms/{subRoomId:long}/datahistory")]
public async Task<IActionResult> SubRoomDataHistory(long roomId, long subRoomId)
{
var blobs = await db.RoomDataBlobs
.Where(b => b.RoomId == roomId
&& (b.SubRoomId == subRoomId || b.SubRoomId == null))
// Skip the canonical per-scene blob from the import — the watch
// is asking for HISTORY, and serving the current `room_<id>_<slug>_<scene>.room`
// back to it as an option would make `restore to current` a
// confusing no-op entry in the list.
.Where(b => !b.BlobName.StartsWith($"room_{roomId}_"))
.OrderByDescending(b => b.UploadedAt)
.Take(50)
.Select(b => new
{
SubRoomId = subRoomId,
DataBlob = b.BlobName,
SavedByAccountId = b.UploadedByPlayerId,
CreatedAt = b.UploadedAt,
})
.ToListAsync();
return Ok(blobs);
}
/// <summary>POST <c>roomserver/rooms/{roomId}/subrooms/{subRoomId}/restoredata</c>
/// — the watch's "Go" button in the Restore Room dialog. Body is
/// form-urlencoded with a single <c>filename</c> field carrying the
/// historical blob name from the datahistory list. <paramref name="subRoomId"/>
/// is RoomSceneEntity.OrderIndex (same convention as the GET above).
///
/// Effect: stamp the chosen scene's DataBlobName to the historical
/// blob and, when restoring the room's current entry scene, also
/// update the room-level CurrentDataBlobName so a fresh /v4/details
/// returns the restored bytes too. Then push SubscriptionUpdateRoom
/// so any clients already cached on the room reload the scene.
///
/// Returns CreateModifyRoomSceneResponse — same shape as
/// /api/rooms/v1/datahistory/restore — so the watch's
/// ProcessCreateModifyRoomSceneResponse chain can deserialise and
/// flip the Restore dialog out of its "applying" state.</summary>
[HttpPost("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/restoredata")]
[HttpPost("rooms/{roomId:long}/subrooms/{subRoomId:long}/restoredata")]
[Authorize]
public async Task<IActionResult> SubRoomRestoreData(
long roomId, long subRoomId, [FromForm] string? filename)
{
if (string.IsNullOrWhiteSpace(filename))
return BadRequest(new { error = "missing_filename" });
var pid = this.RequireCurrentPlayerId();
var room = await rooms.GetByIdAsync(roomId);
if (room is null) return NotFound(new { error = "room_not_found" });
if (room.CreatorPlayerId != pid) return Forbid();
var blob = await db.RoomDataBlobs
.FirstOrDefaultAsync(b => b.BlobName == filename && b.RoomId == roomId);
if (blob is null) return NotFound(new { error = "blob_not_found", filename });
var sceneRows = await db.RoomScenes
.Where(s => s.RoomId == roomId)
.OrderBy(s => s.OrderIndex)
.ToListAsync();
var sceneRow = sceneRows.FirstOrDefault(s => s.OrderIndex == (int)subRoomId);
if (sceneRow is null) return NotFound(new { error = "subroom_not_found", subRoomId });
sceneRow.DataBlobName = filename;
sceneRow.DataModifiedAt = DateTime.UtcNow;
// Only stamp the room-level CurrentDataBlobName when restoring the
// entry scene (OrderIndex 0). Restoring a non-entry sub-room
// shouldn't rewrite what the room loads at the front door.
if (sceneRow.OrderIndex == 0)
{
await db.Rooms
.Where(r => r.Id == roomId)
.ExecuteUpdateAsync(s => s
.SetProperty(r => r.CurrentDataBlobName, filename)
.SetProperty(r => r.UpdatedAt, DateTime.UtcNow));
}
await db.SaveChangesAsync();
var pushRoom = room.IsDormRoom
? CloneWithCreator(room, pid, room.CurrentDataBlobName)
: CloneWithCreator(room, room.CreatorPlayerId, room.CurrentDataBlobName);
var roomDetailsPayload = BuildRoomDetails(pushRoom, sceneRows, sceneRow.OrderIndex, filename);
await notifications.NotifyAsync(pid, PushNotificationId.SubscriptionUpdateRoom, roomDetailsPayload);
logger.LogInformation(
"[rooms-restore] player={PlayerId} room={RoomId} subRoom={SubRoomId} scene={Scene} blob={Blob}",
pid, roomId, subRoomId, sceneRow.Name, filename);
// Watch's response deserializer is PPENFJMFPNE — the full Room DTO
// (verified at EJDCNGBEICB:6029 in the 2020.12.18 dump). Returning
// a bare RoomScene wrapper makes its dict-based decoder throw
// KeyNotFoundException ("Failed to restore subroom save: Malformed
// Response"). Mirror the /roomserver/rooms/{id} shape — same
// payload BuildRoomDetails generates for /v4/details.
return Ok(roomDetailsPayload);
}
/// <summary>POST api/rooms/v4/saveData — the watch's
/// <c>RecNet.Rooms.UploadLocalRoomSceneData</c> save flow.
/// Body shape mirrors <c>RecNet.Rooms+SaveRoomSceneRequest</c>
/// (Cpp2IL_CS RecNet/Rooms.cs:858): RoomSceneId, RoomDataFilename,
/// InventionUsages[], CreatorActionContext, RequestPlayerId.
///
/// Persists the new <c>CurrentDataBlobName</c> on the room
/// (and, for dorms, the per-player DormStateEntity) so the next
/// /v4/details + cdn fetch loads what just got uploaded. Returns
/// a populated <c>RoomScene</c> JSON — that's what the watch's
/// continuation callback (b__1 in DisplayClass124_0) expects;
/// returning an empty body trips a "HTTP Error 404" path in
/// BestHTTP's Future framework even though the HTTP status was
/// fine.</summary>
[HttpPost("api/rooms/v4/saveData")]
[Authorize]
public async Task<IActionResult> SaveData([FromBody] SaveRoomSceneRequest body)
{
var pid = this.RequireCurrentPlayerId();
// Resolve the room. The watch sends RoomSceneId (the scene's
// OrderIndex per BuildRoomDetails above — 0 for single-scene
// rooms). To find the parent room we cross-reference the
// player's current presence (which RecordResponseAsync stamps
// on every /goto). This is the only signal we have without a
// RoomId on the request body.
var current = presence.GetRoom(pid);
if (current is null)
{
// No active room — without it we can't pick a target row.
// Return a degenerate RoomScene so the deserializer doesn't
// throw, but flag CanMatchmakeInto=false so the watch
// doesn't think the save succeeded.
return Ok(new
{
RoomSceneId = body.RoomSceneId,
RoomId = 0L,
RoomSceneLocationId = "76d98498-60a1-430c-ab76-b54a29b7a163",
Name = "Home",
IsSandbox = false,
DataBlobName = body.RoomDataFilename ?? string.Empty,
MaxPlayers = 8,
CanMatchmakeInto = false,
DataModifiedAt = DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ssZ"),
});
}
var roomId = current.RoomId;
var room = await rooms.GetByIdAsync(roomId);
if (room is null) return NotFound(new { error = "room_not_found" });
var newBlob = body.RoomDataFilename?.Trim() ?? string.Empty;
// Multi-scene rooms have a RoomScenes row per scene. The 2020
// client usually sends the scene's OrderIndex, but some flows
// send a stale/non-index id while the local room is still a
// single-scene dorm/custom room. In that common single-scene
// case, stamp the only row anyway; otherwise the pushed
// RoomDetails can keep Scenes[0].DataBlobName at v(N) while
// Rooms.CurrentDataBlobName is v(N+1), and the post-save reload
// reads stale bytes.
var sceneRowsForRoom = await db.RoomScenes
.Where(s => s.RoomId == roomId)
.OrderBy(s => s.OrderIndex)
.ToListAsync();
var sceneRow = sceneRowsForRoom.FirstOrDefault(s => s.OrderIndex == (int)body.RoomSceneId)
?? (sceneRowsForRoom.Count == 1 ? sceneRowsForRoom[0] : null);
// Dorms: per-player state, keyed by the calling player so two
// people in the same dorm room ID save into separate slots.
if (room.IsDormRoom)
{
if (room.CreatorPlayerId != pid) return Forbid();
var dormState = await db.DormStates.FirstOrDefaultAsync(d => d.PlayerId == pid);
if (dormState is null)
{
dormState = new DormStateEntity { PlayerId = pid };
db.DormStates.Add(dormState);
}
dormState.CurrentDataBlobName = newBlob;
dormState.UpdatedAt = DateTime.UtcNow;
}
else
{
// Owned rooms: only the creator can save. Admin override
// intentionally not included — admins should use /restore
// when they need to roll a room back.
if (room.CreatorPlayerId != pid) return Forbid();
}
if (sceneRow is not null)
{
sceneRow.DataBlobName = newBlob;
sceneRow.DataModifiedAt = DateTime.UtcNow;
}
await db.Rooms
.Where(r => r.Id == roomId)
.ExecuteUpdateAsync(s => s
.SetProperty(r => r.CurrentDataBlobName, newBlob)
.SetProperty(r => r.UpdatedAt, DateTime.UtcNow));
await db.SaveChangesAsync();
// The watch's MasterUploadRoomDataBlobCoroutine waits for BOTH
// the /upload + /saveData responses AND a SubscriptionUpdateRoom
// SignalR push before flipping the save state machine out of
// SAVING. Without this push the spinner stays "Saving..." forever
// even though both HTTP calls returned 200 — verified via the
// RoomDetailsUpdatedCallback closure in
// RoomPersistenceManager+<>c__DisplayClass174_0.
//
// For dorms we re-shape with the caller as creator + their own
// DormStateEntity blob name so the watch's set_LocalRoomDetails
// matches LocalRoomDetails.RoomId and triggers
// SafeRaiseRoomDetailsUpdated(roomId).
var pushRoom = room;
if (room.IsDormRoom)
pushRoom = CloneWithCreator(room, pid, newBlob);
else
pushRoom = CloneWithCreator(room, room.CreatorPlayerId, newBlob);
foreach (var scene in sceneRowsForRoom)
{
if (sceneRow is not null && scene.Id == sceneRow.Id)
{
scene.DataBlobName = newBlob;
scene.DataModifiedAt = DateTime.UtcNow;
}
}
var savedSceneId = sceneRow?.OrderIndex ?? body.RoomSceneId;
var roomDetailsPayload = BuildRoomDetails(pushRoom, sceneRowsForRoom, savedSceneId, newBlob);
logger.LogInformation(
"[rooms-save] saveData player={PlayerId} room={RoomId} requestedScene={RequestedSceneId} savedScene={SavedSceneId} blob={BlobName} sceneRow={SceneRowFound} scenes={SceneCount}",
pid, roomId, body.RoomSceneId, savedSceneId, newBlob, sceneRow is not null, sceneRowsForRoom.Count);
var playersInInstance = onlinePresence.OnlinePlayerIds()
.Where(playerId =>
{
var playerRoom = presence.GetRoom(playerId);
return playerRoom is not null
&& playerRoom.RoomId == roomId
&& playerRoom.RoomInstanceId == current.RoomInstanceId;
})
.Append(pid)
.Distinct()
.ToArray();
logger.LogInformation(
"[rooms-save] fanout room update player={PlayerId} room={RoomId} instance={InstanceId} blob={BlobName} recipients={RecipientCount}",
pid, roomId, current.RoomInstanceId, newBlob, playersInInstance.Length);
var updatedRoomInstances = new Dictionary<long, RoomInstanceDto>();
foreach (var playerId in playersInInstance)
{
var currentPresence = presence.GetRoom(playerId);
if (currentPresence is null && playerId == pid)
{
currentPresence = current;
}
if (currentPresence is null) continue;
currentPresence.DataBlob = newBlob;
presence.SetRoom(playerId, currentPresence);
updatedRoomInstances[playerId] = currentPresence;
}
// SubscriptionUpdateRoom delivers the new RoomDetails (with
// Scenes[0].DataBlobName = newBlob). Rooms.OnSubscriptionUpdateRoom
// → StoreRoomDetailsInCache → set_LocalRoomDetails iterates
// Scenes for one matching RoomSceneId == LocalSubRoomId and writes
// LocalRoomScene to that scene, which is what DownloadRoomData
// BlobCoroutine reads when PostSaveReloading runs.
foreach (var playerId in playersInInstance)
{
await notifications.NotifyAsync(playerId, PushNotificationId.SubscriptionUpdateRoom, roomDetailsPayload);
}
// RoomInstanceUpdate keeps the matchmaking-side dataBlob and any
// friends-list "in room X" indicators in sync. No nudge — live
// doesn't send one, and the fake sub-room id confuses the watch's
// OnLocalGameSessionUpdated scene re-resolve.
foreach (var (playerId, currentPresence) in updatedRoomInstances)
{
await notifications.NotifyAsync(
playerId,
PushNotificationId.SubscriptionUpdateGameSession,
currentPresence);
}
// PresenceUpdate keeps the next heartbeat aligned with the blob
// applied through the room-details push above.
foreach (var (playerId, currentPresence) in updatedRoomInstances)
{
var presencePayload = new
{
playerId = (int)playerId,
statusVisibility = 1,
deviceClass = 0,
vrMovementMode = 0,
roomInstance = new
{
roomInstanceId = currentPresence.RoomInstanceId,
roomId = currentPresence.RoomId,
subRoomId = currentPresence.SubRoomId,
roomInstanceType = currentPresence.RoomInstanceType,
location = currentPresence.Location,
dataBlob = currentPresence.DataBlob,
eventId = currentPresence.EventId,
clubId = currentPresence.ClubId,
roomCode = currentPresence.RoomCode,
photonRegionId = currentPresence.PhotonRegionId,
photonRoomId = currentPresence.PhotonRoomId,
name = currentPresence.Name,
maxCapacity = currentPresence.MaxCapacity,
isFull = currentPresence.IsFull,
isPrivate = currentPresence.IsPrivate,
isInProgress = currentPresence.IsInProgress,
EncryptVoiceChat = currentPresence.EncryptVoiceChat,
},
isOnline = true,
appVersion = 20201210,
};
await notifications.NotifyAsync(playerId, PushNotificationId.SubscriptionUpdatePresence, presencePayload);
}
return Ok(new
{
RoomSceneId = savedSceneId,
RoomId = roomId,
RoomSceneLocationId = sceneRow?.RoomSceneLocationId ?? room.LocationReplicationId,
Name = sceneRow?.Name ?? "Home",
IsSandbox = sceneRow?.IsSandbox ?? false,
DataBlobName = newBlob,
MaxPlayers = sceneRow?.MaxPlayers ?? 8,
CanMatchmakeInto = sceneRow?.CanMatchmakeInto ?? true,
DataModifiedAt = DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ssZ"),
});
}
public class SaveRoomSceneRequest
{
public long RoomSceneId { get; set; }
public string? RoomDataFilename { get; set; }
public List<long>? InventionUsages { get; set; }
public CreatorActionContextDto? CreatorActionContext { get; set; }
public long RequestPlayerId { get; set; }
}
public class CreatorActionContextDto
{
public bool IsTeachableMomentRunning { get; set; }
}
/// <summary>POST api/rooms/v1/restore — switch a room back to a
/// previous save. Body: roomId, blobName (or version id). Updates
/// the player's DormStateEntity (for dorms) or RoomEntity
/// CurrentDataBlobName (for owned rooms) so the next visit loads
/// the historical bytes.</summary>
/// <summary>POST <c>api/rooms/v1/datahistory/restore</c> — restore a
/// room to a previous save.
///
/// Request body: <c>RestoreDataHistoryRequest</c> serialized by
/// Unity's <c>JsonUtility.ToJson</c>, which uses C# field names.
/// Verified at <c>Cpp2IL_ISIL/.../Rooms.txt:13513-14</c> — the only
/// field on the request type is the long history id at offset +16,
/// and Unity's serializer names it <c>roomDataHistoryId</c>. We
/// look up <see cref="RoomDataBlobEntity"/> by that id, find its
/// parent room, and rewrite <c>CurrentDataBlobName</c> (and the
/// dorm-state row for personal dorms) so the next room reload
/// downloads the historical bytes.
///
/// Response: <c>CreateModifyRoomSceneResponse</c> — keys are
/// PascalCase per <c>Cpp2IL_ISIL/.../Rooms_NestedType_CreateModifyRoomSceneResponse.txt</c>:
/// <c>Result</c> (int, CreateModifyRoomStatus: 0=Success), and
/// <c>RoomScene</c> (the restored scene). The watch's
/// <c>ProcessCreateModifyRoomSceneResponse</c> chain expects both;
/// returning a bare <c>{restored: "..."}</c> like the old impl makes
/// the watch's deserializer throw and the restore silently fails.
/// The legacy form-urlencoded path stays under
/// <c>/api/rooms/v1/restore</c> for the admin SPA's restore UI.
/// </summary>
public sealed class RestoreDataHistoryRequest
{
// Unity JsonUtility serializes C# private backing fields by
// their declared name. The watch's request type has a single
// field which Unity emits as "roomDataHistoryId" (camelCase) —
// [JsonPropertyName] here matches that wire key explicitly so
// System.Text.Json picks it up regardless of any future global
// naming policy.
[System.Text.Json.Serialization.JsonPropertyName("roomDataHistoryId")]
public long RoomDataHistoryId { get; set; }
}
[HttpPost("api/rooms/v1/datahistory/restore")]
[Authorize]
public async Task<IActionResult> RestoreDataHistory([FromBody] RestoreDataHistoryRequest body)
{
var pid = this.RequireCurrentPlayerId();
var blob = await db.RoomDataBlobs.FirstOrDefaultAsync(b => b.Id == body.RoomDataHistoryId);
if (blob is null) return NotFound(new { error = "version_not_found" });
var room = await rooms.GetByIdAsync(blob.RoomId);
if (room is null) return NotFound(new { error = "room_not_found" });
// Ownership: only the dorm owner / room creator can restore.
// Admin override is intentionally NOT included here — restore
// is destructive (overrides the player's chosen state) so we
// require the actual creator.
if (room.CreatorPlayerId != pid)
return Forbid();
if (room.IsDormRoom)
{
var dormState = await db.DormStates.FirstOrDefaultAsync(d => d.PlayerId == pid);
if (dormState is null)
{
dormState = new DormStateEntity { PlayerId = pid };
db.DormStates.Add(dormState);
}
dormState.CurrentDataBlobName = blob.BlobName;
dormState.UpdatedAt = DateTime.UtcNow;
}
await db.Rooms
.Where(r => r.Id == room.Id)
.ExecuteUpdateAsync(s => s
.SetProperty(r => r.CurrentDataBlobName, blob.BlobName)
.SetProperty(r => r.UpdatedAt, DateTime.UtcNow));
await db.SaveChangesAsync();
// Push the same SubscriptionUpdateRoom the save flow sends so
// the watch's StoreRoomDetailsInCache → SafeRaiseRoomDetailsUpdated
// chain fires and any open room-detail panels refresh.
var pushRoom = room.IsDormRoom ? CloneWithCreator(room, pid, blob.BlobName) : CloneWithCreator(room, room.CreatorPlayerId, blob.BlobName);
var allScenes = await db.RoomScenes
.Where(s => s.RoomId == room.Id)
.OrderBy(s => s.OrderIndex)
.ToListAsync();
var roomDetailsPayload = BuildRoomDetails(pushRoom, allScenes, 0L, blob.BlobName);
await notifications.NotifyAsync(pid, PushNotificationId.SubscriptionUpdateRoom, roomDetailsPayload);
// CreateModifyRoomSceneResponse shape. RoomScene fields match
// RecNet.RoomScene.Deserialize (PascalCase, RoomSceneId,
// RoomId, RoomSceneLocationId, Name, IsSandbox, DataBlobName,
// MaxPlayers, CanMatchmakeInto, DataModifiedAt).
return Ok(new
{
Result = 0, // CreateModifyRoomStatus.Success
RoomScene = new
{
RoomSceneId = 0L,
RoomId = room.Id,
RoomSceneLocationId = room.LocationReplicationId,
Name = room.Name,
IsSandbox = false,
DataBlobName = blob.BlobName,
MaxPlayers = 8,
CanMatchmakeInto = true,
DataModifiedAt = DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ssZ"),
},
});
}
/// <summary>Legacy form-urlencoded restore — kept for the admin SPA's
/// "Restore version" picker which posts {roomId, blobName} fields.
/// New callers (the watch) use the JSON variant above.</summary>
[HttpPost("api/rooms/v1/restore")]
[Authorize]
public async Task<IActionResult> RestoreVersionLegacy(
[FromForm] long roomId,
[FromForm] string? blobName,
[FromForm] long version = 0)
{
var pid = this.RequireCurrentPlayerId();
var room = await rooms.GetByIdAsync(roomId);
if (room is null) return NotFound(new { error = "room_not_found" });
var blob = !string.IsNullOrEmpty(blobName)
? await db.RoomDataBlobs.FirstOrDefaultAsync(b => b.BlobName == blobName && b.RoomId == roomId)
: await db.RoomDataBlobs.FirstOrDefaultAsync(b => b.Id == version && b.RoomId == roomId);
if (blob is null) return NotFound(new { error = "version_not_found" });
if (room.CreatorPlayerId != pid)
return Forbid();
if (room.IsDormRoom)
{
var dormState = await db.DormStates.FirstOrDefaultAsync(d => d.PlayerId == pid);
if (dormState is null)
{
dormState = new DormStateEntity { PlayerId = pid };
db.DormStates.Add(dormState);
}
dormState.CurrentDataBlobName = blob.BlobName;
dormState.UpdatedAt = DateTime.UtcNow;
}
await db.Rooms
.Where(r => r.Id == roomId)
.ExecuteUpdateAsync(s => s
.SetProperty(r => r.CurrentDataBlobName, blob.BlobName)
.SetProperty(r => r.UpdatedAt, DateTime.UtcNow));
await db.SaveChangesAsync();
return Ok(new { restored = blob.BlobName });
}
/// <summary>GET <c>api/rooms/v{1,2}/personaldetails/{roomId}</c> —
/// per-caller bookmark/cheer flags for one room. Wire shape verified
/// against <c>RecNet.PersonalRoomDetails.Deserialize</c>
/// (<c>Cpp2IL_ISIL/.../PersonalRoomDetails.txt:85-91</c>):
/// <c>IsCheering</c> - bool, REQUIRED (Util.GetKey, throws if missing)
/// <c>IsBookmarked</c> - bool, REQUIRED
/// Both keys are PascalCase. Returning the full room-details payload
/// here (which is what we used to do) makes the deserialiser throw
/// KeyNotFoundException for IsCheering, and that blocks the entire
/// /goto/* flow because RecNet.Rooms.GetPersonalRoomDetails is
/// awaited before SessionManager.JoinRoom can run.</summary>
[HttpGet("api/rooms/v2/personaldetails/{roomId:long}")]
[HttpGet("api/rooms/v1/personaldetails/{roomId:long}")]
public async Task<IActionResult> PersonalDetails(long roomId)
{
var pid = CurrentPlayerId ?? 0;
bool cheering = false, bookmarked = false;
if (pid > 0)
{
cheering = await db.Cheers.AnyAsync(c => c.TargetRoomId == roomId && c.FromPlayerId == pid);
bookmarked = await db.RoomBookmarks.AnyAsync(b => b.RoomId == roomId && b.PlayerId == pid);
}
return Ok(new { IsCheering = cheering, IsBookmarked = bookmarked });
}
/// <summary>
/// GET <c>/roomserver/rooms/{id}/interactionby/me</c> — sibling of
/// personaldetails but DIFFERENT wire shape. Per
/// <c>EJDCNGBEICB.txt:8219,8266</c> the deserialiser is
/// <c>CJODCLDGFCF</c>, which reads <c>Cheered</c> + <c>Favorited</c>
/// (PascalCase, both bool, both required — verified at
/// <c>CJODCLDGFCF.txt:118,123</c>). Sharing the
/// <c>{IsCheering, IsBookmarked}</c> payload with personaldetails
/// throws <c>KeyNotFoundException</c> on the watch and stalls the
/// room-tile UI.
/// </summary>
[HttpGet("roomserver/rooms/{roomId:long}/interactionby/me")]
[HttpGet("rooms/{roomId:long}/interactionby/me")]
public async Task<IActionResult> RoomInteractionByMe(long roomId)
{
var pid = CurrentPlayerId ?? 0;
bool cheered = false, favorited = false;
if (pid > 0)
{
cheered = await db.Cheers.AnyAsync(c => c.TargetRoomId == roomId && c.FromPlayerId == pid);
favorited = await db.RoomBookmarks.AnyAsync(b => b.RoomId == roomId && b.PlayerId == pid);
}
return Ok(new { Cheered = cheered, Favorited = favorited });
}
/// <summary>
/// GET <c>/roomserver/playlists/{id}/interactionby/me</c> — same
/// <c>CJODCLDGFCF</c> wire shape as the room variant. Per
/// <c>EJDCNGBEICB.txt:8600</c> the URL is
/// <c>"playlists/{0}/interactionby/me"</c>. Reads from
/// <see cref="PlaylistInteractionEntity"/>; an anonymous caller
/// (no JWT) reads as (false, false) so the watch's pre-login
/// browse renders cleanly.
/// </summary>
[HttpGet("roomserver/playlists/{playlistId:long}/interactionby/me")]
[HttpGet("playlists/{playlistId:long}/interactionby/me")]
public async Task<IActionResult> PlaylistInteractionByMe(long playlistId)
{
var pid = CurrentPlayerId ?? 0;
if (pid <= 0) return Ok(new { Cheered = false, Favorited = false });
var (cheered, favorited) = await playlists.InteractionForAsync(playlistId, pid);
return Ok(new { Cheered = cheered, Favorited = favorited });
}
[HttpGet("api/rooms/v4/details/{roomId:long}")]
[HttpGet("api/rooms/v3/details/{roomId:long}")]
[HttpGet("roomserver/rooms/{roomId:long}")]
public async Task<IActionResult> Details(long roomId)
{
var r = await rooms.GetByIdAsync(roomId);
var room = r ?? Synthetic($"Room_{roomId}", roomId);
// Dorm-specific shaping: each player has their own first-class
// dorm row. Only the dorm's actual creator should see Creator
// role / Maker Pen. Guests loading someone else's dorm must see
// the owner as CreatorPlayerId, while still receiving the
// owner's DormStateEntity blob so they load the same room state.
if (room.IsDormRoom)
{
var dormBlobName = await db.DormStates
.Where(d => d.PlayerId == room.CreatorPlayerId)
.Select(d => d.CurrentDataBlobName)
.FirstOrDefaultAsync()
?? string.Empty;
room = CloneWithCreator(room, room.CreatorPlayerId, dormBlobName);
}
var scenes = await db.RoomScenes
.Where(s => s.RoomId == room.Id)
.OrderBy(s => s.OrderIndex)
.ToListAsync();
var roomRoles = await db.RoomRoles
.Where(r => r.RoomId == room.Id)
.ToListAsync();
if (Request.Path.StartsWithSegments("/roomserver", StringComparison.OrdinalIgnoreCase))
return Ok(BuildRoomServerDetails(room, scenes, roles: roomRoles));
return Ok(BuildRoomDetails(room, scenes, roles: roomRoles));
}
[HttpGet("roomserver/rooms")]
[HttpGet("rooms")]
public async Task<IActionResult> RoomServerDetailsByName([FromQuery(Name = "name")] string? roomName)
{
if (string.IsNullOrWhiteSpace(roomName))
return Ok(Array.Empty<object>());
var pid = CurrentPlayerId ?? 0;
RoomEntity? room;
if (roomName.Equals("DormRoom", StringComparison.OrdinalIgnoreCase) && pid > 0)
{
room = await rooms.EnsurePersonalDormAsync(pid);
}
else
{
room = await rooms.GetByNameAsync(roomName);
}
room ??= Synthetic(roomName);
if (room.IsDormRoom)
{
var dormBlobName = await db.DormStates
.Where(d => d.PlayerId == room.CreatorPlayerId)
.Select(d => d.CurrentDataBlobName)
.FirstOrDefaultAsync()
?? string.Empty;
room = CloneWithCreator(room, room.CreatorPlayerId, dormBlobName);
}
var scenes = await db.RoomScenes
.Where(s => s.RoomId == room.Id)
.OrderBy(s => s.OrderIndex)
.ToListAsync();
var roomRoles = await db.RoomRoles
.Where(r => r.RoomId == room.Id)
.ToListAsync();
return Ok(BuildRoomServerDetails(room, scenes, roles: roomRoles));
}
/// <summary>GET <c>/roomserver/rooms/bulk?name=X&amp;name=Y</c> — bulk
/// lookup by room name. Used by EJDCNGBEICB's room cache to resolve
/// well-known names (RecCenter, etc.) to their RoomServerDetails
/// shape in one round-trip. Returns the same per-room object that
/// <c>/roomserver/rooms/{id}</c> emits, one per requested name; names
/// we can't resolve are silently skipped (watch handles a shorter
/// list fine, but a 404 wedges the room browser).</summary>
[HttpGet("roomserver/rooms/bulk")]
[HttpGet("rooms/bulk")]
public async Task<IActionResult> RoomServerBulkByName()
{
var names = Request.Query["name"].Where(n => !string.IsNullOrWhiteSpace(n)).ToArray();
if (names.Length == 0) return Ok(Array.Empty<object>());
var pid = CurrentPlayerId ?? 0;
var results = new List<object>(names.Length);
foreach (var raw in names)
{
var name = raw!.Trim();
RoomEntity? room;
if (name.Equals("DormRoom", StringComparison.OrdinalIgnoreCase) && pid > 0)
room = await rooms.EnsurePersonalDormAsync(pid);
else
room = await rooms.GetByNameAsync(name);
room ??= Synthetic(name);
if (room.IsDormRoom)
{
var dormBlobName = await db.DormStates
.Where(d => d.PlayerId == room.CreatorPlayerId)
.Select(d => d.CurrentDataBlobName)
.FirstOrDefaultAsync()
?? string.Empty;
room = CloneWithCreator(room, room.CreatorPlayerId, dormBlobName);
}
var scenes = await db.RoomScenes
.Where(s => s.RoomId == room.Id)
.OrderBy(s => s.OrderIndex)
.ToListAsync();
var roomRoles = await db.RoomRoles
.Where(r => r.RoomId == room.Id)
.ToListAsync();
results.Add(BuildRoomServerDetails(room, scenes, roles: roomRoles));
}
return Ok(results);
}
/// <summary>
/// Shallow clone of a RoomEntity with a different CreatorPlayerId
/// and (optionally) a different CurrentDataBlobName. Used to give
/// the per-caller dorm the appearance of being authored by the
/// caller and pointing at the caller's own most recent save.
/// Doesn't touch the DB — purely a response-shaping utility.
/// </summary>
public static RoomEntity CloneWithCreator(
RoomEntity src, long creatorId, string? overrideBlobName = null) => new()
{
Id = src.Id,
Name = src.Name,
Description = src.Description,
CreatorPlayerId = creatorId,
ImageName = src.ImageName,
State = src.State,
Accessibility = src.Accessibility,
SupportsLevelVoting = src.SupportsLevelVoting,
IsAGRoom = src.IsAGRoom,
IsDormRoom = src.IsDormRoom,
CloningAllowed = src.CloningAllowed,
SupportsVRLow = src.SupportsVRLow,
SupportsMobile = src.SupportsMobile,
SupportsScreens = src.SupportsScreens,
SupportsWalkVR = src.SupportsWalkVR,
SupportsTeleportVR = src.SupportsTeleportVR,
AllowsJuniors = src.AllowsJuniors,
RoomWarningMask = src.RoomWarningMask,
CustomRoomWarning = src.CustomRoomWarning,
DisableMicAutoMute = src.DisableMicAutoMute,
LocationReplicationId = src.LocationReplicationId,
TagsCsv = src.TagsCsv,
CheerCount = src.CheerCount,
FavoriteCount = src.FavoriteCount,
VisitCount = src.VisitCount,
HotScore = src.HotScore,
CreatedAt = src.CreatedAt,
UpdatedAt = src.UpdatedAt,
CurrentDataBlobName = overrideBlobName ?? src.CurrentDataBlobName,
};
/// <summary>
/// Wire shape for the RoomDetails object — matches RoomDetails.Deserialize
/// at RVA 0x114C580. JSON key names DO NOT necessarily match the C#
/// property/field names from dump.cs — the deserializer is hand-written
/// and uses different (mostly PascalCase) literal strings. Verified
/// empirically: this exact shape gets past Deserialize successfully.
/// </summary>
public static object BuildRoomDetails(
RoomEntity room,
IReadOnlyList<RoomSceneEntity>? sceneRows = null,
long? overrideSceneId = null,
string? overrideDataBlobName = null,
IReadOnlyList<RoomRoleEntity>? roles = null)
{
// Tags is a list of OBJECTS, not strings. RoomDetails.Deserialize
// does Util.DeserializeList<RoomTag>(tags) — each entry is parsed as
// a Dictionary with `Type` (int RoomTagType: 0=Auto, 1=PlayerAdded,
// 2=AG) and `Tag` (string). Sending bare strings crashes:
// InvalidCastException: Unable to cast 'String' to 'Dictionary`2'
var tags = string.IsNullOrEmpty(room.TagsCsv)
? Array.Empty<object>()
: room.TagsCsv
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(t => (object)new { Type = 0, Tag = t })
.ToArray();
// Scenes population: every room must produce a Scenes[0] entry
// with RoomSceneId=0 (matching the matchmaking SubRoomId=0) so
// that Rooms.set_LocalRoomDetails actually transitions
// Rooms.LocalRoomScene to the new room's scene reference.
//
// Why every room: the setter (verified in Cpp2IL ISIL of
// RecNet.Rooms.set_LocalRoomDetails) iterates Scenes looking
// for a RoomSceneId match; if none matches (Scenes=[] or
// mismatched), it falls through WITHOUT writing LocalRoomScene
// — so the field stays stale at whatever the previous room set.
// After the dorm fix landed, that stale value started leaking
// into the rec center: the dorm sets LocalRoomScene → user
// goes to rec center → if we send Scenes=[], LocalRoomScene
// STAYS pointing at the dorm scene → DownloadRoomDataBlob
// Coroutine reads scene.DataBlobName from the dorm and
// downloads room_1_v1.dat (smoking gun in the server log)
// during the rec center load. The rec center master init
// blocks → FROSTBITE.
//
// The DataBlobName field is what determines whether the
// persistence coroutine actually downloads anything.
// OnRoomDataBlobNameChanged (RoomPersistenceManager.txt ISIL
// {027-040}) short-circuits when DataBlobName is empty: it
// sets GetRoomDataBlobPromise to a CompletedPromise<byte[]>
// with empty bytes and returns immediately — no HTTP fetch,
// no DeserializeRoomDataBlobCoroutine, the master init
// proceeds with default-zero permissions.
//
// • Dorm / customisable rooms: DataBlobName is either the
// latest uploaded blob name (RoomEntity.CurrentDataBlobName,
// written by StorageController on /upload) or, before any
// save, "room_<id>_v1.dat" — which the catch-all serves
// from RoomDataBlobService (the all-perms default blob).
// • AG-Original rooms: DataBlobName="" → completed-promise
// short-circuit → no download → no leaked stale-blob
// behaviour → master flow proceeds → spawn fires. Maker
// Pen stays locked, which matches public-server semantics
// (you can't edit the rec center).
//
// Heuristic: "customisable" iff it's the dorm OR a user-created
// room (CreatorPlayerId != 1, the seeded system account that
// owns AG-Originals like the rec center). User clones inherit
// the all-perms default blob until their first save replaces
// it via StorageController; the rec center / paintball / etc.
// serve "" so the persistence flow short-circuits.
var customisable = room.IsDormRoom || room.CreatorPlayerId != 1;
var dataBlobName = !string.IsNullOrEmpty(room.CurrentDataBlobName)
? room.CurrentDataBlobName
: customisable ? $"room_{room.Id}_v1.dat" : "";
// Multi-scene rooms (imported via tools/import-room.ps1) carry one
// RoomSceneEntity row per sub-room. Emit the full Scenes[] array
// when present so cross-scene portals (Lobby→Ch1, etc.) can
// resolve. Single-scene rooms (every legacy room, every dorm,
// every AG-Original) fall back to the synthesised Scenes[0] from
// RoomEntity fields.
object[] scenes;
if (sceneRows is { Count: > 0 })
{
scenes = sceneRows
.Select(s => (object)new
{
RoomSceneId = (long)s.OrderIndex,
RoomId = room.Id,
RoomSceneLocationId = s.RoomSceneLocationId,
Name = s.Name,
IsSandbox = s.IsSandbox,
DataBlobName = !string.IsNullOrWhiteSpace(overrideDataBlobName) &&
(overrideSceneId == s.OrderIndex || sceneRows.Count == 1)
? overrideDataBlobName
: s.DataBlobName,
MaxPlayers = s.MaxPlayers,
CanMatchmakeInto = s.CanMatchmakeInto,
DataModifiedAt = (s.DataModifiedAt == default ? DateTime.UtcNow : s.DataModifiedAt)
.ToString("yyyy-MM-ddTHH:mm:ssZ"),
})
.ToArray();
}
else
{
scenes = new object[]
{
new
{
RoomSceneId = 0L, // MUST match RoomInstance.SubRoomId
RoomId = room.Id,
RoomSceneLocationId = room.LocationReplicationId,
Name = "Home",
IsSandbox = false,
DataBlobName = dataBlobName,
MaxPlayers = 8,
CanMatchmakeInto = true,
// Use the room's actual UpdatedAt so the watch's
// "is my local cache stale?" check sees an accurate
// freshness timestamp. Hardcoding the build date
// (2020-03-06) made the watch flag every dorm visit
// as "room is not up to date" because the cached
// local copy was always newer than the server's
// declared timestamp.
DataModifiedAt = (room.UpdatedAt == default ? DateTime.UtcNow : room.UpdatedAt)
.ToString("yyyy-MM-ddTHH:mm:ssZ"),
},
};
}
// Owners tile (UIRoomDetails bottom-left) reads CoOwners — the
// 2020 RoomDetails.Deserialize reads keys WITHOUT the "Ids"
// suffix (verified at RoomDetails.txt:1096/1141/1146/1191/1196:
// "CoOwners" / "InvitedCoOwners" / "Moderators" /
// "InvitedModerators" / "Hosts" / "InvitedHosts"). The
// GetListKey calls are strict — wrong names throw
// KeyNotFoundException → "Failed to get room details:
// Malformed Response" → dorm load stalls in a redirect loop.
//
// CreatorPlayerId is always implicitly a CoOwner so RR Originals
// surface Coach (id=1) as the owner instead of rendering blank.
// Additional grants come from RoomRoleEntity rows when supplied;
// callers without role data (clone/myrooms/etc.) ship just the
// implicit creator.
var roleList = roles ?? Array.Empty<RoomRoleEntity>();
int[] PlayersIn(int role, bool accepted) => roleList
.Where(r => r.Role == role && r.Accepted == accepted)
.Select(r => (int)r.PlayerId)
.ToArray();
var coOwnerSet = new HashSet<int> { (int)room.CreatorPlayerId };
foreach (var pid in PlayersIn(0, true)) coOwnerSet.Add(pid);
return new
{
Room = RoomService.ToWireRoom(room),
Scenes = scenes,
CoOwners = coOwnerSet.ToArray(),
InvitedCoOwners = PlayersIn(0, false),
Moderators = PlayersIn(1, true),
InvitedModerators = PlayersIn(1, false),
Hosts = PlayersIn(2, true),
InvitedHosts = PlayersIn(2, false),
CheerCount = room.CheerCount,
FavoriteCount = room.FavoriteCount,
VisitCount = room.VisitCount,
// VisitorCount is the official Rec.Net "unique visitors"
// companion field (see Stats payload at apim.rec.net).
// 2020 RoomDetails.Deserialize doesn't read it but leaving
// it on the wire is harmless and lets the admin UI / any
// future client surface the number.
VisitorCount = room.VisitorCount,
Tags = tags,
};
}
/// <summary>
/// 2020.12 <c>/roomserver/rooms/{id}?include=301</c> shape. The client
/// deserializes this directly as PPENFJMFPNE, not as the older
/// { Room, Scenes, ... } wrapper.
/// </summary>
public static object BuildRoomServerDetails(
RoomEntity room,
IReadOnlyList<RoomSceneEntity>? sceneRows = null,
string? overrideDataBlobName = null,
IReadOnlyList<RoomRoleEntity>? roles = null)
{
var customisable = room.IsDormRoom || room.CreatorPlayerId != 1;
var dataBlobName = !string.IsNullOrEmpty(room.CurrentDataBlobName)
? room.CurrentDataBlobName
: customisable ? $"room_{room.Id}_v1.dat" : "";
var updatedAt = (room.UpdatedAt == default ? DateTime.UtcNow : room.UpdatedAt)
.ToString("yyyy-MM-ddTHH:mm:ssZ");
object[] subRooms = sceneRows is { Count: > 0 }
? sceneRows.Select(s => (object)new
{
SubRoomId = (long)s.OrderIndex,
RoomId = room.Id,
Name = s.Name,
DataBlob = !string.IsNullOrWhiteSpace(overrideDataBlobName) && sceneRows.Count == 1
? overrideDataBlobName
: s.DataBlobName,
DataSavedAt = (s.DataModifiedAt == default ? DateTime.UtcNow : s.DataModifiedAt)
.ToString("yyyy-MM-ddTHH:mm:ssZ"),
IsSandbox = s.IsSandbox,
MaxPlayers = s.MaxPlayers,
Accessibility = room.Accessibility,
UnitySceneId = s.RoomSceneLocationId,
}).ToArray()
: new object[]
{
new
{
SubRoomId = 0L,
RoomId = room.Id,
Name = "Home",
DataBlob = dataBlobName,
DataSavedAt = updatedAt,
IsSandbox = false,
MaxPlayers = 8,
Accessibility = room.Accessibility,
UnitySceneId = room.LocationReplicationId,
},
};
var roleList = roles ?? Array.Empty<RoomRoleEntity>();
static int WireRole(int role) => role switch
{
0 => 30, // CoOwner
1 => 20, // Moderator
2 => 10, // Host
_ => 0,
};
var wireRoles = new List<object>
{
new { AccountId = room.CreatorPlayerId, Role = 255, InvitedRole = 0 },
};
wireRoles.AddRange(roleList.Select(r =>
{
var role = WireRole(r.Role);
return (object)new
{
AccountId = r.PlayerId,
Role = r.Accepted ? role : 0,
InvitedRole = r.Accepted ? 0 : role,
};
}));
var tags = string.IsNullOrEmpty(room.TagsCsv)
? Array.Empty<object>()
: room.TagsCsv
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(t => (object)new { Type = 0, Tag = t })
.ToArray();
return new
{
RoomId = room.Id,
Name = room.Name,
Description = room.Description,
ImageName = room.ImageName,
WarningMask = room.RoomWarningMask,
CustomWarning = room.CustomRoomWarning,
CreatorAccountId = room.CreatorPlayerId,
State = room.State,
Accessibility = room.Accessibility,
SupportsLevelVoting = room.SupportsLevelVoting,
IsRRO = room.IsAGRoom,
SupportsScreens = room.SupportsScreens,
SupportsWalkVR = room.SupportsWalkVR,
SupportsTeleportVR = room.SupportsTeleportVR,
SupportsVRLow = room.SupportsVRLow,
SupportsQuest2 = true,
SupportsMobile = room.SupportsMobile,
SupportsJuniors = room.AllowsJuniors,
CreatedAt = (room.CreatedAt == default ? DateTime.UtcNow : room.CreatedAt)
.ToString("yyyy-MM-ddTHH:mm:ssZ"),
Stats = new
{
CheerCount = room.CheerCount,
FavoriteCount = room.FavoriteCount,
VisitorCount = room.VisitorCount,
VisitCount = room.VisitCount,
},
IsDorm = room.IsDormRoom,
CloningAllowed = room.CloningAllowed,
DisableMicAutoMute = room.DisableMicAutoMute,
DisableRoomComments = false,
EncryptVoiceChat = false,
SubRooms = subRooms,
Roles = wireRoles,
LoadScreens = Array.Empty<object>(),
PromoImages = Array.Empty<string>(),
PromoExternalContent = Array.Empty<object>(),
Tags = tags,
};
}
// ── Clone / create ───────────────────────────────────────────────────
/// <summary>
/// `Rooms.CloneRoom(roomIdToClone, name)` — POST api/rooms/v1/clone
/// with body `{"Name":"<new>","RoomId":<sourceId>}`.
///
/// Response shape (CreateModifyRoomResponse.Deserialize, RVA 0xAF1950):
/// Result (int CreateModifyRoomStatus: 0=Success, 1=Unknown,
/// 2=PermissionDenied, 3=RoomNotActive,
/// 4=RoomDoesNotExist, 5=RoomHasNoDataBlob, …)
/// RoomDetails (RoomDetails object, only populated on Result=0)
/// </summary>
[HttpPost("api/rooms/v1/clone")]
[Authorize]
public async Task<IActionResult> Clone([FromBody] CloneRoomRequest req)
{
var pid = CurrentPlayerId;
if (pid is null) return Unauthorized();
// CloneAsync sets the CreateModifyRoomStatus code itself
// (Success/RoomDoesNotExist/DuplicateName/InappropriateName) so the
// watch can show the right toast instead of a generic "Unknown".
var result = await rooms.CloneAsync(req.RoomId, req.Name ?? string.Empty, pid.Value);
return Ok(new
{
Result = result.Status,
RoomDetails = result.Room is null ? null : BuildRoomDetails(result.Room),
});
}
public class CloneRoomRequest
{
public string? Name { get; set; }
public long RoomId { get; set; }
}
// ── My-Rooms tabs ────────────────────────────────────────────────────
// 2020.12 OJMCBOKJFOF populates its local rooms cache from these
// URLs at boot (verified via metadata strings: "rooms/createdby/me",
// "rooms/moderatedby/me", "rooms/visitedby/me"). The 2020.03 client
// used /api/rooms/v2/myrooms, while 2020.12 moved the calls under
// /roomserver/rooms/*. Keep both route families on the same handlers.
[HttpGet("api/rooms/v2/myrooms")]
[HttpGet("api/rooms/v1/createdby/me")]
[HttpGet("api/rooms/v2/createdby/me")]
[HttpGet("api/rooms/v3/createdby/me")]
[HttpGet("roomserver/rooms/createdby/me")]
[HttpGet("roomserver/rooms/ownedby/me")]
[HttpGet("rooms/createdby/me")]
[HttpGet("rooms/ownedby/me")]
[Authorize]
public async Task<IActionResult> MyRooms()
{
var pid = CurrentPlayerId;
if (pid is null) return Ok(Array.Empty<object>());
return Ok((await rooms.CreatedByAsync(pid.Value)).Select(RoomService.ToWireRoom).ToList());
}
[HttpGet("api/rooms/v1/createdby/{otherPlayerId:long}")]
[HttpGet("api/rooms/v2/createdby/{otherPlayerId:long}")]
[HttpGet("api/rooms/v3/createdby/{otherPlayerId:long}")]
[HttpGet("roomserver/rooms/createdby/{otherPlayerId:long}")]
[HttpGet("rooms/createdby/{otherPlayerId:long}")]
public async Task<IActionResult> CreatedByOther(long otherPlayerId)
=> Ok((await rooms.CreatedByAsync(otherPlayerId)).Select(RoomService.ToWireRoom).ToList());
[HttpGet("api/rooms/v1/visitedby/me")]
[HttpGet("api/rooms/v2/visitedby/me")]
[HttpGet("api/rooms/v3/visitedby/me")]
[HttpGet("roomserver/rooms/visitedby/me")]
[HttpGet("rooms/visitedby/me")]
[Authorize]
public IActionResult VisitedByMe() => Ok(Array.Empty<object>());
[HttpGet("api/rooms/v1/moderatedby/me")]
[HttpGet("api/rooms/v2/moderatedby/me")]
[HttpGet("api/rooms/v3/moderatedby/me")]
[HttpGet("roomserver/rooms/moderatedby/me")]
[HttpGet("rooms/moderatedby/me")]
[Authorize]
public IActionResult ModeratedByMe() => Ok(Array.Empty<object>());
[HttpGet("api/rooms/v1/favoritedby/me")]
[HttpGet("api/rooms/v2/favoritedby/me")]
[HttpGet("api/rooms/v3/favoritedby/me")]
[HttpGet("roomserver/rooms/favoritedby/me")]
[HttpGet("rooms/favoritedby/me")]
[Authorize]
public IActionResult FavoritedByMe() => Ok(Array.Empty<object>());
[HttpGet("api/rooms/v1/cheeredby/me")]
[HttpGet("api/rooms/v2/cheeredby/me")]
[HttpGet("api/rooms/v3/cheeredby/me")]
[HttpGet("roomserver/rooms/cheeredby/me")]
[HttpGet("rooms/cheeredby/me")]
[Authorize]
public IActionResult CheeredByMe() => Ok(Array.Empty<object>());
[HttpGet("api/rooms/v2/mybookmarks")]
[HttpGet("api/rooms/v2/mybookmarkedrooms")]
[Authorize]
public async Task<IActionResult> MyBookmarks()
{
var pid = CurrentPlayerId;
if (pid is null) return Ok(Array.Empty<object>());
return Ok((await rooms.BookmarkedByAsync(pid.Value)).Select(RoomService.ToWireRoom).ToList());
}
[HttpGet("api/rooms/v2/mymoderated")]
[HttpGet("api/rooms/v1/modrooms")]
[HttpGet("api/rooms/v2/mysubscribed")]
[HttpGet("api/rooms/v2/myrecent")]
public IActionResult MyOtherTabs() => Ok(Array.Empty<object>());
// ── Bookmark mutations ───────────────────────────────────────────────
[HttpPost("api/rooms/v1/bookmark/{roomId:long}")]
[HttpPost("api/rooms/v2/bookmark/{roomId:long}")]
[Authorize]
public async Task<IActionResult> Bookmark(long roomId)
{
var pid = CurrentPlayerId;
if (pid is null) return Unauthorized();
await rooms.BookmarkAsync(pid.Value, roomId);
return Ok(new { success = true, error = "" });
}
[HttpDelete("api/rooms/v1/bookmark/{roomId:long}")]
[HttpDelete("api/rooms/v2/bookmark/{roomId:long}")]
[Authorize]
public async Task<IActionResult> Unbookmark(long roomId)
{
var pid = CurrentPlayerId;
if (pid is null) return Unauthorized();
await rooms.UnbookmarkAsync(pid.Value, roomId);
return Ok(new { success = true, error = "" });
}
// ── Helpers ──────────────────────────────────────────────────────────
/// <summary>GET <c>api/rooms/v1/agRoomIds</c> — list of every
/// seeded AG / RR-Original room id. Used by the watch to bias the
/// room browser toward official rooms first.</summary>
[HttpGet("api/rooms/v1/agRoomIds")]
[HttpGet("rooms/rro_ids")]
public async Task<IActionResult> AgRoomIds() =>
Ok(await rooms.AgRoomIdsAsync());
/// <summary>GET <c>api/rooms/v1/featuredRoomGroup</c> +
/// <c>v3/featured</c> — the "Featured" carousel on the watch's
/// rooms tab. Returns the top-12 AG rooms by HotScore.</summary>
[HttpGet("api/rooms/v1/featuredRoomGroup")]
[HttpGet("api/rooms/v3/featured")]
public async Task<IActionResult> Featured() => Ok(new
{
Name = "Featured",
FeaturedRooms = await rooms.FeaturedAgRoomIdsAsync(12),
});
/// <summary>GET <c>featuredrooms/current</c> — the watch's pointer
/// to the current "Featured" room group. Caller:
/// <c>EJDCNGBEICB.CFKDADKHAGB</c>. Wire shape is <c>NMPFCIJPODA</c>
/// with required keys <c>FeaturedRoomGroupId</c> (long) and
/// <c>Name</c> (string) — a pointer record, NOT the room list
/// itself (the watch fetches the list separately via
/// <c>api/rooms/v3/featured</c>). Returning the
/// <see cref="Featured"/> shape here throws KeyNotFoundException on
/// the watch's group-id lookup.</summary>
[HttpGet("featuredrooms/current")]
public async Task<IActionResult> FeaturedRoomsCurrent() => Ok(new
{
FeaturedRoomGroupId = 1L,
Name = "Featured",
// NMPFCIJPODA.PPGFHEDFBEA (NMPFCIJPODA.txt:100-125) reads
// 3 strict keys, not 2 — FeaturedRoomGroupId, Name, AND
// Rooms (List<PPKJFAAAGDO>). Returning without the third
// key throws KeyNotFoundException on the watch and the
// Featured carousel stays empty.
Rooms = await rooms.FeaturedAgRoomIdsAsync(12),
});
/// <summary>POST <c>api/rooms/v1/bookmark</c> — toggle bookmark
/// state for a room. Body: <c>RoomId</c> + optional <c>Bookmark</c>
/// flag (default = add).</summary>
[HttpPost("api/rooms/v1/bookmark")]
[Authorize]
public async Task<IActionResult> BookmarkV1(
[FromForm(Name = "RoomId")] long? roomId,
[FromForm(Name = "Bookmark")] bool? bookmark)
{
if (roomId is not long rid) return Ok(new { success = true });
var pid = this.RequireCurrentPlayerId();
if (bookmark == false) await rooms.UnbookmarkAsync(pid, rid);
else await rooms.BookmarkAsync(pid, rid);
return Ok(new { success = true, error = "" });
}
/// <summary>POST <c>rooms/{roomId}/name</c> — rename a room.
/// Watch path: <c>EJDCNGBEICB.DNOGGKKHMHI(long roomId, string newName)</c>
/// (analytics tag "change room name"). Body shape: form-urlencoded
/// <c>Name=...</c> or a bare string in the body. Returns the
/// updated Room DTO (<c>PPENFJMFPNE : KLCOGEIGEBJ</c>).
///
/// <para>Caller must be the room creator OR a co-owner. Other
/// validations: name length 1..40, no leading/trailing whitespace,
/// uniqueness against other Rooms.Name. Failures return
/// the watch's <c>CreateModifyRoomStatus</c>-style payload with
/// a non-zero Result so the rename toast surfaces a real reason.</para>
///
/// <para>We accept both POST and PUT because the watch's ISIL
/// doesn't expose the explicit HTTPMethod literal at this call
/// site — covering both is cheap and means we can't get bitten
/// by the wrong one.</para></summary>
public sealed class RenameBody { public string? Name { get; set; } }
[HttpPost("rooms/{roomId:long}/name")]
[HttpPut("rooms/{roomId:long}/name")]
[Authorize]
public async Task<IActionResult> RenameRoom(
long roomId,
[FromForm(Name = "Name")] string? nameForm,
[FromForm(Name = "name")] string? nameFormLower,
[FromBody] RenameBody? body)
{
var newName = nameForm ?? nameFormLower ?? body?.Name;
if (string.IsNullOrWhiteSpace(newName))
return BadRequest(new { Result = 1, error = "empty_name" });
newName = newName.Trim();
if (newName.Length is < 1 or > 40)
return BadRequest(new { Result = 1, error = "invalid_length", length = newName.Length });
var me = this.RequireCurrentPlayerId();
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
if (room is null) return NotFound(new { Result = 4, error = "room_not_found" });
if (room.CreatorPlayerId != me)
{
// RoomRoles co-owner check — same authorisation as the
// existing modify endpoint, lets co-owners rename too.
var coOwner = await db.RoomRoles.AnyAsync(rr =>
rr.RoomId == roomId && rr.PlayerId == me && rr.Accepted && (rr.Role == 0 || rr.Role == 1));
if (!coOwner) return Forbid();
}
// Uniqueness — Rooms.Name has a unique index (DorkNetDbContext.cs:118).
var collision = await db.Rooms.AnyAsync(r => r.Id != roomId && r.Name == newName);
if (collision) return Conflict(new { Result = 5, error = "name_taken" });
room.Name = newName;
room.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
logger.LogInformation("[room-rename] room={Room} by={By} → '{Name}'", roomId, me, newName);
// Return the full updated Room DTO. The watch's
// <c>PPENFJMFPNE</c> deserialiser inherits from
// <c>KLCOGEIGEBJ</c> (the standard Room shape) so we can
// forward to the same v3-details payload builder by re-reading.
// BuildRoomDetailsV3Async is too heavy here — just return the
// minimal mutated subset; the watch UI re-fetches details
// shortly after for the room-info screen.
return Ok(new
{
Result = 0,
RoomId = room.Id,
Name = room.Name,
Description = room.Description,
ImageName = room.ImageName,
CreatorPlayerId = room.CreatorPlayerId,
CreatorAccountId = room.CreatorPlayerId,
State = room.State,
Accessibility = room.Accessibility,
IsAGRoom = room.IsAGRoom,
IsRRO = false,
IsDormRoom = room.IsDormRoom,
});
}
/// <summary>POST <c>rooms/{roomId}/subrooms/{subRoomId}/name</c> —
/// rename a scene/sub-room within a multi-scene room. Watch path:
/// <c>EJDCNGBEICB.PAENPJCDLNJ(long roomId, long subRoomId, string newName)</c>.
/// Updates the matching <see cref="RoomSceneEntity.Name"/> after
/// verifying ownership and intra-room uniqueness.</summary>
[HttpPost("rooms/{roomId:long}/subrooms/{subRoomId:long}/name")]
[HttpPut("rooms/{roomId:long}/subrooms/{subRoomId:long}/name")]
[Authorize]
public async Task<IActionResult> RenameSubroom(
long roomId,
long subRoomId,
[FromForm(Name = "Name")] string? nameForm,
[FromForm(Name = "name")] string? nameFormLower,
[FromBody] RenameBody? body)
{
var newName = nameForm ?? nameFormLower ?? body?.Name;
if (string.IsNullOrWhiteSpace(newName))
return BadRequest(new { Result = 1, error = "empty_name" });
newName = newName.Trim();
if (newName.Length is < 1 or > 40)
return BadRequest(new { Result = 1, error = "invalid_length", length = newName.Length });
var me = this.RequireCurrentPlayerId();
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
if (room is null) return NotFound(new { Result = 4, error = "room_not_found" });
if (room.CreatorPlayerId != me)
{
var coOwner = await db.RoomRoles.AnyAsync(rr =>
rr.RoomId == roomId && rr.PlayerId == me && rr.Accepted && (rr.Role == 0 || rr.Role == 1));
if (!coOwner) return Forbid();
}
var scene = await db.RoomScenes.FirstOrDefaultAsync(s =>
s.RoomId == roomId && s.OrderIndex == subRoomId);
if (scene is null) return NotFound(new { Result = 4, error = "subroom_not_found", subRoomId });
// Uniqueness within this room — same scene name twice would
// make /goto/room/X/Y ambiguous against the RoomScenes index
// on (RoomId, Name) at DorkNetDbContext.cs:153.
var collision = await db.RoomScenes.AnyAsync(s =>
s.RoomId == roomId && s.OrderIndex != subRoomId && s.Name == newName);
if (collision) return Conflict(new { Result = 5, error = "subroom_name_taken" });
scene.Name = newName;
scene.DataModifiedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
logger.LogInformation("[subroom-rename] room={Room} sub={Sub} by={By} → '{Name}'", roomId, subRoomId, me, newName);
return Ok(new
{
Result = 0,
RoomId = roomId,
RoomSceneId = scene.OrderIndex,
SubRoomId = scene.OrderIndex,
Name = scene.Name,
scene.DataBlobName,
RoomSceneLocationId = scene.RoomSceneLocationId,
scene.IsSandbox,
scene.MaxPlayers,
scene.CanMatchmakeInto,
});
}
/// <summary>POST <c>api/rooms/v1/cheer</c> — cheer a room (body
/// form). Idempotent per (caller, room) — duplicate taps don't
/// inflate CheerCount.</summary>
[HttpPost("api/rooms/v1/cheer")]
[Authorize]
public async Task<IActionResult> CheerRoom(
[FromForm(Name = "RoomId")] long? roomId,
[FromForm(Name = "Type")] int? type)
{
if (roomId is not long rid) return Ok(new { success = true });
var me = this.RequireCurrentPlayerId();
var (newCount, already) = await rooms.CheerRoomAsync(me, rid, type ?? 0);
return Ok(new { RoomId = rid, CheerCount = newCount, AlreadyCheered = already });
}
/// <summary>POST <c>api/rooms/v1/uncheer</c> — remove a previously
/// applied room cheer.</summary>
[HttpPost("api/rooms/v1/uncheer")]
[HttpPost("api/rooms/v1/uncheer/{roomId:long}")]
[Authorize]
public async Task<IActionResult> UncheerRoom(
long? roomId,
[FromForm(Name = "RoomId")] long? roomIdForm,
[FromForm(Name = "Type")] int? type)
{
var rid = roomId ?? roomIdForm ?? 0;
if (rid == 0) return Ok(new { success = true });
var me = this.RequireCurrentPlayerId();
var already = await rooms.UncheerRoomAsync(me, rid, type ?? 0);
return Ok(new { RoomId = rid, AlreadyUncheered = already });
}
/// <summary>GET <c>api/rooms/v1/cheers/{roomId}</c> — did the
/// caller cheer this room, and what's the current count?</summary>
[HttpGet("api/rooms/v1/cheers/{roomId:long}")]
[Authorize]
public async Task<IActionResult> CheckCheered(long roomId)
{
var me = this.CurrentPlayerId();
var (count, iCheered) = await rooms.GetCheerStateAsync(me ?? 0, roomId);
return Ok(new { RoomId = roomId, CheerCount = count, ICheered = iCheered });
}
// ── Bare-path interactionby/me toggles ─────────────────────────────
[HttpPost("rooms/{roomId:long}/interactionby/me/cheer")]
[HttpPut("rooms/{roomId:long}/interactionby/me/cheer")]
[Authorize]
public async Task<IActionResult> BareCheer(long roomId)
{
var me = this.RequireCurrentPlayerId();
await rooms.CheerRoomAsync(me, roomId, 0);
return Ok(new { success = true });
}
[HttpDelete("rooms/{roomId:long}/interactionby/me/cheer")]
[Authorize]
public async Task<IActionResult> BareUncheer(long roomId)
{
var me = this.RequireCurrentPlayerId();
await rooms.UncheerRoomAsync(me, roomId, 0);
return Ok(new { success = true });
}
[HttpPost("rooms/{roomId:long}/interactionby/me/favorite")]
[HttpPut("rooms/{roomId:long}/interactionby/me/favorite")]
[Authorize]
public async Task<IActionResult> BareFavorite(long roomId)
{
var me = this.RequireCurrentPlayerId();
await rooms.BookmarkAsync(me, roomId);
return Ok(new { success = true });
}
[HttpDelete("rooms/{roomId:long}/interactionby/me/favorite")]
[Authorize]
public async Task<IActionResult> BareUnfavorite(long roomId)
{
var me = this.RequireCurrentPlayerId();
await rooms.UnbookmarkAsync(me, roomId);
return Ok(new { success = true });
}
// ── Rooms-and-Playlists union (2020.12 watch) ────────────────────────
/// <summary>
/// GET <c>/roomserver/roomsandplaylists/hot</c> — the 2020.12 watch's
/// merged "Trending" / "New" tab. Two URL variants exist with
/// DIFFERENT wire shapes:
///
/// 1. <c>/roomserver/roomsandplaylists/hot</c> (this endpoint)
/// Watch caller: <c>EJDCNGBEICB.EBBHGHEGBKG</c>
/// Signature: <c>IPromise&lt;HJKAOMOICJG&gt;</c>
/// Wire shape: <c>{ TotalResults, Results: [union] }</c>
/// (verified at <c>EJDCNGBEICB.txt:2571,2645</c> +
/// <c>HJKAOMOICJG.txt:39-50</c>)
/// 2. <c>/roomserver/hot_roomsandplaylists/{tags-joined}</c>
/// Watch caller: <c>OJMCBOKJFOF.EBBHGHEGBKG</c>
/// Signature: <c>IPromise&lt;List&lt;MKAMHOIHOJK&gt;&gt;</c>
/// Wire shape: bare list, see <see cref="HotRoomsAndPlaylistsPath"/>
/// (verified at <c>OJMCBOKJFOF.txt:4607,4743</c>)
///
/// Returning a bare list here throws
/// <c>InvalidCastException: Unable to cast object of type 'List`1'
/// to type 'Dictionary`2'</c> on the watch — the wrapped
/// HJKAOMOICJG deserializer reads <c>TotalResults</c> as a top-level
/// dict key, and a JSON array body fails that cast.
///
/// Inside <c>Results</c>: union entries discriminated by lowercase
/// <c>roomId</c> key per the <c>MKAMHOIHOJK</c> factory at
/// <c>MKAMHOIHOJK.txt:621</c> — see <see cref="BuildRoomUnionEntry"/>
/// + <see cref="BuildPlaylistUnionEntry"/>.
/// </summary>
[HttpGet("roomserver/roomsandplaylists/hot")]
[HttpGet("roomsandplaylists/hot")]
public async Task<IActionResult> RoomsAndPlaylistsHot([FromQuery] string? tag)
{
var (merged, total) = await BuildHotUnionAsync(tag);
return Ok(new Dictionary<string, object>(StringComparer.Ordinal)
{
["TotalResults"] = (long)total,
["Results"] = merged,
});
}
/// <summary>
/// GET <c>/roomserver/hot_roomsandplaylists/{tagsJoined}</c> —
/// path-style sibling of <see cref="RoomsAndPlaylistsHot"/> with a
/// BARE <c>List&lt;MKAMHOIHOJK&gt;</c> shape (no TotalResults
/// wrapper). Tags are comma-joined into the URL path per
/// <c>OJMCBOKJFOF.txt:4739,4743</c> (<c>String.Join(",", tags)</c>
/// followed by <c>String.Concat("hot_roomsandplaylists/", joined)</c>).
/// Empty path is legal — equivalent to "no tag filter".
/// </summary>
[HttpGet("roomserver/hot_roomsandplaylists/{tagsJoined?}")]
[HttpGet("hot_roomsandplaylists/{tagsJoined?}")]
public async Task<IActionResult> HotRoomsAndPlaylistsPath(string? tagsJoined)
{
// Watch joins multiple tags with "," — use the first one as a
// substring filter since RoomService.HotAsync takes a single
// tag arg. Empty / null = unfiltered.
var firstTag = string.IsNullOrWhiteSpace(tagsJoined)
? null
: tagsJoined.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.FirstOrDefault();
var (merged, _) = await BuildHotUnionAsync(firstTag);
return Ok(merged);
}
/// <summary>GET <c>hot_rooms/{tagsJoined?}</c> — rooms-only variant
/// of <see cref="HotRoomsAndPlaylistsPath"/>. Caller:
/// <c>OJMCBOKJFOF.DLPLPKCNLNA</c>. Same <c>MKAMHOIHOJK</c> union
/// entry shape as <c>hot_roomsandplaylists</c>, just skips playlist
/// rows so the watch's rooms-only browse tab doesn't render
/// playlist tiles that have nowhere to go.</summary>
[HttpGet("hot_rooms/{tagsJoined?}")]
public async Task<IActionResult> HotRoomsOnlyPath(string? tagsJoined)
{
var firstTag = string.IsNullOrWhiteSpace(tagsJoined)
? null
: tagsJoined.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.FirstOrDefault();
var roomList = await rooms.HotAsync(firstTag, take: 12);
var roomIds = roomList.Select(r => r.Id).ToList();
var sceneRows = roomIds.Count == 0
? new List<RoomSceneEntity>()
: await db.RoomScenes.Where(s => roomIds.Contains(s.RoomId))
.OrderBy(s => s.OrderIndex).ToListAsync();
var scenesByRoom = sceneRows.GroupBy(s => s.RoomId)
.ToDictionary(g => g.Key, g => (IReadOnlyList<RoomSceneEntity>)g.ToList());
return Ok(roomList
.Select(r => BuildRoomUnionEntry(r, scenesByRoom.GetValueOrDefault(r.Id)))
.ToList());
}
/// <summary>
/// Shared body for the two hot variants — pulls hot rooms +
/// playlists, fans out sub-room rows in one query to avoid N+1,
/// and interleaves into the union shape the watch's factory
/// expects. Returns (entries, totalCount) so the wrapped variant
/// can fill in TotalResults.
/// </summary>
private async Task<(List<IDictionary<string, object>> Entries, int Total)> BuildHotUnionAsync(string? tag)
{
// Sequential — both services share the scoped DbContext, which
// is not thread-safe (Task.WhenAll here throws "A second
// operation was started on this context instance before a
// previous operation completed").
var roomList = await rooms.HotAsync(tag, take: 12);
var playlistList = await playlists.HotAsync(tag, take: 12);
var roomIds = roomList.Select(r => r.Id).ToList();
var sceneRows = await db.RoomScenes
.Where(s => roomIds.Contains(s.RoomId))
.OrderBy(s => s.OrderIndex)
.ToListAsync();
var scenesByRoom = sceneRows
.GroupBy(s => s.RoomId)
.ToDictionary(g => g.Key, g => (IReadOnlyList<RoomSceneEntity>)g.ToList());
var merged = InterleaveUnion(
roomList.Select(r => BuildRoomUnionEntry(r, scenesByRoom.GetValueOrDefault(r.Id))),
playlistList.Select(BuildPlaylistUnionEntry));
return (merged, roomList.Count + playlistList.Count);
}
/// <summary>
/// GET <c>/api/curatedroomplaylists</c> — the editorial "Curated"
/// row on the watch's playlists tab.
///
/// Wire shape: <c>List&lt;Int64&gt;</c> — JUST the playlist IDs, not
/// the full playlist objects. Verified against
/// <c>FPNPBJBCMKB.EPPPMHMMGME</c> (signature
/// <c>IPromise&lt;List&lt;long&gt;&gt;</c>) and the call site at
/// <c>BrowseRoomsScreen.txt:2806</c> which constructs an
/// <c>Action&lt;List&lt;Int64&gt;&gt;</c> callback. Returning rich
/// objects here throws
/// <c>InvalidCastException: Unable to cast object of type 'List`1'
/// to type 'Dictionary`2'</c> on the watch ("Failed to get curated
/// room playlists: Malformed Response"). The watch fetches each
/// playlist's details separately via the playlist-detail endpoints.
/// </summary>
[HttpGet("api/curatedroomplaylists")]
public async Task<IActionResult> CuratedRoomPlaylists()
{
var curated = await playlists.CuratedAsync();
return Ok(curated.Select(p => p.Id).ToList());
}
/// <summary>
/// GET <c>/roomserver/playlists/{id}</c> — single-playlist details
/// with members. The watch deserializes this as <b>BMFAGMFKODA</b>
/// (verified at <c>BMFAGMFKODA.txt:182-191</c>) which:
/// 1. Calls <c>KMKPEOGJDFK.PPGFHEDFBEA</c> to read the base
/// Playlist union fields (<c>PlaylistId</c> + all
/// <c>MKAMHOIHOJK</c> base keys).
/// 2. Reads required key <c>Rooms</c> — <c>List&lt;KLCOGEIGEBJ&gt;</c>
/// (the playlist's member rooms, full Room dicts).
/// 3. Reads required key <c>Tags</c> — <c>List&lt;DPHPFLGAICI&gt;</c>
/// (tag rows with <c>Type</c> + <c>Tag</c>).
/// Returning just the <c>KMKPEOGJDFK</c> shape (no Rooms/Tags) throws
/// <c>KeyNotFoundException: Failed to find key 'Rooms' when
/// deserializing object of type KLCOGEIGEBJ</c> on the watch.
/// </summary>
[HttpGet("roomserver/playlists/{id:long}")]
[HttpGet("playlists/{id:long}")]
public async Task<IActionResult> PlaylistDetails(long id)
{
var p = await db.Playlists.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id);
if (p is null) return NotFound();
// Pull member rooms in playlist order, then fan out room +
// scene rows so each entry can render as a full KLCOGEIGEBJ.
var roomIds = await db.PlaylistRooms
.Where(pr => pr.PlaylistId == id)
.OrderBy(pr => pr.OrderIndex)
.Select(pr => pr.RoomId)
.ToListAsync();
List<RoomEntity> memberRooms;
Dictionary<long, IReadOnlyList<RoomSceneEntity>> scenesByRoom;
if (roomIds.Count == 0)
{
memberRooms = new List<RoomEntity>();
scenesByRoom = new Dictionary<long, IReadOnlyList<RoomSceneEntity>>();
}
else
{
var roomRows = await db.Rooms
.Where(r => roomIds.Contains(r.Id))
.ToListAsync();
// Preserve playlist order — db.Rooms.Where doesn't guarantee
// it matches the OrderBy on PlaylistRooms.OrderIndex.
var roomById = roomRows.ToDictionary(r => r.Id);
memberRooms = roomIds
.Select(rid => roomById.TryGetValue(rid, out var r) ? r : null)
.Where(r => r is not null)
.Select(r => r!)
.ToList();
var sceneRows = await db.RoomScenes
.Where(s => roomIds.Contains(s.RoomId))
.OrderBy(s => s.OrderIndex)
.ToListAsync();
scenesByRoom = sceneRows
.GroupBy(s => s.RoomId)
.ToDictionary(g => g.Key, g => (IReadOnlyList<RoomSceneEntity>)g.ToList());
}
var roomsWire = memberRooms
.Select(r => BuildRoomServerDetails(r, scenesByRoom.GetValueOrDefault(r.Id)))
.ToList();
// Tags wire shape: [{Type:int, Tag:string}] — same shape used in
// BuildRoomServerDetails for Room.Tags. Split TagsCsv if present.
var tagsWire = string.IsNullOrWhiteSpace(p.TagsCsv)
? new List<object>()
: p.TagsCsv
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(t => (object)new { Type = 0, Tag = t })
.ToList();
// Start from BuildPlaylistUnionEntry for base + PlaylistId, then
// splice in the Rooms + Tags lists. Dictionary lets us add the
// extra keys after the base build.
var result = BuildPlaylistUnionEntry(p);
result["Rooms"] = roomsWire;
result["Tags"] = tagsWire;
return Ok(result);
}
/// <summary>
/// GET <c>/roomserver/roomsandplaylists/search?query=X</c> — search
/// surface used by the watch's friend-mention / room-search affordance.
/// Verified at <c>EJDCNGBEICB.txt:2486,2560</c> — signature
/// <c>IPromise&lt;HJKAOMOICJG&gt;</c>. Per
/// <see cref="HJKAOMOICJG.PPGFHEDFBEA"/> at <c>HJKAOMOICJG.txt:39-50</c>
/// the response object reads <c>TotalResults</c> (Int64) and
/// <c>Results</c> (List of <c>MKAMHOIHOJK</c> union entries — same
/// Room/Playlist factory used by <c>/hot</c>).
///
/// Distinct from <c>/roomserver/search_roomsandplaylists/{query}</c>
/// (path-param shape from <c>OJMCBOKJFOF.txt:4567</c>); both URLs
/// reach the same data but differ in shape — the path-param sibling
/// returns a bare <c>List&lt;MKAMHOIHOJK&gt;</c> without the
/// TotalResults wrapper.
/// </summary>
[HttpGet("roomserver/roomsandplaylists/search")]
[HttpGet("roomsandplaylists/search")]
public async Task<IActionResult> RoomsAndPlaylistsSearch([FromQuery] string? query)
{
var (merged, total) = await BuildSearchUnionAsync(query);
return Ok(new Dictionary<string, object>(StringComparer.Ordinal)
{
["TotalResults"] = (long)total,
["Results"] = merged,
});
}
/// <summary>
/// GET <c>/roomserver/search_roomsandplaylists/{query}</c> — free-text
/// search across both rooms and playlists, BARE-LIST variant. Per
/// <c>OJMCBOKJFOF.txt:4439,4567</c> the signature is
/// <c>IPromise&lt;List&lt;MKAMHOIHOJK&gt;&gt;</c> — no TotalResults
/// wrapper. URL is <c>search_roomsandplaylists/{query}</c>
/// (query in path, not query-string).
/// </summary>
[HttpGet("roomserver/search_roomsandplaylists/{query}")]
[HttpGet("search_roomsandplaylists/{query}")]
public async Task<IActionResult> SearchRoomsAndPlaylists(string query)
{
var (merged, _) = await BuildSearchUnionAsync(query);
return Ok(merged);
}
/// <summary>GET <c>search_rooms/{query}</c> — rooms-only search
/// variant. Same <c>MKAMHOIHOJK</c> union entry shape as
/// <c>search_roomsandplaylists/{query}</c>, just skips playlist
/// rows.</summary>
[HttpGet("search_rooms/{query}")]
public async Task<IActionResult> SearchRoomsOnly(string query)
{
if (string.IsNullOrWhiteSpace(query))
return Ok(Array.Empty<object>());
var roomList = await rooms.SearchAsync(query);
var roomIds = roomList.Select(r => r.Id).ToList();
var sceneRows = roomIds.Count == 0
? new List<RoomSceneEntity>()
: await db.RoomScenes.Where(s => roomIds.Contains(s.RoomId))
.OrderBy(s => s.OrderIndex).ToListAsync();
var scenesByRoom = sceneRows.GroupBy(s => s.RoomId)
.ToDictionary(g => g.Key, g => (IReadOnlyList<RoomSceneEntity>)g.ToList());
return Ok(roomList
.Select(r => BuildRoomUnionEntry(r, scenesByRoom.GetValueOrDefault(r.Id)))
.ToList());
}
/// <summary>
/// Shared body for the two search variants. Empty/whitespace query
/// returns an empty result without round-tripping the DB.
/// </summary>
private async Task<(List<IDictionary<string, object>> Entries, int Total)> BuildSearchUnionAsync(string? query)
{
if (string.IsNullOrWhiteSpace(query))
return (new List<IDictionary<string, object>>(), 0);
// Sequential — shared scoped DbContext isn't thread-safe.
var roomList = await rooms.SearchAsync(query);
var playlistList = await playlists.SearchAsync(query);
var roomIds = roomList.Select(r => r.Id).ToList();
var sceneRows = await db.RoomScenes
.Where(s => roomIds.Contains(s.RoomId))
.OrderBy(s => s.OrderIndex)
.ToListAsync();
var scenesByRoom = sceneRows
.GroupBy(s => s.RoomId)
.ToDictionary(g => g.Key, g => (IReadOnlyList<RoomSceneEntity>)g.ToList());
var merged = InterleaveUnion(
roomList.Select(r => BuildRoomUnionEntry(r, scenesByRoom.GetValueOrDefault(r.Id))),
playlistList.Select(BuildPlaylistUnionEntry));
return (merged, roomList.Count + playlistList.Count);
}
/// <summary>Round-robin merge of two enumerables so the watch's
/// hot row alternates between rooms and playlists rather than
/// stacking one type on top. Once one side runs out the remainder
/// of the other side spills out in order.</summary>
private static List<IDictionary<string, object>> InterleaveUnion(
IEnumerable<IDictionary<string, object>> a,
IEnumerable<IDictionary<string, object>> b)
{
var listA = a.ToList();
var listB = b.ToList();
var output = new List<IDictionary<string, object>>(listA.Count + listB.Count);
int i = 0, j = 0;
while (i < listA.Count || j < listB.Count)
{
if (i < listA.Count) output.Add(listA[i++]);
if (j < listB.Count) output.Add(listB[j++]);
}
return output;
}
/// <summary>
/// Build a union-shape Room entry — same payload as
/// <see cref="BuildRoomServerDetails"/> but with the additional
/// lowercase <c>roomId</c> key that the 2020.12
/// <c>MKAMHOIHOJK</c> factory uses as its Room-vs-Playlist
/// discriminator (factory at <c>MKAMHOIHOJK.txt:621</c> calls
/// <c>Dictionary.ContainsKey("roomId")</c>; the actual Room
/// deserializer at <c>KLCOGEIGEBJ.txt:208-234</c> reads
/// PascalCase <c>RoomId</c>). Must include BOTH keys.
/// </summary>
private static IDictionary<string, object> BuildRoomUnionEntry(
RoomEntity room,
IReadOnlyList<RoomSceneEntity>? sceneRows)
{
// Start from the canonical RoomServerDetails shape so the union
// entry carries every field the 2020.12 client expects on a
// Room subclass (SubRooms, Roles, Stats, etc.). Then add the
// lowercase 'roomId' discriminator key on top.
var details = BuildRoomServerDetails(room, sceneRows);
// Convert anonymous record into a mutable dictionary so we can
// add the lowercase 'roomId' key that anonymous types can't
// express (you can't have two properties differing only in
// letter casing on a single anonymous type).
var dict = new Dictionary<string, object>(StringComparer.Ordinal);
foreach (var prop in details.GetType().GetProperties())
{
var value = prop.GetValue(details);
if (value is not null) dict[prop.Name] = value;
}
// Discriminator key — lowercase 'roomId'. Without this the
// MKAMHOIHOJK factory falls through to the Playlist branch
// and PlaylistId.PPGFHEDFBEA throws on the missing key.
dict["roomId"] = room.Id;
return dict;
}
/// <summary>
/// Build a union-shape Playlist entry — the
/// <see cref="KMKPEOGJDFK"/> branch of MKAMHOIHOJK. Keys read at
/// <c>MKAMHOIHOJK.txt:516-612</c> (PascalCase base keys) and
/// <c>KMKPEOGJDFK.txt:68</c> (PascalCase <c>PlaylistId</c>).
/// Omits the lowercase <c>roomId</c> key so the factory's
/// Room-vs-Playlist branch falls through to the Playlist
/// constructor.
/// </summary>
public static IDictionary<string, object> BuildPlaylistUnionEntry(PlaylistEntity p)
{
return new Dictionary<string, object>(StringComparer.Ordinal)
{
["PlaylistId"] = p.Id,
["Name"] = p.Name,
["Description"] = p.Description,
["ImageName"] = p.ImageName,
["CreatorAccountId"] = p.CreatorPlayerId,
["State"] = 0,
["Accessibility"] = 1,
["WarningMask"] = 0,
["CustomWarning"] = string.Empty,
["SupportsLevelVoting"] = false,
// Playlists are watchable from every device the
// member rooms support; advertise the broadest set so
// the watch never filters them out by capability.
["IsRRO"] = p.IsCurated,
["SupportsScreens"] = true,
["SupportsWalkVR"] = true,
["SupportsTeleportVR"] = true,
["SupportsVRLow"] = true,
["SupportsQuest2"] = true,
["SupportsMobile"] = true,
["SupportsJuniors"] = true,
["CreatedAt"] = (p.CreatedAt == default ? DateTime.UtcNow : p.CreatedAt)
.ToString("yyyy-MM-ddTHH:mm:ssZ"),
["Stats"] = new
{
CheerCount = p.CheerCount,
FavoriteCount = p.FavoriteCount,
VisitorCount = p.VisitorCount,
VisitCount = p.VisitCount,
},
};
}
// ── Subroom mutations (bare path) ──────────────────────────────────
public sealed class CreateSubRoomRequest
{
public string? Name { get; set; }
public string? RoomSceneLocationId { get; set; }
public int? MaxPlayers { get; set; }
public bool? IsSandbox { get; set; }
}
/// <summary>POST <c>rooms/{id}/subrooms</c> — append a new scene.
/// Owner-gated. OrderIndex = max+1, Name unique within room.</summary>
[HttpPost("rooms/{roomId:long}/subrooms")]
[Authorize]
public async Task<IActionResult> AddSubRoom(long roomId, [FromBody] CreateSubRoomRequest req)
{
var pid = this.RequireCurrentPlayerId();
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
if (room is null) return NotFound();
if (room.CreatorPlayerId != pid) return Forbid();
var name = (req.Name ?? string.Empty).Trim();
if (name.Length == 0) name = "NewScene";
var collision = await db.RoomScenes.AnyAsync(s => s.RoomId == roomId && s.Name == name);
if (collision) return Conflict(new { error = "name_taken" });
var nextOrder = (await db.RoomScenes
.Where(s => s.RoomId == roomId)
.Select(s => (int?)s.OrderIndex).MaxAsync() ?? -1) + 1;
var scene = new RoomSceneEntity
{
RoomId = roomId,
OrderIndex = nextOrder,
Name = name,
RoomSceneLocationId = req.RoomSceneLocationId
?? "a75f7547-79eb-47c6-8986-6767abcb4f92",
MaxPlayers = req.MaxPlayers ?? 8,
IsSandbox = req.IsSandbox ?? false,
};
db.RoomScenes.Add(scene);
await db.SaveChangesAsync();
return Ok(SceneWire(scene));
}
[HttpDelete("rooms/{roomId:long}/subrooms/{subRoomId:long}")]
[Authorize]
public async Task<IActionResult> DeleteSubRoom(long roomId, long subRoomId)
{
var pid = this.RequireCurrentPlayerId();
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
if (room is null) return NotFound();
if (room.CreatorPlayerId != pid) return Forbid();
var scene = await db.RoomScenes
.FirstOrDefaultAsync(s => s.RoomId == roomId && s.OrderIndex == subRoomId);
if (scene is null) return NotFound();
if (scene.OrderIndex == 0) return BadRequest(new { error = "cannot_delete_entry_scene" });
db.RoomScenes.Remove(scene);
await db.SaveChangesAsync();
return Ok(new { Result = 0 });
}
public sealed class SubRoomIntRequest { public int? Value { get; set; } }
public sealed class SubRoomBoolRequest { public bool? Value { get; set; } }
public sealed class SubRoomModifyRequest
{
public string? Name { get; set; }
public int? MaxPlayers { get; set; }
public bool? IsSandbox { get; set; }
public bool? CanMatchmakeInto { get; set; }
public string? RoomSceneLocationId { get; set; }
}
public sealed class SubRoomMoveRequest { public int? NewIndex { get; set; } }
[HttpPost("rooms/{roomId:long}/subrooms/{subRoomId:long}/maxplayers")]
[HttpPut("rooms/{roomId:long}/subrooms/{subRoomId:long}/maxplayers")]
[Authorize]
public Task<IActionResult> SubRoomMaxPlayers(long roomId, long subRoomId, [FromBody] SubRoomIntRequest req) =>
MutateScene(roomId, subRoomId, s => { if (req.Value is int v) s.MaxPlayers = Math.Max(1, v); });
[HttpPost("rooms/{roomId:long}/subrooms/{subRoomId:long}/accessibility")]
[HttpPut("rooms/{roomId:long}/subrooms/{subRoomId:long}/accessibility")]
[Authorize]
public Task<IActionResult> SubRoomAccessibility(long roomId, long subRoomId, [FromBody] SubRoomBoolRequest req) =>
MutateScene(roomId, subRoomId, s => { if (req.Value is bool v) s.CanMatchmakeInto = v; });
/// <summary>POST <c>rooms/{id}/subrooms/{sub}/clone</c> — duplicate
/// a scene (DataBlobName + location pointer). New scene goes at the
/// end with " Copy" suffix; per-room name uniqueness preserved.</summary>
[HttpPost("rooms/{roomId:long}/subrooms/{subRoomId:long}/clone")]
[Authorize]
public async Task<IActionResult> CloneSubRoom(long roomId, long subRoomId)
{
var pid = this.RequireCurrentPlayerId();
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
if (room is null) return NotFound();
if (room.CreatorPlayerId != pid) return Forbid();
var src = await db.RoomScenes
.FirstOrDefaultAsync(s => s.RoomId == roomId && s.OrderIndex == subRoomId);
if (src is null) return NotFound();
var baseName = $"{src.Name} Copy";
var newName = baseName;
int suffix = 1;
while (await db.RoomScenes.AnyAsync(s => s.RoomId == roomId && s.Name == newName))
{
suffix += 1;
newName = $"{baseName} {suffix}";
}
var nextOrder = (await db.RoomScenes.Where(s => s.RoomId == roomId)
.Select(s => (int?)s.OrderIndex).MaxAsync() ?? -1) + 1;
var clone = new RoomSceneEntity
{
RoomId = roomId,
OrderIndex = nextOrder,
Name = newName,
RoomSceneLocationId = src.RoomSceneLocationId,
DataBlobName = src.DataBlobName,
MaxPlayers = src.MaxPlayers,
IsSandbox = src.IsSandbox,
CanMatchmakeInto = src.CanMatchmakeInto,
};
db.RoomScenes.Add(clone);
await db.SaveChangesAsync();
return Ok(SceneWire(clone));
}
[HttpPost("rooms/{roomId:long}/subrooms/{subRoomId:long}/modify")]
[HttpPut("rooms/{roomId:long}/subrooms/{subRoomId:long}/modify")]
[Authorize]
public Task<IActionResult> ModifySubRoom(long roomId, long subRoomId, [FromBody] SubRoomModifyRequest req) =>
MutateScene(roomId, subRoomId, s =>
{
if (!string.IsNullOrWhiteSpace(req.Name)) s.Name = req.Name.Trim();
if (req.MaxPlayers is int mp) s.MaxPlayers = Math.Max(1, mp);
if (req.IsSandbox is bool sb) s.IsSandbox = sb;
if (req.CanMatchmakeInto is bool cmi) s.CanMatchmakeInto = cmi;
if (req.RoomSceneLocationId is not null) s.RoomSceneLocationId = req.RoomSceneLocationId;
});
/// <summary>POST <c>rooms/{id}/subrooms/{sub}/move</c> — reorder a
/// scene. Body NewIndex is the target OrderIndex; the in-between
/// rows shift to fill the gap.</summary>
[HttpPost("rooms/{roomId:long}/subrooms/{subRoomId:long}/move")]
[Authorize]
public async Task<IActionResult> MoveSubRoom(long roomId, long subRoomId, [FromBody] SubRoomMoveRequest req)
{
var pid = this.RequireCurrentPlayerId();
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
if (room is null) return NotFound();
if (room.CreatorPlayerId != pid) return Forbid();
var scenes = await db.RoomScenes
.Where(s => s.RoomId == roomId).OrderBy(s => s.OrderIndex).ToListAsync();
var moving = scenes.FirstOrDefault(s => s.OrderIndex == subRoomId);
if (moving is null) return NotFound();
var newIndex = Math.Clamp(req.NewIndex ?? 0, 0, scenes.Count - 1);
scenes.Remove(moving);
scenes.Insert(newIndex, moving);
// Recompute OrderIndex on the entire row set. The unique
// (RoomId, OrderIndex) index would clash if we wrote rows in
// place — flush a sentinel offset first, then settle to
// final indices.
const int sentinelOffset = 100_000;
for (int i = 0; i < scenes.Count; i++) scenes[i].OrderIndex = i + sentinelOffset;
await db.SaveChangesAsync();
for (int i = 0; i < scenes.Count; i++) scenes[i].OrderIndex = i;
await db.SaveChangesAsync();
return Ok(SceneWire(moving));
}
/// <summary>POST <c>rooms/{id}/subrooms/{sub}/data</c> — bare-path
/// equivalent of <c>api/rooms/v4/saveData</c>. Delegates to the
/// existing save logic so the watch's two URL variants land on the
/// same persistence path.</summary>
[HttpPost("rooms/{roomId:long}/subrooms/{subRoomId:long}/data")]
[Authorize]
public Task<IActionResult> SubRoomData(long roomId, long subRoomId,
[FromBody] SaveRoomSceneRequest body)
{
body.RoomSceneId = subRoomId;
return SaveData(body);
}
private async Task<IActionResult> MutateScene(long roomId, long subRoomId, Action<RoomSceneEntity> mutator)
{
var pid = this.RequireCurrentPlayerId();
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
if (room is null) return NotFound();
if (room.CreatorPlayerId != pid) return Forbid();
var scene = await db.RoomScenes
.FirstOrDefaultAsync(s => s.RoomId == roomId && s.OrderIndex == subRoomId);
if (scene is null) return NotFound();
mutator(scene);
scene.DataModifiedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return Ok(SceneWire(scene));
}
private static object SceneWire(RoomSceneEntity s) => new
{
Result = 0,
RoomId = s.RoomId,
RoomSceneId = s.OrderIndex,
SubRoomId = s.OrderIndex,
Name = s.Name,
s.DataBlobName,
RoomSceneLocationId = s.RoomSceneLocationId,
s.IsSandbox,
s.MaxPlayers,
s.CanMatchmakeInto,
};
// ── Bare-path roles ────────────────────────────────────────────────
/// <summary>GET <c>rooms/{id}/roles</c> — accepted role grants on
/// the room. Owner is implicit (CreatorPlayerId) and surfaces under
/// Role=30; co-owner/mod/host rows come from RoomRoles.</summary>
[HttpGet("rooms/{roomId:long}/roles")]
public async Task<IActionResult> RolesList(long roomId)
{
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
if (room is null) return NotFound();
var rows = await db.RoomRoles.Where(r => r.RoomId == roomId).ToListAsync();
var list = new List<object>(rows.Count + 1)
{
new { RoomId = roomId, PlayerId = (int)room.CreatorPlayerId, Role = 30 /*Owner*/, Accepted = true },
};
list.AddRange(rows.Select(r => (object)new
{
RoomId = roomId,
PlayerId = (int)r.PlayerId,
Role = r.Role,
Accepted = r.Accepted,
}));
return Ok(list);
}
[HttpGet("rooms/{roomId:long}/roles/{playerId:long}")]
public async Task<IActionResult> RoleForPlayer(long roomId, long playerId)
{
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
if (room is null) return NotFound();
if (room.CreatorPlayerId == playerId)
return Ok(new { RoomId = roomId, PlayerId = (int)playerId, Role = 30, Accepted = true });
var row = await db.RoomRoles
.FirstOrDefaultAsync(r => r.RoomId == roomId && r.PlayerId == playerId);
if (row is null) return NotFound();
return Ok(new { RoomId = roomId, PlayerId = (int)playerId, Role = row.Role, Accepted = row.Accepted });
}
public sealed class GrantRoleRequest { public int? Role { get; set; } }
/// <summary>POST <c>rooms/{id}/roles/{playerId}</c> — owner grants
/// a role to a player. Auto-accepted (no invite step).</summary>
[HttpPost("rooms/{roomId:long}/roles/{playerId:long}")]
[Authorize]
public async Task<IActionResult> GrantRole(long roomId, long playerId, [FromBody] GrantRoleRequest req)
{
var me = this.RequireCurrentPlayerId();
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
if (room is null) return NotFound();
if (room.CreatorPlayerId != me) return Forbid();
var role = req.Role ?? 0;
var existing = await db.RoomRoles
.FirstOrDefaultAsync(r => r.RoomId == roomId && r.PlayerId == playerId && r.Role == role);
if (existing is null)
{
db.RoomRoles.Add(new RoomRoleEntity
{
RoomId = roomId, PlayerId = playerId, Role = role,
Accepted = true, GrantedByPlayerId = me,
});
}
else
{
existing.Accepted = true;
}
await db.SaveChangesAsync();
return Ok(new { RoomId = roomId, PlayerId = (int)playerId, Role = role, Accepted = true });
}
/// <summary>POST <c>rooms/{id}/roles/{playerId}/invite</c> — same
/// as grant but Accepted=false; the target's accept-invite flow
/// flips the flag (separate endpoint, not yet exposed). For now
/// invited rows surface in RoomDetails.InvitedCoOwners etc.</summary>
[HttpPost("rooms/{roomId:long}/roles/{playerId:long}/invite")]
[Authorize]
public async Task<IActionResult> InviteRole(long roomId, long playerId, [FromBody] GrantRoleRequest req)
{
var me = this.RequireCurrentPlayerId();
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
if (room is null) return NotFound();
if (room.CreatorPlayerId != me) return Forbid();
var role = req.Role ?? 0;
var existing = await db.RoomRoles
.FirstOrDefaultAsync(r => r.RoomId == roomId && r.PlayerId == playerId && r.Role == role);
if (existing is null)
{
db.RoomRoles.Add(new RoomRoleEntity
{
RoomId = roomId, PlayerId = playerId, Role = role,
Accepted = false, GrantedByPlayerId = me,
});
}
else
{
existing.Accepted = false;
}
await db.SaveChangesAsync();
return Ok(new { RoomId = roomId, PlayerId = (int)playerId, Role = role, Accepted = false });
}
/// <summary>
/// Synthesise a placeholder Room when the client asks for one we don't
/// have in the DB. Keeps the deserializer happy — every required key
/// is populated. Safe defaults: public, AG, default DormRoom location.
///
/// Special case: roomId=1 is the canonical DormRoom id used by
/// GoToController when the client visits its own dorm. Make sure the
/// /v4/details/1 follow-up shows "DormRoom" rather than "Room_1".
/// </summary>
private static RoomEntity Synthetic(string name, long? id = null)
{
var isDorm = (id == 1) || name.Equals("DormRoom", StringComparison.OrdinalIgnoreCase);
var displayName = isDorm ? "DormRoom" : name;
return new RoomEntity
{
Id = id ?? Math.Abs((long)name.GetHashCode()),
Name = displayName,
Description = isDorm
? "Your private dorm — yours alone, decorated however you like."
: $"{displayName} room",
CreatorPlayerId = 1,
State = 0,
Accessibility = isDorm ? 0 /* Private */ : 1 /* Public */,
IsAGRoom = !isDorm,
IsDormRoom = isDorm,
LocationReplicationId = "76d98498-60a1-430c-ab76-b54a29b7a163",
};
}
}
@@ -0,0 +1,646 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using System.Text.Json;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
namespace DorkNet.Server.Controllers.API.Rooms.V2;
/// <summary>
/// api.rec.net/api/rooms/v{1,2}/{modify,modifyPermissions,banfromroom,
/// importroombans,roombans,report,modify/scene,modify/sceneParent,
/// modify/tags,modifyscene} — owner-side mutation surface for rooms.
///
/// Split from <see cref="RoomsController"/> to keep that file
/// (browse / details / clone) at a manageable size. All endpoints
/// are owner-gated (the caller must equal
/// <see cref="RoomEntity.CreatorPlayerId"/>) or admin-bypassed via
/// <see cref="AdminOnlyAttribute"/> on the admin-only sub-routes.
/// </summary>
[ApiController]
[Authorize]
public class RoomsModerationController(DorkNetDbContext db) : ControllerBase
{
private long Me => this.RequireCurrentPlayerId();
private async Task<RoomEntity?> RequireOwnedRoomAsync(long roomId)
{
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
if (room is null) return null;
return room.CreatorPlayerId == Me ? room : null;
}
// ── /modify (general fields) ─────────────────────────────────────────
public sealed class ModifyRoomRequest
{
public long RoomId { get; set; }
public string? Name { get; set; }
public string? Description { get; set; }
public string? ImageName { get; set; }
public string? Tags { get; set; }
public int? Accessibility { get; set; }
public bool? SupportsScreens { get; set; }
public bool? SupportsMobile { get; set; }
public bool? SupportsVRLow { get; set; }
public bool? SupportsWalkVR { get; set; }
public bool? SupportsTeleportVR { get; set; }
public bool? DisableMicAutoMute { get; set; }
}
[HttpPost("api/rooms/v2/modify")]
public async Task<IActionResult> Modify([FromBody] ModifyRoomRequest req)
{
var room = await RequireOwnedRoomAsync(req.RoomId);
if (room is null) return NotFound();
if (!string.IsNullOrWhiteSpace(req.Name)) room.Name = req.Name.Trim();
if (req.Description is not null) room.Description = req.Description;
if (req.ImageName is not null) room.ImageName = req.ImageName;
if (req.Tags is not null) room.TagsCsv = req.Tags;
if (req.Accessibility.HasValue) room.Accessibility = Math.Clamp(req.Accessibility.Value, 0, 2);
if (req.SupportsScreens.HasValue) room.SupportsScreens = req.SupportsScreens.Value;
if (req.SupportsMobile.HasValue) room.SupportsMobile = req.SupportsMobile.Value;
if (req.SupportsVRLow.HasValue) room.SupportsVRLow = req.SupportsVRLow.Value;
if (req.SupportsWalkVR.HasValue) room.SupportsWalkVR = req.SupportsWalkVR.Value;
if (req.SupportsTeleportVR.HasValue) room.SupportsTeleportVR = req.SupportsTeleportVR.Value;
if (req.DisableMicAutoMute.HasValue) room.DisableMicAutoMute = req.DisableMicAutoMute.Value;
room.UpdatedAt = DateTime.UtcNow;
try
{
await db.SaveChangesAsync();
}
catch (DbUpdateException)
{
// Likely a unique-name collision.
return Conflict(new { Result = 2, Message = "name taken" });
}
return Ok(new { Result = 0, Room = Services.RoomService.ToWireRoom(room) });
}
public sealed class ModifyPermissionsRequest
{
public long RoomId { get; set; }
public int? Accessibility { get; set; }
public bool? SupportsLevelVoting { get; set; }
public bool? AllowsJuniors { get; set; }
public bool? CloningAllowed { get; set; }
public int? RoomWarningMask { get; set; }
public string? CustomRoomWarning { get; set; }
}
[HttpPost("api/rooms/v2/modifyPermissions")]
public async Task<IActionResult> ModifyPermissions([FromBody] ModifyPermissionsRequest req)
{
var room = await RequireOwnedRoomAsync(req.RoomId);
if (room is null) return NotFound();
if (req.Accessibility.HasValue) room.Accessibility = Math.Clamp(req.Accessibility.Value, 0, 2);
if (req.SupportsLevelVoting.HasValue) room.SupportsLevelVoting = req.SupportsLevelVoting.Value;
if (req.AllowsJuniors.HasValue) room.AllowsJuniors = req.AllowsJuniors.Value;
if (req.CloningAllowed.HasValue) room.CloningAllowed = req.CloningAllowed.Value;
if (req.RoomWarningMask.HasValue) room.RoomWarningMask = req.RoomWarningMask.Value;
if (req.CustomRoomWarning is not null) room.CustomRoomWarning = req.CustomRoomWarning;
room.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return Ok(Services.RoomService.ToWireRoom(room));
}
// ── Scene mutations (LocationReplicationId) ──────────────────────────
public sealed class ModifySceneRequest
{
public long RoomId { get; set; }
public string? LocationReplicationId { get; set; }
}
[HttpPost("api/rooms/v1/modify/scene")]
[HttpPost("api/rooms/v1/modifyscene")]
public async Task<IActionResult> ModifyScene([FromBody] ModifySceneRequest req)
{
var room = await RequireOwnedRoomAsync(req.RoomId);
if (room is null) return NotFound();
if (!string.IsNullOrWhiteSpace(req.LocationReplicationId))
room.LocationReplicationId = req.LocationReplicationId;
room.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return Ok(Services.RoomService.ToWireRoom(room));
}
public sealed class ModifySceneParentRequest
{
public long RoomId { get; set; }
public long? ParentRoomId { get; set; }
}
/// <summary>Sub-room / nested-scene parent override. We don't
/// model sub-rooms as a real graph, so we accept-and-ignore but
/// still owner-gate to prevent grief.</summary>
[HttpPost("api/rooms/v1/modify/sceneParent")]
public async Task<IActionResult> ModifySceneParent([FromBody] ModifySceneParentRequest req)
{
var room = await RequireOwnedRoomAsync(req.RoomId);
if (room is null) return NotFound();
return Ok(new { Result = 0 });
}
public sealed class ModifyTagsRequest
{
public long RoomId { get; set; }
public string? Tags { get; set; }
}
[HttpPost("api/rooms/v1/modify/tags")]
public async Task<IActionResult> ModifyTags([FromBody] ModifyTagsRequest req)
{
var room = await RequireOwnedRoomAsync(req.RoomId);
if (room is null) return NotFound();
room.TagsCsv = req.Tags ?? string.Empty;
room.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return Ok(Services.RoomService.ToWireRoom(room));
}
// ── Bans ─────────────────────────────────────────────────────────────
public sealed class BanFromRoomRequest
{
public long RoomId { get; set; }
public long PlayerId { get; set; }
public int BanType { get; set; } // 0=Soft (kick), 1=Permanent, 2=Temp
public DateTime? Until { get; set; }
public string? Reason { get; set; }
}
[HttpPost("api/rooms/v2/banfromroom")]
public async Task<IActionResult> BanFromRoom([FromBody] BanFromRoomRequest req)
{
var room = await RequireOwnedRoomAsync(req.RoomId);
if (room is null) return NotFound();
db.RoomBans.Add(new RoomBanEntity
{
RoomId = req.RoomId,
BannedPlayerId = req.PlayerId,
BannedByPlayerId = Me,
BanType = req.BanType,
Until = req.Until,
Reason = req.Reason ?? string.Empty,
});
await db.SaveChangesAsync();
return Ok(new { Result = 0 });
}
public sealed class ImportRoomBansRequest
{
public long RoomId { get; set; }
public List<BanFromRoomRequest>? Bans { get; set; }
}
[HttpPost("api/rooms/v1/importroombans")]
public async Task<IActionResult> ImportRoomBans([FromBody] ImportRoomBansRequest req)
{
var room = await RequireOwnedRoomAsync(req.RoomId);
if (room is null) return NotFound();
if (req.Bans is null || req.Bans.Count == 0) return Ok(new { Imported = 0 });
foreach (var b in req.Bans)
{
db.RoomBans.Add(new RoomBanEntity
{
RoomId = req.RoomId,
BannedPlayerId = b.PlayerId,
BannedByPlayerId = Me,
BanType = b.BanType,
Until = b.Until,
Reason = b.Reason ?? string.Empty,
});
}
await db.SaveChangesAsync();
return Ok(new { Imported = req.Bans.Count });
}
[HttpGet("api/rooms/v1/roombans/{roomId:long}")]
public async Task<IActionResult> RoomBansList(long roomId)
{
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
if (room is null) return NotFound();
// Only owner sees the ban list (admins also via existing
// admin endpoints).
if (room.CreatorPlayerId != Me) return Forbid();
var rows = await db.RoomBans
.Where(b => b.RoomId == roomId && (b.Until == null || b.Until > DateTime.UtcNow))
.OrderByDescending(b => b.CreatedAt)
.ToListAsync();
return Ok(rows.Select(b => new
{
b.Id,
b.RoomId,
b.BannedPlayerId,
b.BannedByPlayerId,
b.BanType,
b.Until,
b.Reason,
b.CreatedAt,
}));
}
// ── Report ───────────────────────────────────────────────────────────
public sealed class RoomReportRequest
{
public long RoomId { get; set; }
public int Category { get; set; }
public string? Message { get; set; }
}
[HttpPost("api/rooms/v2/report")]
public async Task<IActionResult> Report([FromBody] RoomReportRequest req)
{
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == req.RoomId);
if (room is null) return NotFound();
var msg = req.Message ?? string.Empty;
db.Reports.Add(new ReportEntity
{
ReporterPlayerId = Me,
TargetPlayerId = room.CreatorPlayerId,
TargetRoomId = req.RoomId,
RoomId = req.RoomId,
Category = req.Category,
Message = msg[..Math.Min(1000, msg.Length)],
});
await db.SaveChangesAsync();
return Ok(new { Reported = true });
}
public sealed class RoomRolePermissionRequest
{
public long RoomId { get; set; }
public long PlayerId { get; set; }
}
/// <summary>POST <c>api/rooms/v1/roomRolePermissions</c> — watch
/// posts its self-computed role + permission grants for
/// server-side validation. Role enum:
/// 0=Visitor, 5=Member, 10=Mod, 20=Host, 30=Owner. Room creator
/// is always Owner; everyone else defaults to Visitor.</summary>
[HttpPost("api/rooms/v1/roomRolePermissions")]
[AllowAnonymous]
public async Task<IActionResult> RoomRolePermissions(
[FromBody] RoomRolePermissionRequest? body,
[FromForm(Name = "RoomId")] long? roomIdForm,
[FromForm(Name = "PlayerId")] long? playerIdForm)
{
var roomId = body?.RoomId ?? roomIdForm ?? 0;
var playerId = body?.PlayerId ?? playerIdForm ?? (this.CurrentPlayerId() ?? 0);
if (roomId <= 0 || playerId <= 0)
return Ok(new { success = true, error = "" });
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
int role = (room?.CreatorPlayerId == playerId) ? 30 : 0;
long permissions = role switch
{
30 => -1L,
10 => 0x0FFFFFFFL,
5 => 0x000000FFL,
_ => 0x0000000FL,
};
return Ok(new
{
success = true,
error = "",
RoomId = roomId,
PlayerId = (int)playerId,
Role = role,
Permissions = permissions,
});
}
/// <summary>POST <c>api/rooms/v1/verifyRole</c> — status-only
/// gate used by 2020.12 before privileged room actions. Body is
/// form or JSON with roomId, role, and optional context.</summary>
[HttpPost("api/rooms/v1/verifyRole")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
public async Task<IActionResult> VerifyRole()
{
var req = await ReadVerifyRoleRequestAsync();
if (req.RoomId <= 0) return BadRequest();
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == req.RoomId);
if (room is null) return NotFound();
var me = Me;
var required = RoleRank(req.Role);
if (required <= 0) return Ok();
var isAdmin = await db.Players
.Where(p => p.Id == me)
.Select(p => p.IsAdmin)
.FirstOrDefaultAsync();
if (isAdmin || room.CreatorPlayerId == me) return Ok();
var acceptedRoles = await db.RoomRoles
.Where(r => r.RoomId == req.RoomId && r.PlayerId == me && r.Accepted)
.Select(r => r.Role)
.ToListAsync();
var actual = acceptedRoles.Select(RoomRoleRank).DefaultIfEmpty(0).Max();
return actual >= required ? Ok() : Forbid();
}
private sealed class VerifyRoleRequest
{
public long RoomId { get; set; }
public string Role { get; set; } = string.Empty;
public string Context { get; set; } = string.Empty;
}
private async Task<VerifyRoleRequest> ReadVerifyRoleRequestAsync()
{
if (Request.HasFormContentType)
{
var form = await Request.ReadFormAsync();
return new VerifyRoleRequest
{
RoomId = long.TryParse(form["roomId"].FirstOrDefault()
?? form["RoomId"].FirstOrDefault(), out var roomId)
? roomId
: 0,
Role = form["role"].FirstOrDefault()
?? form["Role"].FirstOrDefault()
?? string.Empty,
Context = form["context"].FirstOrDefault()
?? form["Context"].FirstOrDefault()
?? string.Empty,
};
}
try
{
var req = await JsonSerializer.DeserializeAsync<VerifyRoleRequest>(
Request.Body,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
return req ?? new VerifyRoleRequest();
}
catch (JsonException)
{
return new VerifyRoleRequest();
}
}
private static int RoomRoleRank(int role) => role switch
{
0 => 30, // CoOwner
1 => 10, // Moderator
2 => 20, // Host
_ => 0,
};
private static int RoleRank(string role)
{
if (int.TryParse(role, out var n)) return n;
return role.Trim().ToLowerInvariant() switch
{
"owner" or "creator" or "coowner" or "co-owner" => 30,
"host" => 20,
"moderator" or "mod" => 10,
"member" => 5,
"visitor" or "" => 0,
_ => 0,
};
}
// ── Bare-path per-field mutations (2020.12 watch URLs) ─────────────
//
// The 2020.12 client emits per-field mutation URLs like
// POST rooms/{id}/description rather than POST api/rooms/v2/modify
// with one field set. Each handler below shares the same
// RequireOwnedRoomAsync gate as /modify and returns the same
// ToWireRoom shape so the watch's CreateModifyRoomResponse
// deserializer reads cleanly.
public sealed class BareDescriptionRequest { public string? Description { get; set; } }
public sealed class BareImageRequest { public string? ImageName { get; set; } }
public sealed class BareTagsRequest { public string? Tags { get; set; } public List<string>? TagsList { get; set; } }
public sealed class BareIntRequest { public int? Value { get; set; } }
public sealed class BareBoolRequest { public bool? Value { get; set; } }
public sealed class BareWarningRequest { public int? RoomWarningMask { get; set; } public string? CustomRoomWarning { get; set; } }
// Each per-field route registers BOTH POST and PUT — the 2020.12
// watch's request-builder wraps the HTTP method inside opaque
// BPHGKAEDBPE helpers; the existing /name handler at
// RoomsController.cs:1455-1456 set the precedent of "register both
// because the ISIL is opaque". Same pattern here so the bind never
// 405s.
[HttpPost("rooms/{roomId:long}/description")]
[HttpPut("rooms/{roomId:long}/description")]
public async Task<IActionResult> BareDescription(long roomId,
[FromBody] BareDescriptionRequest? body,
[FromForm(Name = "Description")] string? form) =>
await ApplyAndReturn(roomId, r => r.Description = body?.Description ?? form ?? r.Description);
[HttpPost("rooms/{roomId:long}/image")]
[HttpPut("rooms/{roomId:long}/image")]
public async Task<IActionResult> BareImage(long roomId,
[FromBody] BareImageRequest? body,
[FromForm(Name = "ImageName")] string? form) =>
await ApplyAndReturn(roomId, r => r.ImageName = body?.ImageName ?? form ?? r.ImageName);
[HttpPost("rooms/{roomId:long}/tags")]
[HttpPut("rooms/{roomId:long}/tags")]
public async Task<IActionResult> BareTags(long roomId,
[FromBody] BareTagsRequest? body,
[FromForm(Name = "Tags")] string? form)
{
var csv = body?.Tags
?? (body?.TagsList is { Count: > 0 } list ? string.Join(',', list) : null)
?? form;
return await ApplyAndReturn(roomId, r => r.TagsCsv = csv ?? r.TagsCsv);
}
[HttpPost("rooms/{roomId:long}/accessibility")]
[HttpPut("rooms/{roomId:long}/accessibility")]
public async Task<IActionResult> BareAccessibility(long roomId, [FromBody] BareIntRequest? body,
[FromForm(Name = "Accessibility")] int? form) =>
await ApplyAndReturn(roomId, r =>
{
var v = body?.Value ?? form;
if (v is int vv) r.Accessibility = Math.Clamp(vv, 0, 2);
});
[HttpPost("rooms/{roomId:long}/cloning")]
[HttpPut("rooms/{roomId:long}/cloning")]
public async Task<IActionResult> BareCloning(long roomId, [FromBody] BareBoolRequest? body,
[FromForm(Name = "CloningAllowed")] bool? form) =>
await ApplyAndReturn(roomId, r =>
{
var v = body?.Value ?? form;
if (v is bool vv) r.CloningAllowed = vv;
});
[HttpPost("rooms/{roomId:long}/automute")]
[HttpPut("rooms/{roomId:long}/automute")]
public async Task<IActionResult> BareAutomute(long roomId, [FromBody] BareBoolRequest? body,
[FromForm(Name = "DisableMicAutoMute")] bool? form) =>
await ApplyAndReturn(roomId, r =>
{
var v = body?.Value ?? form;
if (v is bool vv) r.DisableMicAutoMute = vv;
});
/// <summary><c>rooms/{id}/restrictions</c> — junior / age-related
/// restriction toggle. Body bool maps to AllowsJuniors (true = no
/// restriction, false = juniors restricted).</summary>
[HttpPost("rooms/{roomId:long}/restrictions")]
[HttpPut("rooms/{roomId:long}/restrictions")]
public async Task<IActionResult> BareRestrictions(long roomId, [FromBody] BareBoolRequest? body,
[FromForm(Name = "AllowsJuniors")] bool? form) =>
await ApplyAndReturn(roomId, r =>
{
var v = body?.Value ?? form;
if (v is bool vv) r.AllowsJuniors = vv;
});
[HttpPost("rooms/{roomId:long}/warning")]
[HttpPut("rooms/{roomId:long}/warning")]
public async Task<IActionResult> BareWarning(long roomId, [FromBody] BareWarningRequest? body) =>
await ApplyAndReturn(roomId, r =>
{
if (body?.RoomWarningMask is int m) r.RoomWarningMask = m;
if (body?.CustomRoomWarning is not null) r.CustomRoomWarning = body.CustomRoomWarning;
});
/// <summary><c>rooms/{id}/comments</c> + <c>voice_chat_encryption</c>
/// — fields not persisted in the 2020 RoomEntity. Owner-gated
/// acknowledgement so the watch's settings panel doesn't surface
/// a "save failed" error; populated when the columns land.</summary>
[HttpPost("rooms/{roomId:long}/comments")]
[HttpPut("rooms/{roomId:long}/comments")]
[HttpPost("rooms/{roomId:long}/voice_chat_encryption")]
[HttpPut("rooms/{roomId:long}/voice_chat_encryption")]
public async Task<IActionResult> BareAck(long roomId) =>
await ApplyAndReturn(roomId, _ => { /* no-op: not modelled yet */ });
/// <summary><c>rooms/{id}/modify</c> — bare-path equivalent
/// of <c>api/rooms/v2/modify</c>. Reuses <see cref="Modify"/> by
/// forwarding the body with RoomId pre-populated from the URL.</summary>
[HttpPost("rooms/{roomId:long}/modify")]
[HttpPut("rooms/{roomId:long}/modify")]
public async Task<IActionResult> BareModify(long roomId, [FromBody] ModifyRoomRequest req)
{
req.RoomId = roomId;
return await Modify(req);
}
/// <summary>POST <c>rooms/{id}/clone</c> — bare-path alias of
/// <c>api/rooms/v1/clone</c>. The actual clone logic lives on
/// <see cref="DorkNet.Server.Controllers.API.Rooms.V2.RoomsController"/>;
/// this handler proxies via the service so we don't duplicate the
/// substantial clone-rooms logic.</summary>
[HttpPost("rooms/{roomId:long}/clone")]
public async Task<IActionResult> BareClone(long roomId,
[FromBody] CloneBareRequest? body,
[FromForm(Name = "Name")] string? formName)
{
var newName = body?.Name ?? formName ?? string.Empty;
var source = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
if (source is null) return NotFound();
if (!source.CloningAllowed && source.CreatorPlayerId != Me)
return Forbid();
if (string.IsNullOrWhiteSpace(newName))
newName = $"{source.Name}Copy";
else
newName = newName.Trim();
// Uniqueness — append a numeric suffix until free.
var baseName = newName;
int suffix = 1;
while (await db.Rooms.AnyAsync(r => r.Name == newName))
{
suffix += 1;
newName = $"{baseName}{suffix}";
}
var clone = new RoomEntity
{
Name = newName,
Description = source.Description,
CreatorPlayerId = Me,
ImageName = source.ImageName,
State = 0,
Accessibility = source.Accessibility,
SupportsLevelVoting = source.SupportsLevelVoting,
IsAGRoom = false,
IsDormRoom = false,
CloningAllowed = source.CloningAllowed,
SupportsVRLow = source.SupportsVRLow,
SupportsMobile = source.SupportsMobile,
SupportsScreens = source.SupportsScreens,
SupportsWalkVR = source.SupportsWalkVR,
SupportsTeleportVR = source.SupportsTeleportVR,
AllowsJuniors = source.AllowsJuniors,
RoomWarningMask = source.RoomWarningMask,
CustomRoomWarning = source.CustomRoomWarning,
DisableMicAutoMute = source.DisableMicAutoMute,
LocationReplicationId = source.LocationReplicationId,
TagsCsv = source.TagsCsv,
CurrentDataBlobName = source.CurrentDataBlobName,
};
db.Rooms.Add(clone);
await db.SaveChangesAsync();
return Ok(Services.RoomService.ToWireRoom(clone));
}
public sealed class CloneBareRequest { public string? Name { get; set; } }
// ── Bare-path bans aliases ─────────────────────────────────────────
public sealed class BareBanRequest { public long PlayerId { get; set; } public int BanType { get; set; } public DateTime? Until { get; set; } public string? Reason { get; set; } }
[HttpGet("rooms/{roomId:long}/bans")]
public Task<IActionResult> BareBansList(long roomId) => RoomBansList(roomId);
[HttpPost("rooms/{roomId:long}/bans")]
public Task<IActionResult> BareBan(long roomId, [FromBody] BareBanRequest req) =>
BanFromRoom(new BanFromRoomRequest
{
RoomId = roomId,
PlayerId = req.PlayerId,
BanType = req.BanType,
Until = req.Until,
Reason = req.Reason,
});
[HttpDelete("rooms/{roomId:long}/bans/{playerId:long}")]
public async Task<IActionResult> BareUnban(long roomId, long playerId)
{
var room = await RequireOwnedRoomAsync(roomId);
if (room is null) return NotFound();
var rows = await db.RoomBans
.Where(b => b.RoomId == roomId && b.BannedPlayerId == playerId)
.ToListAsync();
if (rows.Count == 0) return Ok(new { Removed = 0 });
db.RoomBans.RemoveRange(rows);
await db.SaveChangesAsync();
return Ok(new { Removed = rows.Count });
}
[HttpPost("rooms/{roomId:long}/bans/import")]
public Task<IActionResult> BareBansImport(long roomId, [FromBody] ImportRoomBansRequest req)
{
req.RoomId = roomId;
return ImportRoomBans(req);
}
private async Task<IActionResult> ApplyAndReturn(long roomId, Action<RoomEntity> mutator)
{
var room = await RequireOwnedRoomAsync(roomId);
if (room is null) return NotFound();
mutator(room);
room.UpdatedAt = DateTime.UtcNow;
try { await db.SaveChangesAsync(); }
catch (DbUpdateException) { return Conflict(new { Result = 2 }); }
return Ok(Services.RoomService.ToWireRoom(room));
}
}
@@ -0,0 +1,176 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
namespace DorkNet.Server.Controllers.API.Royale;
/// <summary>
/// api.rec.net/api/royale/* — Rec Royale battle-royale stats.
///
/// Per <c>RecNet.RecNetRecRoyaleStats</c> (decompiled at
/// <c>Cpp2IL_CS/.../RecNet/RecNetRecRoyaleStats.cs</c>) the client
/// only calls TWO endpoints:
///
/// • <c>GET api/royale/v1/current</c> → <c>RecRoyalePlayerProgress</c>:
/// <c>TotalXP, Level, RankIdx, RankName, CurrentLevelXPThreshold,
/// NextLevelXPThreshold, NextLevelAcornReward</c>.
/// • <c>POST api/royale/v2/matchcomplete</c> with body
/// <c>MatchCompleteStats</c>: <c>Rank, NumEliminations, SecondsAlive,
/// WalkGame, CustomGame, ChestsOpened, ShieldPotionsConsumed,
/// HealthPotionsConsumed, SecondsInAir</c>; returns <c>StatUpdates</c>:
/// <c>XPAwardStrings, TotalXPAwarded, NewProgress</c>.
///
/// We persist match rows to <see cref="RoyaleMatchEntity"/> +
/// <see cref="RoyaleMatchPlayerEntity"/> for server-side audit, and
/// roll the per-player <see cref="RoyalePlayerProgressEntity"/>.
/// </summary>
[ApiController]
public class RoyaleController(DorkNetDbContext db) : ControllerBase
{
// Rank ladder names (from the watch's RankIdx → RankName mapping).
// Approximated; the client's ladder isn't exposed in the dump but
// these are the standard 2020-era Royale tiers.
private static readonly string[] RankNames =
{
"Recruit", "Cadet", "Sergeant", "Lieutenant", "Captain",
"Major", "Colonel", "Commander", "Champion", "Legend",
};
private static long XpForLevel(int level) => 100L * level * level;
private static (int level, int rankIdx, string rankName, long currentThreshold, long nextThreshold, int reward)
Compute(long totalXp)
{
int lvl = 1;
while (XpForLevel(lvl + 1) <= totalXp) lvl++;
var rankIdx = Math.Min(RankNames.Length - 1, lvl / 5);
var current = XpForLevel(lvl);
var next = XpForLevel(lvl + 1);
var reward = 50 + 10 * lvl;
return (lvl, rankIdx, RankNames[rankIdx], current, next, reward);
}
[HttpGet("api/royale/v1/current")]
public async Task<IActionResult> Current()
{
var pid = this.CurrentPlayerId();
if (pid is not long me) return Ok(BlankProgress());
var row = await db.RoyalePlayerProgress.FirstOrDefaultAsync(p => p.PlayerId == me);
if (row is null) return Ok(BlankProgress());
return Ok(ToWireProgress(row));
}
public sealed class MatchCompleteStats
{
public int Rank { get; set; }
public int NumEliminations { get; set; }
public int SecondsAlive { get; set; }
public bool WalkGame { get; set; }
public bool CustomGame { get; set; }
public int ChestsOpened { get; set; }
public int ShieldPotionsConsumed { get; set; }
public int HealthPotionsConsumed { get; set; }
public int SecondsInAir { get; set; }
}
[HttpPost("api/royale/v2/matchcomplete")]
[Authorize]
public async Task<IActionResult> MatchComplete([FromBody] MatchCompleteStats body)
{
var pid = this.RequireCurrentPlayerId();
if (body is null) return BadRequest("missing body");
var match = new RoyaleMatchEntity
{
CompletedAt = DateTime.UtcNow,
Rank = body.Rank,
NumEliminations = body.NumEliminations,
SecondsAlive = body.SecondsAlive,
WalkGame = body.WalkGame,
CustomGame = body.CustomGame,
ChestsOpened = body.ChestsOpened,
ShieldPotionsConsumed = body.ShieldPotionsConsumed,
HealthPotionsConsumed = body.HealthPotionsConsumed,
SecondsInAir = body.SecondsInAir,
};
db.RoyaleMatches.Add(match);
await db.SaveChangesAsync();
db.RoyaleMatchPlayers.Add(new RoyaleMatchPlayerEntity
{
MatchId = match.Id,
PlayerId = pid,
Rank = body.Rank,
NumEliminations = body.NumEliminations,
SecondsAlive = body.SecondsAlive,
});
// Compute XP awards. Match the watch's pattern:
// • Survival: 1 XP per 10 seconds alive
// • Eliminations: 25 XP each
// • Placement: bonus inversely proportional to rank
// • Custom games don't award XP
var awards = new List<(string label, int xp)>();
if (!body.CustomGame)
{
var survivalXp = body.SecondsAlive / 10;
var killXp = body.NumEliminations * 25;
var rankXp = Math.Max(0, 200 - body.Rank * 5);
if (survivalXp > 0) awards.Add(($"Time Alive: {body.SecondsAlive}s", survivalXp));
if (killXp > 0) awards.Add(($"Eliminations: {body.NumEliminations}", killXp));
if (rankXp > 0) awards.Add(($"Rank #{body.Rank}", rankXp));
if (body.Rank == 1) awards.Add(("Victory Royale!", 500));
}
var totalAward = awards.Sum(a => a.xp);
// Update progress.
var row = await db.RoyalePlayerProgress.FirstOrDefaultAsync(p => p.PlayerId == pid);
if (row is null)
{
row = new RoyalePlayerProgressEntity { PlayerId = pid };
db.RoyalePlayerProgress.Add(row);
}
row.TotalXP += totalAward;
var (lvl, rankIdx, rankName, current, next, reward) = Compute(row.TotalXP);
row.Level = lvl;
row.RankIdx = rankIdx;
row.RankName = rankName;
row.CurrentLevelXPThreshold = current;
row.NextLevelXPThreshold = next;
row.NextLevelAcornReward = reward;
row.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return Ok(new
{
XPAwardStrings = awards.Select(a => $"+{a.xp} XP — {a.label}").ToList(),
TotalXPAwarded = (long)totalAward,
NewProgress = new[] { ToWireProgress(row) },
});
}
private static object ToWireProgress(RoyalePlayerProgressEntity p) => new
{
p.TotalXP,
p.Level,
p.RankIdx,
p.RankName,
p.CurrentLevelXPThreshold,
p.NextLevelXPThreshold,
p.NextLevelAcornReward,
};
private static object BlankProgress() => new
{
TotalXP = 0L,
Level = 1,
RankIdx = 0,
RankName = "Recruit",
CurrentLevelXPThreshold = 0L,
NextLevelXPThreshold = 100L,
NextLevelAcornReward = 50,
};
}
@@ -0,0 +1,118 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.API.Sanitize;
/// <summary>
/// api.{rec.net,localhost}/api/sanitize/* — server-side profanity filter
/// the watch's text-input UI calls before allowing a room name /
/// invention name / chat message to be submitted. Delegates to
/// <see cref="ProfanityFilter"/> for the actual scrubbing. Response
/// shape: <c>{Text (purified), IsClean (bool)}</c>.
/// </summary>
[ApiController]
public class SanitizeController(ILogger<SanitizeController> logger) : ControllerBase
{
[HttpPost("api/sanitize/v1")]
[HttpPost("api/sanitize/v1/text")]
public async Task<IActionResult> Sanitize()
{
// RecRoom.StringSanitization.PurifyString sends JSON body
// {"Value":"<text>","ReplacementChar":42} — verified from a
// live chat-send error trace ("Received malformed RecNet
// response: ...Text:{\"Value\":...}..."). Earlier we used to
// pass the whole body through as "text", which made Text in
// the response contain the literal JSON string, and the watch's
// LitJson then choked on the malformed wrap.
string text = string.Empty;
try
{
if (Request.HasFormContentType)
text = Request.Form["Text"].ToString();
}
catch { }
if (string.IsNullOrEmpty(text))
{
using var reader = new StreamReader(Request.Body);
var raw = await reader.ReadToEndAsync();
if (!string.IsNullOrEmpty(raw))
{
var first = raw.AsSpan().TrimStart();
if (first.Length > 0 && first[0] == '{')
{
try
{
using var doc = System.Text.Json.JsonDocument.Parse(raw);
var root = doc.RootElement;
if (root.ValueKind == System.Text.Json.JsonValueKind.Object)
{
if (root.TryGetProperty("Value", out var vEl) && vEl.ValueKind == System.Text.Json.JsonValueKind.String)
text = vEl.GetString() ?? string.Empty;
else if (root.TryGetProperty("Text", out var tEl) && tEl.ValueKind == System.Text.Json.JsonValueKind.String)
text = tEl.GetString() ?? string.Empty;
else if (root.TryGetProperty("Input", out var iEl) && iEl.ValueKind == System.Text.Json.JsonValueKind.String)
text = iEl.GetString() ?? string.Empty;
}
}
catch { text = raw; }
}
else
{
text = raw;
}
}
}
// StringSanitization.PurifyString_d__9 chains Core.Post to
// ApiCallback<String> via ExpectPrimitiveResponse, so the watch's
// LitJson importer wants a BARE JSON string (e.g. "dwadwa"), not
// an object body. Returning {Text, IsClean} as we did before
// makes LitJson hand the Dictionary to Convert.ChangeType(_, typeof(string))
// which throws "Object must implement IConvertible". Strip the
// envelope — but use Content() with explicit application/json so
// ASP.NET Core's StringOutputFormatter doesn't serialize as
// text/plain (raw string sans quotes), which the watch then sees
// as an empty cleanVersion after LitJson decode.
var purified = ProfanityFilter.Purify(text);
var json = System.Text.Json.JsonSerializer.Serialize(purified);
logger.LogInformation("[sanitize] input=\"{Input}\" purified=\"{Purified}\" json={Json}", text, purified, json);
return Content(json, "application/json");
}
public sealed class SanitizeBody
{
public string? Text { get; set; }
public string? Input { get; set; }
public string? Value { get; set; }
}
/// <summary>POST <c>api/sanitize/v1/purifyString</c> — returns
/// <c>{Text}</c> with profanity replaced. Wire shape matches
/// <c>StringSanitization.PurifyString</c>.</summary>
[HttpPost("api/sanitize/v1/purifyString")]
public IActionResult PurifyString(
[FromBody] SanitizeBody? body,
[FromForm(Name = "Text")] string? textForm,
[FromForm(Name = "Input")] string? inputForm,
[FromForm(Name = "Value")] string? valueForm,
[FromQuery(Name = "text")] string? textQuery)
{
var input = body?.Text ?? body?.Input ?? body?.Value
?? textForm ?? inputForm ?? valueForm ?? textQuery ?? string.Empty;
return Ok(new { Text = ProfanityFilter.Purify(input) });
}
/// <summary>POST <c>api/sanitize/v1/requestIsStringPure</c> —
/// returns a bare JSON bool. Watch's <c>ExpectPrimitiveResponse</c>
/// rejects object bodies here.</summary>
[HttpPost("api/sanitize/v1/requestIsStringPure")]
public IActionResult IsStringPure(
[FromBody] SanitizeBody? body,
[FromForm(Name = "Text")] string? textForm,
[FromQuery(Name = "text")] string? textQuery)
{
var input = body?.Text ?? body?.Input ?? body?.Value
?? textForm ?? textQuery ?? string.Empty;
return Ok(ProfanityFilter.IsClean(input));
}
}
@@ -0,0 +1,101 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
using System.Text.Json.Serialization;
namespace DorkNet.Server.Controllers.API.Settings.V2;
/// <summary>
/// api.rec.net/api/settings/v2 — player settings download / set / remove.
/// URL patterns (verified by disassembling RecNet.Settings$$DowloadLocalPlayerSettings
/// at RVA 0xAF55B0 and RecNet.Settings$$StoreLocalPlayerSetting at RVA 0xAF5650):
///
/// GET api/settings/v2/ — returns List&lt;{Key, Value}&gt;
/// POST api/settings/v2/set — body { Key, Value } (set or update one)
/// POST api/settings/v2/remove — body { Key } (delete one)
///
/// The CLIENT side parses the GET response with Util.DeserializeList&lt;Setting&gt;
/// and reads each entry's "Key" and "Value" fields (verified by string literal
/// references in the deserializer body — both are PascalCase).
///
/// Returning a Dictionary&lt;string,string&gt; here serializes as "{}"
/// when empty, which the client casts to a List and dies with
/// InvalidCastException: Unable to cast object of type 'Dictionary`2' to
/// type 'List`1'. Always return a real List.
/// </summary>
[ApiController]
[Route("api/[controller]/v2")]
[Authorize]
public class SettingsController(DorkNetDbContext db) : ControllerBase
{
private long CurrentPlayerId => this.RequireCurrentPlayerId();
[HttpGet]
[HttpGet("get")]
public async Task<IActionResult> GetSettings()
{
var settings = await db.PlayerSettings
.Where(s => s.PlayerId == CurrentPlayerId)
.Select(s => new SettingDto { Key = s.Key, Value = s.Value })
.ToListAsync();
return Ok(settings);
}
[HttpPost("set")]
public async Task<ActionResult> SetSetting([FromBody] SetSettingRequest req)
{
var existing = await db.PlayerSettings
.FirstOrDefaultAsync(s => s.PlayerId == CurrentPlayerId && s.Key == req.Key);
if (existing is null)
{
db.PlayerSettings.Add(new PlayerSettingEntity
{
PlayerId = CurrentPlayerId,
Key = req.Key,
Value = req.Value,
});
}
else
{
existing.Value = req.Value;
}
await db.SaveChangesAsync();
return Ok(new SettingDto { Key = req.Key, Value = req.Value });
}
[HttpPost("remove")]
public async Task<ActionResult> RemoveSetting([FromBody] SetSettingRequest req)
{
var existing = await db.PlayerSettings
.FirstOrDefaultAsync(s => s.PlayerId == CurrentPlayerId && s.Key == req.Key);
if (existing is not null)
{
db.PlayerSettings.Remove(existing);
await db.SaveChangesAsync();
}
return Ok();
}
}
public class SettingDto
{
[JsonPropertyName("Key")]
public string Key { get; set; } = string.Empty;
[JsonPropertyName("Value")]
public string Value { get; set; } = string.Empty;
}
public class SetSettingRequest
{
[JsonPropertyName("Key")]
public string Key { get; set; } = string.Empty;
[JsonPropertyName("Value")]
public string Value { get; set; } = string.Empty;
}
@@ -0,0 +1,186 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using DorkNet.Server.Auth;
using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.API.Store;
/// <summary>
/// api.rec.net/api/purchase/v1/* — real purchase flow that verifies
/// the player has enough currency, deducts it, and grants the item
/// to their inventory. Replaces the old "ack-everything" stubs in
/// AllEndpointsController that let the watch think it bought
/// something but never actually persisted the transaction.
///
/// The 2020 client follows a three-step flow when the player taps
/// "buy" on a Shop item:
/// 1. POST initiatepurchase — server validates the item exists +
/// player has funds, returns a transaction id (we just echo
/// back the item id; no real reservation needed for a private
/// server's threat model).
/// 2. POST processpurchase — server actually deducts currency,
/// grants the item to the avatar's InventoryJson list, returns
/// the new balance.
/// 3. POST completepurchase — final ack; client uses this to
/// flip its local UI state from "purchasing…" to "owned".
/// We keep all three so the watch's flow doesn't deadlock waiting
/// for one or another, but the actual mutation happens in
/// processpurchase. initiate and complete just validate + ack.
/// </summary>
[ApiController]
[Authorize]
public class PurchaseController(StoreService store, ILogger<PurchaseController> logger) : ControllerBase
{
public sealed record PurchaseRequest(long? ItemId, string? Slug, int? Quantity);
/// <summary>POST initiatepurchase — validate the item exists and
/// the caller has enough currency, but don't mutate yet. Returns
/// the resolved item id so the watch can echo it back on
/// processpurchase.</summary>
[HttpPost("api/purchase/v1/initiatepurchase")]
[HttpPost("api/purchase/v2/initiatepurchase")]
[HttpPost("api/purchase/v3/initiatepurchase")]
public async Task<IActionResult> Initiate([FromForm] PurchaseRequest body)
{
var item = await ResolveItemAsync(body);
if (item is null) return Ok(new { success = false, error = "item_not_found" });
return Ok(new
{
success = true,
error = "",
ItemId = item.Id,
Slug = item.Slug,
// Hand back a token the watch may include in
// processpurchase. We accept anything on the way in, so
// this is informational.
TransactionId = $"txn-{item.Id}-{DateTime.UtcNow.Ticks}",
});
}
/// <summary>POST processpurchase — atomic charge + grant. The
/// real work. Returns success+balance on completion or the
/// specific error code on failure (insufficient_funds,
/// item_not_available, etc.) so the watch can show the right UI
/// message.</summary>
[HttpPost("api/purchase/v1/processpurchase")]
[HttpPost("api/purchase/v2/processpurchase")]
[HttpPost("api/purchase/v3/processpurchase")]
public async Task<IActionResult> Process([FromForm] PurchaseRequest body)
{
var pid = this.RequireCurrentPlayerId();
var item = await ResolveItemAsync(body);
if (item is null) return Ok(new { success = false, error = "item_not_found" });
var result = await store.PurchaseAsync(pid, item.Id);
logger.LogInformation(
"[purchase] player={Pid} item={Item} success={Success} error={Error} balance={Balance}",
pid, item.Slug, result.Success, result.Error ?? "", result.Balance ?? 0);
return Ok(new
{
success = result.Success,
error = result.Error ?? "",
Balance = result.Balance ?? 0,
CurrencyType = item.CurrencyType,
ItemId = item.Id,
Slug = result.Slug ?? item.Slug,
});
}
/// <summary>POST completepurchase — final ack. Client flips its
/// UI to "owned" state and refreshes the avatar selector.</summary>
[HttpPost("api/purchase/v1/completepurchase")]
[HttpPost("api/purchase/v2/completepurchase")]
[HttpPost("api/purchase/v3/completepurchase")]
public IActionResult Complete() =>
Ok(new { success = true, error = "" });
/// <summary>POST cancelpurchase — client retreated mid-flow
/// (closed the panel, network blip, etc.). Nothing to roll back
/// since we don't reserve in initiate.</summary>
[HttpPost("api/purchase/v1/cancelpurchase")]
[HttpPost("api/purchase/v2/cancelpurchase")]
[HttpPost("api/purchase/v3/cancelpurchase")]
public IActionResult Cancel() =>
Ok(new { success = true, error = "" });
/// <summary>POST cleanuppending — client startup probe to release
/// any stale "in-flight" purchases from a crashed previous
/// session. We don't track in-flight state, so always-ack is
/// correct.</summary>
[HttpPost("api/purchase/v1/cleanuppending")]
[HttpPost("api/purchase/v2/cleanuppending")]
[HttpPost("api/purchase/v3/cleanuppending")]
public IActionResult CleanupPending() =>
Ok(new { success = true, error = "" });
/// <summary>POST <c>subscription/v1/*</c> — Rec Room+. The private
/// server has no real billing integration, so buying RR+ is a yes/ack
/// operation and cancel is also acknowledged. Returning an active
/// subscription object keeps the client out of platform IAP UI.</summary>
[HttpPost("/subscription/v1")]
[HttpPost("/subscription/v1/purchase")]
[HttpPost("/subscription/v1/buy")]
[HttpPost("/subscription/v1/cancel")]
[HttpPost("/api/subscription/v1")]
[HttpPost("/api/subscription/v1/purchase")]
[HttpPost("/api/subscription/v1/buy")]
[HttpPost("/api/subscription/v1/cancel")]
public IActionResult SubscriptionAck()
{
var pid = this.CurrentPlayerId() ?? 0;
logger.LogInformation("[subscription] ack requested by player {Pid}", pid);
return Ok(SubscriptionResponse(pid));
}
[HttpGet("/subscription/v1")]
[HttpGet("/subscription/v1/current")]
[HttpGet("/subscription/v1/mine")]
[HttpGet("/api/subscription/v1")]
[HttpGet("/api/subscription/v1/current")]
[HttpGet("/api/subscription/v1/mine")]
public IActionResult SubscriptionStatus() =>
Ok(SubscriptionResponse(this.CurrentPlayerId() ?? 0));
private static Dictionary<string, object?> SubscriptionResponse(long playerId)
{
var now = DateTime.UtcNow;
var pascal = new Dictionary<string, object?>
{
["SubscriptionId"] = $"dorknet-rrplus-{playerId}",
["RecNetPlayerId"] = playerId,
["IsActive"] = true,
["StartedAt"] = now,
["CurrentPeriodEnd"] = now.AddYears(10),
["RenewalDate"] = now.AddYears(10),
};
var camel = new Dictionary<string, object?>
{
["subscriptionId"] = $"dorknet-rrplus-{playerId}",
["recNetPlayerId"] = playerId,
["isActive"] = true,
["startedAt"] = now,
["currentPeriodEnd"] = now.AddYears(10),
["renewalDate"] = now.AddYears(10),
};
return new Dictionary<string, object?>
{
["success"] = true,
["error"] = "",
["IsSubscribed"] = true,
["isSubscribed"] = true,
["Active"] = true,
["active"] = true,
["Subscription"] = pascal,
["subscription"] = camel,
};
}
private async Task<Data.Entities.StoreItemEntity?> ResolveItemAsync(PurchaseRequest body)
{
if (body.ItemId is long id && id > 0)
return await store.GetByIdAsync(id);
if (!string.IsNullOrWhiteSpace(body.Slug))
return await store.GetBySlugAsync(body.Slug);
return null;
}
}
@@ -0,0 +1,422 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using DorkNet.Models.Notification;
using DorkNet.Server.Auth;
using DorkNet.Server.Controllers.API.RoomKeys;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.API.Store;
/// <summary>
/// api.rec.net/api/storefronts/v2/{buyItem,buyTier,buyElite} — the
/// secondary purchase URL family used by the watch's gift-drop
/// tiles and season-pass tier buttons. Wire request matches
/// <c>RecNet.RequestPurchaseItemDTO</c>:
/// <c>StorefrontType, PurchasableItemId, CurrencyType,
/// CouponConsumablePlayerMappingId, Gift</c>.
///
/// Response is <c>PurchaseBalanceUpdateResponseDTO</c>:
/// <c>BalanceUpdates: List&lt;BalanceUpdateDTO&gt;</c>. The client
/// applies these balance deltas optimistically + reads the optional
/// gift package from inventory after.
///
/// The primary purchase URL (<c>api/purchase/v1/*</c>) is handled
/// elsewhere by PurchaseController; this just covers the v2 surface.
/// </summary>
[ApiController]
[Authorize]
public class StorefrontsBuyController(
DorkNetDbContext db,
LevelService level,
NotificationService notifications) : ControllerBase
{
private long Me => this.RequireCurrentPlayerId();
public sealed class RequestPurchaseItemDto
{
public int StorefrontType { get; set; }
public int PurchasableItemId { get; set; }
public int CurrencyType { get; set; } = 2;
public long? CouponConsumablePlayerMappingId { get; set; }
public GiftDto? Gift { get; set; }
}
public sealed class GiftDto
{
public long ToPlayerId { get; set; }
public string? Message { get; set; }
public bool Anonymous { get; set; }
public int GiftContext { get; set; }
}
[HttpPost("api/storefronts/v2/buyItem")]
public async Task<IActionResult> BuyItem([FromBody] RequestPurchaseItemDto req)
{
if (req is null) return BadRequest("missing body");
var pid = Me;
var item = await db.StoreItems.FirstOrDefaultAsync(s => s.Id == req.PurchasableItemId && s.IsActive);
if (item is null) return NotFound(BalanceUpdateResponse(0, 2 /* CurrencyType */, null, null));
var balance = await level.GetBalanceAsync(pid, req.CurrencyType);
if (balance < item.Price)
{
// Insufficient funds — return current balance, no item granted.
return Ok(BalanceUpdateResponse(balance, req.CurrencyType, null, null));
}
// Gift block (when present) means the BUYER spends the tokens but
// the ITEM goes to Gift.ToPlayerId. Self-gifting is disallowed —
// the watch's gift UI prevents it, but guard server-side too.
var giftTarget = req.Gift is { ToPlayerId: > 0 } g && g.ToPlayerId != pid
? g
: null;
var recipient = giftTarget?.ToPlayerId ?? pid;
var isGift = giftTarget is not null;
var newBalance = await level.GrantCurrencyAsync(pid, req.CurrencyType, -item.Price,
isGift ? $"giftItem:{item.Slug}->{recipient}" : $"buyItem:{item.Slug}");
// Grant the item to the RECIPIENT (= buyer for self-purchase, gift
// target for gift purchase). Avatar payloads append to
// AvatarEntity.InventoryJson; consumables and everything else
// insert a PlayerInventory row.
if (StoreService.TryGetAvatarItemPayload(item.Slug, out _, out var avatarItemDesc))
{
await GrantWardrobeAsync(recipient, StoreService.InventoryAvatarItemDesc(avatarItemDesc));
}
else
{
await GrantInventoryAsync(recipient, item.Slug);
}
// Insert a real GiftPackageEntity for the gift-box flow. The watch
// shows a gift-box animation after a purchase, then POSTs the
// package Id to /api/avatar/v2/gifts/consume/ to claim it. If the
// Id isn't a real row, that consume returns 404 and the watch
// logs 'Server failed to consume gift … HTTP Error 404' — the
// user's symptom on every successful buy. By writing the row
// synchronously and returning its DB-assigned Id in the
// BalanceUpdate response, the subsequent consume succeeds.
StoreService.TryGetAvatarItemPayload(item.Slug, out var avatarItemType, out avatarItemDesc);
var inventoryAvatarDesc = StoreService.InventoryAvatarItemDesc(avatarItemDesc);
StoreService.TryGetConsumableItemDesc(item.Slug, out var consumableItemDesc);
if (StoreService.TryGetHairDyePayload(item.Slug, out var hairDyeConsumableDesc, out var hairDyeColorGuid))
{
avatarItemType = 1;
inventoryAvatarDesc = hairDyeColorGuid;
consumableItemDesc = hairDyeConsumableDesc;
}
var gift = new GiftPackageEntity
{
RecipientPlayerId = recipient,
FromPlayerId = isGift && !giftTarget!.Anonymous ? (int?)pid : null,
AvatarItemType = string.IsNullOrEmpty(inventoryAvatarDesc) ? null : avatarItemType,
AvatarItemDescOrHairDyeDesc = inventoryAvatarDesc,
ConsumableItemDesc = consumableItemDesc,
CurrencyType = req.CurrencyType,
Currency = 0,
Xp = 0,
Level = 1,
GiftContext = isGift ? giftTarget!.GiftContext : 0,
GiftRarity = 0,
Message = isGift ? (giftTarget!.Message ?? string.Empty) : string.Empty,
Platform = -1,
PackageVariant = "Standard",
PackageMaterial = string.Empty,
Consumed = false,
IsValid = true,
SupportsCurrentPlatform = true,
};
db.GiftPackages.Add(gift);
await db.SaveChangesAsync();
// Push the buyer's balance update so their wallet UI refreshes.
await notifications.NotifyAsync(pid,
PushNotificationId.StorefrontBalancePurchase,
new { req.CurrencyType, Balance = newBalance, ItemId = item.Id, item.Slug });
if (isGift)
{
// Tell the recipient a gift is waiting. The watch dispatches
// GiftPackageReceived (id=30) to refresh /api/avatar/v2/gifts
// and pop the "you got a gift" toast; the gift box itself
// appears next time the recipient opens their inventory.
await notifications.NotifyAsync(recipient,
PushNotificationId.GiftPackageReceived,
new
{
Id = gift.Id,
Platform = -1,
Xp = 0,
Level = 1,
GiftRarity = 0,
GiftContext = gift.GiftContext,
FromPlayerId = gift.FromPlayerId,
AvatarItemType = gift.AvatarItemType,
AvatarItemDesc = avatarItemDesc,
ConsumableItemDesc = gift.ConsumableItemDesc ?? string.Empty,
Message = gift.Message ?? string.Empty,
});
}
// For gift purchases the buyer doesn't get a box — their flow
// ends with the balance update. Returning an empty Data array
// (giftPackageId = null) makes the watch's BuyItem callback
// skip the gift-receive animation on the sender's screen.
return Ok(BalanceUpdateResponse(newBalance, req.CurrencyType,
isGift ? null : item.Slug,
isGift ? null : gift.Id));
}
[HttpPost("api/storefronts/v2/buyTier")]
public async Task<IActionResult> BuyTier([FromBody] RequestPurchaseItemDto req)
{
// Season-pass tier: no per-item table, so we just deduct a
// small fixed cost (1 token) per tier and ack. A full
// season-pass system would have a SeasonProgressEntity.
var pid = Me;
const int TierCost = (int)StoreService.StoreItemPrice;
var balance = await level.GetBalanceAsync(pid, req.CurrencyType);
if (balance < TierCost) return Ok(BalanceUpdateResponse(balance, req.CurrencyType));
var newBalance = await level.GrantCurrencyAsync(pid, req.CurrencyType, -TierCost,
$"buyTier:{req.PurchasableItemId}");
return Ok(BalanceUpdateResponse(newBalance, req.CurrencyType));
}
[HttpPost("api/storefronts/v2/buyElite")]
public async Task<IActionResult> BuyElite([FromBody] RequestPurchaseItemDto req)
{
// Elite-tier purchase (paid premium track). Without real
// money flow on a private server we just ack.
var pid = Me;
var balance = await level.GetBalanceAsync(pid, req.CurrencyType);
return Ok(BalanceUpdateResponse(balance, req.CurrencyType));
}
/// <summary>GET <c>api/storefronts/v1/buyRoomKey</c> — purchase a
/// persisted room key. The 2020.12 client sends roomKeyId and the
/// price it displayed so we reject stale-price purchases.</summary>
[HttpGet("api/storefronts/v1/buyRoomKey")]
public async Task<IActionResult> BuyRoomKey(
[FromQuery] long roomKeyId,
[FromQuery] int requestedPrice)
{
var pid = Me;
var key = await db.RoomKeys.FirstOrDefaultAsync(k => k.Id == roomKeyId && !k.IsDeleted);
if (key is null)
return Ok(RoomKeyPurchaseResponse(RoomKeyStatus.DoesNotExist, null, await level.GetBalanceAsync(pid, 2)));
if (key.CreatorPlayerId == pid)
return Ok(RoomKeyPurchaseResponse(RoomKeyStatus.PlayerAlreadyOwns, key, await level.GetBalanceAsync(pid, 2)));
if (requestedPrice != key.Price)
return Ok(RoomKeyPurchaseResponse(RoomKeyStatus.InvalidParameters, key, await level.GetBalanceAsync(pid, 2)));
var existing = await db.RoomKeyPurchases
.FirstOrDefaultAsync(p => p.RoomKeyId == key.Id && p.PlayerId == pid);
if (existing is not null)
return Ok(RoomKeyPurchaseResponse(RoomKeyStatus.PlayerAlreadyOwns, key, await level.GetBalanceAsync(pid, 2)));
var balance = await level.GetBalanceAsync(pid, 2);
if (balance < key.Price)
return Ok(RoomKeyPurchaseResponse(RoomKeyStatus.PurchaseFailed, key, balance));
var newBalance = await level.GrantCurrencyAsync(pid, 2, -key.Price, $"buyRoomKey:{key.Id}");
db.RoomKeyPurchases.Add(new RoomKeyPurchaseEntity
{
RoomKeyId = key.Id,
PlayerId = pid,
PaidPrice = key.Price,
});
await db.SaveChangesAsync();
if (key.CreatorPlayerId != pid && key.Price > 0)
await level.GrantCurrencyAsync(key.CreatorPlayerId, 2, key.Price, $"sellRoomKey:{key.Id}");
return Ok(RoomKeyPurchaseResponse(RoomKeyStatus.Success, key, newBalance));
}
// ── Helpers ──────────────────────────────────────────────────────────
private async Task GrantWardrobeAsync(long pid, string guid)
{
var avatar = await db.Avatars.FirstOrDefaultAsync(a => a.PlayerId == pid);
if (avatar is null)
{
avatar = new AvatarEntity { PlayerId = pid };
db.Avatars.Add(avatar);
}
try
{
var list = System.Text.Json.JsonSerializer
.Deserialize<List<string>>(avatar.InventoryJson) ?? new();
if (!list.Contains(guid))
{
list.Add(guid);
avatar.InventoryJson = System.Text.Json.JsonSerializer.Serialize(list);
avatar.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
}
}
catch
{
avatar.InventoryJson = System.Text.Json.JsonSerializer.Serialize(new[] { guid });
avatar.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
}
}
private async Task GrantInventoryAsync(long pid, string slug)
{
var row = await db.PlayerInventory
.FirstOrDefaultAsync(p => p.PlayerId == pid && p.ItemSlug == slug);
if (row is null)
{
db.PlayerInventory.Add(new PlayerInventoryEntity
{
PlayerId = pid,
ItemSlug = slug,
Quantity = 1,
});
}
else
{
row.Quantity += 1;
}
await db.SaveChangesAsync();
}
private static object BalanceUpdateResponse(long balance, int currencyType,
string? grantedSlug = null, long? giftPackageId = null)
{
// PurchaseBalanceUpdateResponseDTO<GiftPackage> wire shape, verified
// against Cpp2IL_ISIL/.../RecNet/Avatars_NestedType_GiftPackage.txt:
//
// PurchaseBalanceUpdateResponseDTO EXTENDS BalanceResponseDTO,
// so the watch first runs BalanceResponseDTO.Deserialize which
// does Util.GetKey<long>("Balance") / GetKey<int>("CurrencyType")
// / GetKey<int>("BalanceType") — REQUIRED, KeyNotFoundException
// on miss. Mirror the trio on the OUTER object.
//
// Then it reads BalanceUpdates: List<BalanceUpdateDTO<GiftPackage>>.
// Each inner BalanceUpdateDTO has UpdateResponse + Data, where
// Data is List<GiftPackage>. GiftPackage.Deserialize REQUIRES:
// "Id" (long) | Util.GetKey
// "Platform" (int) | Util.GetKey
// "Xp" (int) | Util.GetKey
// "Level" (int) | Util.GetKey
// "GiftRarity" (int) | Util.GetKey
// "GiftContext" (int) | Util.GetKey
// Plus optional FromPlayerId / AvatarItemType / AvatarItemDesc /
// ConsumableItemDesc / EquipmentPrefabName / EquipmentModificationGuid /
// CurrencyType / Currency / Message.
//
// The previous shape shipped {CurrencyType,Balance,BalanceType,Platform}
// in the Data array — those don't match GiftPackage's required keys,
// so Util.GetKey<long>("Id") threw KeyNotFoundException, the
// ApiCallback received a non-null error string, and StorePurchaseItem
// Screen logged the error as "Null". The user's
// output_log.txt:920 shows that callback path firing on every
// purchase attempt. New shape ships a real (synthetic) GiftPackage
// with all required keys + the granted item's GUID under
// AvatarItemDesc so the watch's gift-receive flow can preview the
// unlocked item.
//
// CRITICAL: the watch follows up every purchase with a POST to
// /api/avatar/v2/gifts/consume/ containing this Id. If the Id
// isn't a real GiftPackageEntity row in the DB, consume returns
// 404 and the watch's <RunConsumeGift> logs 'Server failed to
// consume gift … HTTP Error 404', which the player-destroy
// cascade picks up on (the user's symptom on every buy:
// game crashes after opening the gift box). Caller MUST pass
// <paramref name="giftPackageId"/> = the saved
// GiftPackageEntity.Id. The previous code used
// DateTime.UtcNow.Ticks as a fake id.
var giftId = giftPackageId ?? DateTime.UtcNow.Ticks;
// Pull the GUID off the slug for wardrobe-* purchases so the
// watch's GiftPackage receives the AvatarItem GUID and can
// render the unlock animation. dorknet-* slugs leave the desc
// empty (those are non-wardrobe SKUs).
var avatarItemType = 0;
var avatarItemDesc = string.Empty;
var consumableItemDesc = string.Empty;
if (grantedSlug is not null)
{
StoreService.TryGetAvatarItemPayload(grantedSlug, out avatarItemType, out avatarItemDesc);
StoreService.TryGetConsumableItemDesc(grantedSlug, out consumableItemDesc);
if (StoreService.TryGetHairDyePayload(grantedSlug, out var hairDyeConsumableDesc, out var hairDyeColorGuid))
{
avatarItemType = 1;
avatarItemDesc = hairDyeColorGuid;
consumableItemDesc = hairDyeConsumableDesc;
}
}
var giftPackage = new
{
// REQUIRED via Util.GetKey — KeyNotFoundException on miss.
// Verified keys at NLEKGNENMCO_NestedType_LOCNECLOHCA.txt:792-946:
// Id, Message, FromPlayerId, AvatarItemType, AvatarItemDesc,
// ConsumableItemDesc, EquipmentPrefabName,
// EquipmentModificationGuid, Platform, PlatformsToSpawnOn,
// BalanceType, CurrencyType, Currency, Xp, GiftRarity,
// GiftContext. Missing any → "Failed to download gifts:
// Malformed Response" (and on a purchase path, the buy
// times out because the response never deserializes).
Id = giftId,
Platform = 0,
PlatformsToSpawnOn = -1,
BalanceType = 0,
Xp = 0,
Level = 1,
GiftRarity = 0,
GiftContext = 0,
// Optional — surfaced to the watch's gift-preview UI.
FromPlayerId = (int?)null,
AvatarItemType = string.IsNullOrEmpty(avatarItemDesc) ? (int?)null : avatarItemType,
AvatarItemDesc = avatarItemDesc,
AvatarItemDescOrHairDyeDesc = StoreService.InventoryAvatarItemDesc(avatarItemDesc),
ConsumableItemDesc = consumableItemDesc,
EquipmentPrefabName = string.Empty,
EquipmentModificationGuid = string.Empty,
CurrencyType = currencyType,
Currency = 0,
Message = string.Empty,
Consumed = false,
ErrorMessage = string.Empty,
};
return new
{
// BalanceResponseDTO required trio.
Balance = balance,
CurrencyType = currencyType,
BalanceType = 0,
// PurchaseBalanceUpdateResponseDTO.BalanceUpdates =
// List<BalanceUpdateDTO<GiftPackage>>.
BalanceUpdates = new[]
{
new
{
UpdateResponse = 0, // UpdateResponseTypes.Success
Data = grantedSlug is null
? Array.Empty<object>()
: new object[] { giftPackage },
},
},
};
}
private static object RoomKeyPurchaseResponse(RoomKeyStatus status, RoomKeyEntity? key, long balance) => new
{
RoomKeyResponse = RoomKeysController.RoomKeyResponse(status, key),
BalanceUpdateResponse = new
{
Balance = balance,
CurrencyType = 2,
BalanceType = 0,
BalanceUpdates = Array.Empty<object>(),
},
};
}
@@ -0,0 +1,515 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using System.Text.Json;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.API.Store;
/// <summary>
/// api.rec.net/api/storefronts/v* — real implementation backed by the
/// <see cref="StoreService"/> catalog (StoreItems table). Replaces the
/// previous AllEndpointsController stubs that returned empty lists,
/// which made the watch's Shop tab look broken.
///
/// Wire shape: every endpoint returns either a list of items (for
/// catalog endpoints) or a StorefrontDTO with a StoreItems array.
/// </summary>
[ApiController]
public class StorefrontsController(
StoreService store,
DorkNetDbContext db,
IConfiguration config,
LevelService level,
DomainConfig domain) : ControllerBase
{
/// <summary>GET api/storefronts/v3/all — full active catalog. The
/// watch's Shop tab uses this to populate every category.</summary>
[HttpGet("api/storefronts/v1/all")]
[HttpGet("api/storefronts/v2/all")]
[HttpGet("api/storefronts/v3/all")]
[AllowAnonymous]
public async Task<IActionResult> All()
{
var items = await store.GetAllActiveAsync();
return Ok(items.Select(i => StoreService.ToItemDto(i, domain.Apex)).ToArray());
}
/// <summary>GET api/storefronts/v3/skus — alias for the full
/// catalog. Some 2020 client paths call this instead of /all
/// during the watch's "browse all SKUs" flow.</summary>
[HttpGet("api/storefronts/v3/skus")]
[AllowAnonymous]
public async Task<IActionResult> Skus()
{
var items = await store.GetAllActiveAsync();
return Ok(items.Select(i => StoreService.ToItemDto(i, domain.Apex)).ToArray());
}
/// <summary>GET <c>api/storefronts/v1/p2p/betaEnabled</c> —
/// raw Boolean gate for player-to-player sales. Returning false
/// disables the beta affordance without lying about catalog state.</summary>
[HttpGet("api/storefronts/v1/p2p/betaEnabled")]
[AllowAnonymous]
public IActionResult P2PBetaEnabled() => Content("false", "application/json");
/// <summary>GET <c>api/storefronts/v1/trialInvention/duration</c>.
/// The 2020.12 client's public promise projects the Duration
/// property from this object, so the wire body is not a bare int.</summary>
[HttpGet("api/storefronts/v1/trialInvention/duration")]
[AllowAnonymous]
public IActionResult TrialInventionDuration() => Ok(new
{
Duration = Math.Max(0, config.GetValue("Store:TrialInventionDurationSeconds", 300)),
});
/// <summary>GET api/storefronts/v3/season — items currently in the
/// season-pass storefront. Empty for now since we don't run
/// seasons; returning an empty list rather than the catch-all so
/// the deserialiser sees the right shape.</summary>
[HttpGet("api/storefronts/v3/season")]
[AllowAnonymous]
public async Task<IActionResult> Season()
{
var items = await store.GetActiveByStorefrontAsync("season:1");
return Ok(items.Select(i => StoreService.ToItemDto(i, domain.Apex)).ToArray());
}
/// <summary>GET api/storefronts/v3/giftdrop — list of available
/// gift-drop storefront ids. The watch then queries each one via
/// /giftdropstore/{id} below to render the rotating shelf.</summary>
[HttpGet("api/storefronts/v3/giftdrop")]
[AllowAnonymous]
public IActionResult GiftDropList() =>
Ok(StoreService.GetStorefrontDefinitions()
.Where(s => s.StorefrontType is > 0)
.Select(s => s.StorefrontType!.Value)
.Distinct()
.OrderBy(i => i)
.ToArray());
/// <summary>GET api/storefronts/v3/giftdropstore/{id} — one
/// storefront shelf. The <c>{id}</c> is a value from the
/// <c>RecNet.StorefrontTypes</c> enum (verified against
/// Cpp2IL_CS/.../RecNet/StorefrontTypes.cs):
/// None=0, LaserTag=1, RecCenter=2, <b>Watch=3</b>,
/// Quest_*=100..103, RecRoyale=200, Cafe=300, Paintball=400..406,
/// Bowling=500, StuntRunner=600, DormMirror=700.
/// The Watch storefront (id=3) is the in-game Shop's Featured /
/// Clothing / Other grid. That grid assumes every gift drop has a
/// renderable AvatarItemDesc, so only wardrobe-backed rows are
/// surfaced there. Other storefronts use the canonical
/// <c>giftdrop:{id}</c> key plus the shared <c>rro</c> and
/// <c>all</c> admin keys.
/// </summary>
[HttpGet("api/storefronts/v3/giftdropstore/{id:int}")]
[AllowAnonymous]
public async Task<IActionResult> GiftDropStore(int id)
{
var (key, items) = id switch
{
3 => ($"watch", await store.GetRenderableGiftDropItemsAsync()),
_ => (StoreService.StorefrontKeyForType(id), await store.GetRoomStorefrontItemsAsync(id)),
};
return Ok(StoreService.ToStorefrontDto(key, items, domain.Apex, id));
}
/// <summary>GET api/storefronts/v1/balance/{type} — currency
/// balance for the caller. Wire shape is a list of
/// {CurrencyType, Balance, BalanceType, Platform} entries. The
/// watch displays the Balance value next to item prices.</summary>
[HttpGet("api/storefronts/v1/balance/{currencyType:int}")]
[HttpGet("api/storefronts/v2/balance/{currencyType:int}")]
[HttpGet("api/storefronts/v3/balance/{currencyType:int}")]
[HttpGet("api/storefronts/v4/balance/{currencyType:int}")]
[HttpGet("api/storefronts/v4/balance")]
[Authorize]
public async Task<IActionResult> Balance(int currencyType = 2,
[FromServices] LevelService level = null!)
{
var pid = this.RequireCurrentPlayerId();
var balance = await level.GetBalanceAsync(pid, currencyType);
return Ok(new[]
{
new
{
CurrencyType = currencyType,
BalanceType = 0,
Balance = balance,
Platform = 0,
},
});
}
/// <summary>GET <c>api/storefronts/v{1-4}/balanceAddType/{type}/{tier}</c>
/// — purchase-tier metadata for a currency. The watch uses this
/// to populate the "add tokens" tier list. Empty for a private
/// server with no IAP.</summary>
[HttpGet("api/storefronts/v1/balanceAddType/{currencyType:int}/{tierId:int}")]
[HttpGet("api/storefronts/v2/balanceAddType/{currencyType:int}/{tierId:int}")]
[HttpGet("api/storefronts/v3/balanceAddType/{currencyType:int}/{tierId:int}")]
[HttpGet("api/storefronts/v4/balanceAddType/{currencyType:int}/{tierId:int}")]
public IActionResult BalanceAddType(int currencyType, int tierId)
=> Ok(BalanceAddConfig(currencyType, tierId));
/// <summary>GET <c>api/storefronts/v2/balance</c> — every currency
/// balance for the caller in a single response. The watch's
/// wallet UI uses this to render all four currency chips at once.
/// CurrencyType per <c>CurrencyType.cs</c>: 0=Token, 1=Cheer,
/// 2=Coin, 3=Premium.</summary>
[HttpGet("api/storefronts/v2/balance")]
[Authorize]
public async Task<IActionResult> AllBalances()
{
var pid = this.RequireCurrentPlayerId();
var coin = await level.GetBalanceAsync(pid, 2);
var token = await level.GetBalanceAsync(pid, 0);
return Ok(new[]
{
new { CurrencyType = 2, BalanceType = 0, Balance = coin, Platform = 0 },
new { CurrencyType = 0, BalanceType = 0, Balance = token, Platform = 0 },
new { CurrencyType = 1, BalanceType = 0, Balance = 0L, Platform = 0 },
new { CurrencyType = 3, BalanceType = 0, Balance = 0L, Platform = 0 },
});
}
/// <summary>GET <c>api/storefronts/v1/current</c> — single active
/// storefront the watch's "today's deals" panel renders. Returns
/// the <c>main</c> storefront's items as a single PurchasableItem
/// list (bounded to keep older watches snappy).</summary>
[HttpGet("api/storefronts/v1/current")]
public async Task<IActionResult> Current()
{
if (!config.GetValue("Store:EnableWatchGiftDrops", true))
return Ok(StoreService.ToStorefrontDto("Main", Array.Empty<StoreItemEntity>(), domain.Apex));
IQueryable<StoreItemEntity> query = db.StoreItems
.Where(s => s.IsActive && s.Storefront == "main")
.OrderByDescending(s => s.UpdatedAt);
var max = config.GetValue("Store:MaxCurrentStorefrontItems", 96);
if (max > 0) query = query.Take(max);
var rows = await query.ToListAsync();
return Ok(StoreService.ToStorefrontDto("Main", rows, domain.Apex));
}
/// <summary>GET <c>api/storefronts/v1/season/{seasonId}</c> —
/// season-pass tier list, sourced from StoreItems tagged
/// <c>Storefront = "season:{id}"</c>.</summary>
[HttpGet("api/storefronts/v1/season/{seasonId:int}")]
public async Task<IActionResult> Season(int seasonId)
{
var key = $"season:{seasonId}";
var tiers = await db.StoreItems
.Where(s => s.IsActive && s.Storefront == key)
.OrderBy(s => s.Price)
.Select(s => new
{
TierId = s.Id,
s.DisplayName,
s.Description,
s.Price,
CurrencyType = s.CurrencyType,
s.ImageName,
})
.ToListAsync();
return Ok(new
{
SeasonId = seasonId,
SeasonType = seasonId,
Active = tiers.Count > 0,
StartsAt = DateTime.UtcNow.AddDays(-30),
EndsAt = DateTime.UtcNow.AddDays(30),
Tiers = tiers,
});
}
/// <summary>GET <c>api/storefronts/v1/objectives</c> — season
/// objective progress for the caller, keyed
/// <c>season:{n}</c>.</summary>
[HttpGet("api/storefronts/v1/objectives")]
public async Task<IActionResult> Objectives()
{
var pid = this.CurrentPlayerId();
if (pid is not long me) return Ok(Array.Empty<object>());
var rows = await db.ObjectiveProgress
.Where(o => o.PlayerId == me && o.Key.StartsWith("season:"))
.Select(o => new { Key = o.Key, IsCompleted = o.IsCompleted, ClearedAt = o.ClearedAt })
.ToListAsync();
return Ok(rows);
}
/// <summary>POST <c>api/storefronts/v1/objectives</c> —
/// <c>Storefronts.CompleteObjectives</c> uploads completion
/// records and expects a <c>BalanceUpdateResponseDTO</c> back.
/// We ship the wrapper with the required Balance/CurrencyType/
/// BalanceType trio so <c>BalanceResponseDTO.Deserialize</c>
/// doesn't throw, plus an empty <c>BalanceUpdates</c>.</summary>
[HttpPost("api/storefronts/v1/objectives")]
[Consumes("application/json", "application/x-www-form-urlencoded", "multipart/form-data")]
[Authorize]
public async Task<IActionResult> CompleteObjectives()
{
var pid = this.RequireCurrentPlayerId();
var records = await ReadStorefrontObjectiveRecordsAsync();
var total = 0;
foreach (var record in records ?? new())
{
if (record.CompletionPercentage <= 0f) continue;
var rewardKey = $"storefront-objective:{record.ObjectiveType}:{DateTime.UtcNow:yyyyMMdd}";
if (await MarkRewardOnceAsync(pid, rewardKey))
{
var award = record.ObjectiveType switch
{
10 or 11 or 12 => 25,
13 => 150,
14 => 50,
15 => 75,
4000 or 4001 => 50,
_ => Math.Max(5, (int)Math.Round(25 * Math.Clamp(record.CompletionPercentage, 0f, 1f))),
};
total += award;
}
}
if (total > 0)
{
await level.AwardXpAsync(pid, total, "storefront-objectives");
await level.GrantCurrencyAsync(pid, 2, total, "storefront-objectives");
}
var balance = await level.GetBalanceAsync(pid, 2);
return Ok(BalanceUpdateResponse(balance, 2, new[]
{
RewardModification(100, total, total, currentCount: total > 0 ? 1 : 0),
}));
}
public sealed class StorefrontObjectiveCompletionRecord
{
public int ObjectiveType { get; set; }
public float CompletionPercentage { get; set; }
public long? RoomId { get; set; }
}
public sealed class GrantBalanceRewardDto
{
public int CurrencyType { get; set; } = 2;
public List<GrantBalanceRequest> BalanceAdds { get; set; } = new();
}
public sealed class GrantBalanceRequest
{
public float Multiplier { get; set; } = 1f;
public int BalanceAddType { get; set; } = 1;
}
[HttpPost("api/storefronts/v2/balance")]
[Consumes("application/json", "application/x-www-form-urlencoded", "multipart/form-data")]
[Authorize]
public async Task<IActionResult> ModifyBalance()
{
var body = await ReadGrantBalanceRewardAsync();
var pid = this.RequireCurrentPlayerId();
var currencyType = body.CurrencyType == 0 ? 2 : body.CurrencyType;
var modifications = new List<object>();
var total = 0;
foreach (var add in body.BalanceAdds ?? new())
{
var config = BalanceAddConfigValues(currencyType, add.BalanceAddType);
var multiplier = Math.Clamp(add.Multiplier <= 0 ? 1f : add.Multiplier, 0f, config.MaxPartialMultiplier);
var baseAward = config.IgnorePartialMultiplier
? config.BaseAward
: (int)Math.Round(config.BaseAward * multiplier);
baseAward = Math.Max(0, baseAward);
if (baseAward == 0) continue;
total += baseAward;
modifications.Add(RewardModification(add.BalanceAddType, baseAward, baseAward, currentCount: 1));
}
if (total > 0)
{
await level.GrantCurrencyAsync(pid, currencyType, total, "storefront-balance-reward");
await level.AwardXpAsync(pid, Math.Max(10, total / 2), "storefront-balance-reward");
}
var balance = await level.GetBalanceAsync(pid, currencyType);
return Ok(BalanceUpdateResponse(balance, currencyType, modifications));
}
public sealed class BalanceAddTypeRequest { public long Amount { get; set; } }
/// <summary>POST <c>api/storefronts/v1/balanceAddType/{type}/{playerId}</c>
/// — admin-only currency credit. Grants via
/// <see cref="LevelService.GrantCurrencyAsync"/>.</summary>
[HttpPost("api/storefronts/v1/balanceAddType/{type:int}/{playerId:long}")]
[Authorize]
public async Task<IActionResult> BalanceAddTypeAdmin(int type, long playerId, [FromBody] BalanceAddTypeRequest? body)
{
var me = this.RequireCurrentPlayerId();
var isAdmin = await db.Players.Where(p => p.Id == me).Select(p => p.IsAdmin).FirstOrDefaultAsync();
if (!isAdmin) return Forbid();
var amount = body?.Amount ?? 0L;
if (amount == 0) return Ok(new { Added = 0L });
var newBalance = await level.GrantCurrencyAsync(playerId, type, amount, $"balanceAddType:{me}");
return Ok(new
{
CurrencyType = type,
Balance = newBalance,
BalanceType = 0,
Platform = 0,
});
}
private static object BalanceAddConfig(int currencyType, int balanceAddType)
{
var cfg = BalanceAddConfigValues(currencyType, balanceAddType);
return new
{
CurrencyType = currencyType,
BalanceAddType = balanceAddType,
cfg.BaseAward,
BonusAwardMin = 0,
BonusAwardMax = 0,
RateLimitType = 0,
IgnorePartialMultiplier = cfg.IgnorePartialMultiplier,
MaxPartialMultiplier = (int)Math.Ceiling(cfg.MaxPartialMultiplier),
RateLimit = 0,
BalanceInGiftBox = false,
};
}
private static (int BaseAward, float MaxPartialMultiplier, bool IgnorePartialMultiplier)
BalanceAddConfigValues(int currencyType, int balanceAddType) => balanceAddType switch
{
1 => (100, 10f, false), // DirectBalanceWithMultiplier: Stunt Runner / banked loot.
10 => (25, 1f, true), // NUXChallenge.
11 => (100, 1f, true), // AllNUXChallenges.
100 => (25, 1f, true), // DailyChallenge.
101 => (150, 1f, true), // AllDailyChallenges.
200 => (50, 1f, true), // FinishActivity.
1000 => (100, 1f, true), // WonGame.
1001 => (25, 1f, true), // LostGame.
_ => (25, 1f, true),
};
private async Task<GrantBalanceRewardDto> ReadGrantBalanceRewardAsync()
{
if (Request.HasFormContentType)
{
var form = await Request.ReadFormAsync();
var dto = new GrantBalanceRewardDto
{
CurrencyType = int.TryParse(form["CurrencyType"], out var ct) ? ct : 2,
};
if (int.TryParse(form["BalanceAddType"], out var bat) || int.TryParse(form["balanceAddType"], out bat))
{
dto.BalanceAdds.Add(new GrantBalanceRequest
{
BalanceAddType = bat,
Multiplier = float.TryParse(form["Multiplier"], out var mult) ? mult : 1f,
});
}
return dto;
}
try
{
var dto = await JsonSerializer.DeserializeAsync<GrantBalanceRewardDto>(
Request.Body,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
return dto ?? new GrantBalanceRewardDto();
}
catch
{
return new GrantBalanceRewardDto();
}
}
private async Task<List<StorefrontObjectiveCompletionRecord>> ReadStorefrontObjectiveRecordsAsync()
{
if (Request.HasFormContentType)
{
var form = await Request.ReadFormAsync();
if (!int.TryParse(form["objectiveType"], out var objectiveType)
&& !int.TryParse(form["ObjectiveType"], out objectiveType))
{
return new();
}
return new()
{
new StorefrontObjectiveCompletionRecord
{
ObjectiveType = objectiveType,
CompletionPercentage = float.TryParse(form["completionPercentage"], out var pct)
|| float.TryParse(form["CompletionPercentage"], out pct)
? pct
: 1f,
RoomId = long.TryParse(form["roomId"], out var roomId)
|| long.TryParse(form["RoomId"], out roomId)
? roomId
: null,
},
};
}
try
{
var rows = await JsonSerializer.DeserializeAsync<List<StorefrontObjectiveCompletionRecord>>(
Request.Body,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
return rows ?? new();
}
catch
{
return new();
}
}
private async Task<bool> MarkRewardOnceAsync(long playerId, string rewardKey)
{
var key = $"reward:{rewardKey}";
if (await db.ObjectiveProgress.AnyAsync(o => o.PlayerId == playerId && o.Key == key && o.IsCompleted))
return false;
db.ObjectiveProgress.Add(new ObjectiveProgressEntity
{
PlayerId = playerId,
Key = key,
IsCompleted = true,
ClearedAt = DateTime.UtcNow,
});
await db.SaveChangesAsync();
return true;
}
private static object RewardModification(int balanceAddType, int baseAward, int total, int currentCount) => new
{
BalanceAddType = balanceAddType,
BaseAward = baseAward,
BonusAward = 0,
RateLimit = 0,
CurrentCount = currentCount,
Total = total,
BalanceType = 0,
BalanceInGiftBox = false,
};
private static object BalanceUpdateResponse(long balance, int currencyType, IEnumerable<object> modifications) => new
{
Balance = balance,
CurrencyType = currencyType,
BalanceType = 0,
BalanceUpdates = new[]
{
new
{
UpdateResponse = 0,
Data = modifications.Where(m => m is not null).ToArray(),
},
},
};
}
@@ -0,0 +1,195 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using System.Text.Json;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
namespace DorkNet.Server.Controllers.API.TestCaseManagement;
/// <summary>
/// api.rec.net/api/testcasemanagement/* — internal QA tooling. URL
/// surface verified against
/// <c>Cpp2IL_ISIL/.../RecNet/TestCaseManagement.txt</c>:
///
/// GET api/testcasemanagement/v1/testpasssummary → List&lt;TestPass&gt;
/// GET api/testcasemanagement/v1/testpass/{id} → TestPass
/// GET api/testcasemanagement/v1/testcase/{id} → TestCase
/// POST api/testcasemanagement/v1/testcase/{id}/claim
/// POST api/testcasemanagement/v1/testcase/{id}/unclaim
/// POST api/testcasemanagement/v1/testcase/{id}/status
///
/// Wire DTOs match <c>RecNet.TestCase</c> + <c>RecNet.TestPass</c>
/// exactly:
///
/// • TestCase: <c>Id(string), Key, Title, Description, RoomName,
/// Status(TestCaseStatus enum 0=NotYetTested/1=Claimed/2=Failed/3=Passed),
/// MinNumAssignedPlayers, AssignedPlayerIds(List&lt;int&gt;),
/// AssignedPlayerNames, Tags, JiraUrl, JiraBugUrl</c>.
/// • TestPass: <c>Id(uint), Name, Description, StartDate, EndDate?,
/// WasManuallyClosed, TestCases(List&lt;TestCase&gt;), Tags,
/// NumTestCases, NumPassedTestCases, NumFailedTestCases</c>.
///
/// Routes are reachable by anyone (the watch only renders this tab
/// when the player has the developer role; client-side gate). The
/// claim/status mutations no-op if the caller isn't authenticated.
/// </summary>
[ApiController]
public class TestCaseManagementController(DorkNetDbContext db) : ControllerBase
{
[HttpGet("api/testcasemanagement/v1/testpasssummary")]
public async Task<IActionResult> TestPassSummaries()
{
var passes = await db.TestPasses.ToListAsync();
var caseCountsByPass = await db.TestCases
.Where(c => c.TestPassId != null)
.GroupBy(c => c.TestPassId!.Value)
.Select(g => new PassCounts(g.Key, g.Count(),
g.Count(c => c.Status == 3), g.Count(c => c.Status == 2)))
.ToDictionaryAsync(x => x.PassId);
return Ok(passes.Select(p => ToWirePass(p, new List<TestCaseEntity>(), caseCountsByPass)));
}
[HttpGet("api/testcasemanagement/v1/testpass/{id:long}")]
public async Task<IActionResult> TestPass(long id)
{
if (id < 0 || id > uint.MaxValue) return NotFound();
var pid = (uint)id;
var pass = await db.TestPasses.FirstOrDefaultAsync(p => p.Id == pid);
if (pass is null) return NotFound();
var cases = await db.TestCases.Where(c => c.TestPassId == pid).ToListAsync();
var counts = new Dictionary<uint, PassCounts>
{
[pid] = new(pid, cases.Count, cases.Count(c => c.Status == 3), cases.Count(c => c.Status == 2)),
};
return Ok(ToWirePass(pass, cases, counts));
}
private record PassCounts(uint PassId, int Total, int Passed, int Failed);
[HttpGet("api/testcasemanagement/v1/testcase/{id}")]
public async Task<IActionResult> TestCase(string id)
{
var tc = await db.TestCases.FirstOrDefaultAsync(c => c.Id == id);
if (tc is null) return NotFound();
return Ok(ToWireCase(tc));
}
[HttpPost("api/testcasemanagement/v1/testcase/{id}/claim")]
[Authorize]
public async Task<IActionResult> Claim(string id)
{
var pid = this.RequireCurrentPlayerId();
var tc = await db.TestCases.FirstOrDefaultAsync(c => c.Id == id);
if (tc is null) return NotFound();
var player = await db.Players.Where(p => p.Id == pid).Select(p => p.Username).FirstOrDefaultAsync();
var ids = ParseIntList(tc.AssignedPlayerIdsJson);
var names = ParseStringList(tc.AssignedPlayerNamesJson);
if (!ids.Contains((int)pid))
{
ids.Add((int)pid);
names.Add(player ?? $"Player {pid}");
tc.AssignedPlayerIdsJson = JsonSerializer.Serialize(ids);
tc.AssignedPlayerNamesJson = JsonSerializer.Serialize(names);
}
tc.Status = 1; // Claimed
tc.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return Ok(ToWireCase(tc));
}
[HttpPost("api/testcasemanagement/v1/testcase/{id}/unclaim")]
[Authorize]
public async Task<IActionResult> Unclaim(string id)
{
var pid = this.RequireCurrentPlayerId();
var tc = await db.TestCases.FirstOrDefaultAsync(c => c.Id == id);
if (tc is null) return NotFound();
var ids = ParseIntList(tc.AssignedPlayerIdsJson);
var names = ParseStringList(tc.AssignedPlayerNamesJson);
var idx = ids.IndexOf((int)pid);
if (idx >= 0)
{
ids.RemoveAt(idx);
if (idx < names.Count) names.RemoveAt(idx);
tc.AssignedPlayerIdsJson = JsonSerializer.Serialize(ids);
tc.AssignedPlayerNamesJson = JsonSerializer.Serialize(names);
}
if (ids.Count == 0 && tc.Status == 1) tc.Status = 0; // back to NotYetTested
tc.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return Ok(ToWireCase(tc));
}
public sealed class StatusUpdateRequest
{
public int NewStatus { get; set; }
}
[HttpPost("api/testcasemanagement/v1/testcase/{id}/status")]
[Authorize]
public async Task<IActionResult> UpdateStatus(string id, [FromBody] StatusUpdateRequest req)
{
var tc = await db.TestCases.FirstOrDefaultAsync(c => c.Id == id);
if (tc is null) return NotFound();
if (req.NewStatus is < 0 or > 3) return BadRequest("invalid status");
tc.Status = req.NewStatus;
tc.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return Ok(ToWireCase(tc));
}
// ── Helpers ──────────────────────────────────────────────────────────
private static List<int> ParseIntList(string? json)
{
if (string.IsNullOrWhiteSpace(json)) return new();
try { return JsonSerializer.Deserialize<List<int>>(json) ?? new(); }
catch { return new(); }
}
private static List<string> ParseStringList(string? json)
{
if (string.IsNullOrWhiteSpace(json)) return new();
try { return JsonSerializer.Deserialize<List<string>>(json) ?? new(); }
catch { return new(); }
}
private static object ToWireCase(TestCaseEntity c) => new
{
c.Id,
c.Key,
c.Title,
c.Description,
c.RoomName,
c.Status,
c.MinNumAssignedPlayers,
AssignedPlayerIds = ParseIntList(c.AssignedPlayerIdsJson),
AssignedPlayerNames = ParseStringList(c.AssignedPlayerNamesJson),
Tags = ParseStringList(c.TagsJson),
c.JiraUrl,
c.JiraBugUrl,
};
private static object ToWirePass(TestPassEntity p, List<TestCaseEntity> cases,
IDictionary<uint, PassCounts> counts)
{
var c = counts.TryGetValue(p.Id, out var v) ? v : new PassCounts(p.Id, 0, 0, 0);
return new
{
p.Id,
p.Name,
p.Description,
p.StartDate,
p.EndDate,
p.WasManuallyClosed,
TestCases = cases.Select(ToWireCase),
Tags = ParseStringList(p.TagsJson),
NumTestCases = c.Total,
NumPassedTestCases = c.Passed,
NumFailedTestCases = c.Failed,
};
}
}