Import march-2020-03-10 from private repo (scrubbed 2026-05-27)

Targets Rec Room 2020.03.10 client (also serves 2020.03.06).

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 dorknet-clientpatch-{key}.zip
  for v1-march-* Release uploads.
- .github/workflows/release.yml — on tag push publishes both
  dorknet-server-march_2020_03_10-win-x64.zip and
  dorknet-clientpatch-march_2020_03_10.zip as Release assets.

Same scrub rules as december; tools/build-patcher-zip.ps1 added to
the maintainer-side allow-list so it survives.
This commit is contained in:
Alexa
2026-05-27 01:06:12 +03:00
commit d471b418b9
389 changed files with 104767 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,281 @@
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
};
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,
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,411 @@
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>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 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);
// Rec Room's bundled item names are wrapped in parens like
// "(Hat_Wizard)" — strip those and the underscores for a
// friendlier UI label.
var pretty = 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;
[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; }
[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,89 @@
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);
featuredRoomGroup = new
{
Name = group.Name ?? string.Empty,
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,72 @@
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>
/// `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,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,849 @@
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; }
}
/// <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");
return new
{
// Primary keys the 2020 watch's ImageInfo deserialiser reads.
ImageName = p.BlobName,
AccountId = (int)accountId,
ImageType = 0,
CreatedAt = p.CreatedAt,
// Bonus fields — useful for other clients (and our own feed UI).
Id = p.Id,
Filename = p.BlobName,
Url = $"https://{cdnHost}/{p.BlobName}",
Caption = p.Caption,
Description = p.Caption,
RoomId = p.RoomId,
IsPublic = p.IsPublic,
CheerCount = p.CheerCount,
ViewCount = p.ViewCount,
TaggedPlayerIds = ParseTagged(p.TaggedPlayerIdsCsv).ToArray(),
};
}
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,549 @@
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 <c>RecNet.Invention</c>
/// (<c>Cpp2IL_CS/.../RecNet/Invention.cs</c>) byte-for-byte: keys
/// <c>InventionId, ReplicationId, CreatorPlayerId, Name, Description,
/// ImageName, CurrentVersionNumber, IsPublished, ModifiedAt,
/// CreatedAt, FirstPublishedAt, CreationRoomId,
/// NumPlayersHaveUsedInRoom, NumDownloads, CheerCount,
/// CreatorPermission, GeneralPermission, IsAgInvention</c>.
/// </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>());
}
// ── Single fetch ─────────────────────────────────────────────────────
[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 record SaveInventionRequest(
string Name, string? Description, string? ImageName,
int? Permission, string? Tags, string BlobName,
long? CreationRoomId, int? InstantiationCost, int? LightsCost);
[HttpPost("api/inventions/v3/save")]
[Authorize]
public async Task<ActionResult> Save([FromBody] SaveInventionRequest req)
{
var pid = CurrentPlayerId;
if (string.IsNullOrWhiteSpace(req.Name)) return BadRequest("missing name");
if (string.IsNullOrWhiteSpace(req.BlobName)) return BadRequest("missing BlobName");
var inv = new InventionEntity
{
CreatorPlayerId = pid,
ReplicationId = Guid.NewGuid().ToString("D"),
Name = req.Name.Trim(),
Description = req.Description ?? string.Empty,
ImageName = req.ImageName ?? string.Empty,
Permission = Math.Clamp(req.Permission ?? 0, 0, 2),
CurrentBlobName = req.BlobName,
TagsCsv = req.Tags ?? string.Empty,
CreationRoomId = req.CreationRoomId,
CreatorPermission = 100, // Unlimited for the creator
GeneralPermission = 0, // Unassigned (private) until publish
IsPublished = false,
CurrentVersionNumber = 1,
};
db.Inventions.Add(inv);
await db.SaveChangesAsync();
// Snapshot the v1 version row.
var v1 = new InventionVersionEntity
{
InventionId = inv.Id,
ReplicationId = Guid.NewGuid().ToString("D"),
VersionNumber = 1,
BlobName = req.BlobName,
InstantiationCost = req.InstantiationCost ?? 0,
LightsCost = req.LightsCost ?? 0,
};
db.InventionVersions.Add(v1);
await db.SaveChangesAsync();
await level.AwardXpAsync(pid, LevelService.InventionSavedXp, $"invention_save:{inv.Id}");
return Ok(ToWire(inv));
}
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/v2/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,
};
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,
ModifiedAt = i.UpdatedAt,
i.CreatedAt,
i.FirstPublishedAt,
i.CreationRoomId,
i.NumPlayersHaveUsedInRoom,
NumDownloads = i.SpawnCount,
i.CheerCount,
i.CreatorPermission,
i.GeneralPermission,
i.IsAgInvention,
};
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,204 @@
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;
// The slug for equipment is the modification GUID (or prefab
// name fallback when no mod is applied).
var slug = !string.IsNullOrWhiteSpace(req.ModificationGuid)
? 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)
{
// 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 ──────────────────────────────────────────────────────
[HttpGet("api/consumables/v1/getUnlocked")]
public async Task<IActionResult> GetUnlockedConsumables()
{
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)));
}
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) => new
{
PrefabName = item.Slug, // for store-derived items the slug doubles as prefab name
ModificationGuid = inv.ItemSlug,
PlatformMask = -1, // All
IsPlatformLocked = false,
FriendlyName = item.DisplayName,
Tooltip = item.Description,
Rarity = 0, // Common (no rarity column on StoreItem yet)
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,586 @@
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,
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")]
[HttpGet("/api/messages/v1/onlineStatus")]
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" });
// Only the OWNER of the private instance can invite into it.
// Otherwise an attacker who somehow learned a roomInstanceId
// could invite themselves (or anyone) freely.
var inst = await privateInstances.GetAsync(roomInstanceId);
if (inst is null)
return BadRequest(new { error = "instance_not_found", roomInstanceId });
if (inst.OwnerPlayerId != Me)
return Forbid();
// 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 roomName = await db.Rooms
.Where(r => r.Id == inst.RoomId)
.Select(r => r.Name)
.FirstOrDefaultAsync() ?? "Private match";
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 = inst.RoomId,
};
db.Messages.Add(entry);
await db.SaveChangesAsync();
// InviteAsync AFTER the message save so we can pin the freshly-
// allocated Message.Id onto the invitee row's
// LatestInviteMessageId. This is what /goto/invite/{N} falls
// back to when the watch's accept flow has already raced
// through DELETE /api/messages/v3/delete — the message row is
// gone but the (instance, player) → messageId mapping survives.
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/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>.</summary>
[HttpGet("/api/messages/v1/favoriteFriendOnlineStatus")]
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,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,99 @@
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).</summary>
[HttpPost("api/PlayerCheer/v1/SetSelectedCheer")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
public async Task<IActionResult> SetSelectedCheer([FromForm(Name = "Cheer")] int cheer = 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 = cheer.ToString() });
else
existing.Value = cheer.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,319 @@
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.</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(new { success = false });
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(new { success = true, error = "" });
}
/// <summary>POST <c>api/playerevents/v1/deleteResponse</c> — drop
/// the caller's RSVP row.</summary>
[HttpPost("api/playerevents/v1/deleteResponse")]
public async Task<IActionResult> DeleteResponseForm([FromForm(Name = "EventId")] long? eventId)
{
if (eventId is not long evt) return Ok(new { success = true });
await db.PlayerEventResponses
.Where(r => r.PlayerId == Me && r.EventId == evt)
.ExecuteDeleteAsync();
return Ok(new { success = true, error = "" });
}
/// <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; }
}
[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 NotFound();
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 { Reported = true });
}
}
@@ -0,0 +1,210 @@
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
{
[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,554 @@
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) : 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 keys = new[] { $"challenge:{mapId}:0", $"challenge:{mapId}:1", $"challenge:{mapId}:2" };
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 = false,
StartAt = start,
EndAt = start.AddDays(7),
ServerTime = now,
ChallengeThemeString = "Weekly",
Challenges = new[]
{
new { ChallengeId = mapId * 10 + 0, Name = "Visit any room",
Config = "{\"Goal\":1}", Description = "Step into any room this week.",
Tooltip = "", Complete = Done(0) },
new { ChallengeId = mapId * 10 + 1, Name = "Cheer a player",
Config = "{\"Goal\":1}", Description = "Send a cheer to anyone you meet this week.",
Tooltip = "", Complete = Done(1) },
new { ChallengeId = mapId * 10 + 2, Name = "Finish an activity",
Config = "{\"Goal\":1}", Description = "Complete any activity this week.",
Tooltip = "", Complete = Done(2) },
},
Gift = new
{
GiftDropId = mapId,
AvatarItemDesc = "",
ConsumableItemDesc = "",
EquipmentPrefabName = "",
EquipmentModificationGuid = "",
Xp = 100,
Level = 1,
GiftContext = 0,
GiftRarity = 0,
},
});
}
public sealed class UpdateChallengeRequest
{
public int Id { get; set; }
public int ChallengeMapId { get; set; }
public int ChallengeId { get; set; }
public int Progress { get; set; }
}
[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 index = challengeId >= mapId * 10 && challengeId < mapId * 10 + 10
? challengeId - mapId * 10
: Math.Abs(challengeId % 10);
index = Math.Clamp(index, 0, 2);
var key = $"challenge:{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);
}
if (!row.IsCompleted)
{
row.IsCompleted = true;
row.ClearedAt = DateTime.UtcNow;
}
await db.SaveChangesAsync();
var weeklyRewarded = false;
var allKeys = new[] { $"challenge:{mapId}:0", $"challenge:{mapId}:1", $"challenge:{mapId}:2" };
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: 250,
tokens: 250,
reason: $"weekly_challenge:{mapId}");
}
return Ok(new
{
ChallengeMapId = mapId,
ChallengeId = challengeId,
Progress = Math.Max(req.Progress, 10),
IsCompleted = true,
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
{
req.Group,
req.Index,
Progress = row.IsCompleted ? 1f : req.Progress,
VisualProgress = row.IsCompleted ? 1f : req.Progress,
IsCompleted = row.IsCompleted,
IsRewarded = isRewarded,
IsDirty = false,
});
}
[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);
return new
{
Index = idx,
Group = grp,
Progress = o.IsCompleted ? 1f : 0f,
VisualProgress = o.IsCompleted ? 1f : 0f,
IsCompleted = o.IsCompleted,
IsRewarded = rewardKeys.Contains(o.Key),
IsDirty = false,
};
});
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/v2/objectives</c> — batched
/// ProgressionManager completion records. These are separate from
/// ObjectiveProgress rows: they grant XP/currency for daily
/// objective events and must be idempotent per UTC day.</summary>
[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 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,1062 @@
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},
/// 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,
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")]
public async Task<IActionResult> Hot(
[FromQuery] string? roomScoreType,
[FromQuery] string? tags)
=> Ok((await rooms.HotAsync(tags)).Select(RoomService.ToWireRoom).ToList());
/// <summary>
/// `Rooms.SearchForRooms(query)` — free-text room search.
/// </summary>
[HttpGet("api/rooms/v1/search")]
[HttpGet("api/rooms/v2/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")]
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}")]
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 api/rooms/v1/datahistory/{roomId} — returns the
/// version history for a room as a list of saves. Drives the
/// watch's "Restore to old version" UI in the room-settings tab.
///
/// The watch sometimes calls this with <c>roomId=0</c>, which we
/// interpret as "the caller's currently-presence-tracked room"
/// (typically their dorm). Anything else is a literal room id.
///
/// Wire shape: <c>List&lt;RoomDataHistoryDTO&gt;</c>. Deserializer
/// reads EXACTLY three PascalCase keys (verified at
/// <c>Cpp2IL_ISIL/.../RecNet/Rooms_NestedType_RoomDataHistoryDTO.txt</c>):
/// <c>RoomDataHistoryId</c> (long), <c>DataBlobName</c> (string),
/// <c>CreatedAt</c> (DateTime). Extra/wrong keys aren't ignored —
/// missing required keys throw <c>KeyNotFoundException</c> in
/// LitJson and the watch shows an empty history list. Don't add
/// helper fields here; pipe them through a separate admin endpoint
/// if needed.</summary>
[HttpGet("api/rooms/v1/datahistory/{roomId:long}")]
[HttpGet("api/rooms/v2/datahistory/{roomId:long}")]
public async Task<IActionResult> DataHistory(long roomId)
{
// 0 is a sentinel — fall back to the caller's current room
// from PlayerPresenceService.
if (roomId == 0 && CurrentPlayerId is long pid &&
presence.GetRoom(pid)?.RoomId is long currentId)
{
roomId = currentId;
}
if (roomId == 0) return Ok(Array.Empty<object>());
var blobs = await db.RoomDataBlobs
.Where(b => b.RoomId == roomId)
.OrderByDescending(b => b.UploadedAt)
.Take(50)
.Select(b => new
{
RoomDataHistoryId = b.Id,
DataBlobName = b.BlobName,
CreatedAt = b.UploadedAt,
})
.ToListAsync();
return Ok(blobs);
}
/// <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,
location = currentPresence.Location,
photonRegionId = currentPresence.PhotonRegionId,
photonRoomId = currentPresence.PhotonRoomId,
maxCapacity = currentPresence.MaxCapacity,
isFull = currentPresence.IsFull,
isPrivate = currentPresence.IsPrivate,
isInProgress = currentPresence.IsInProgress,
dataBlob = currentPresence.DataBlob,
eventId = currentPresence.EventId,
name = currentPresence.Name,
},
};
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 });
}
[HttpGet("api/rooms/v4/details/{roomId:long}")]
[HttpGet("api/rooms/v3/details/{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();
return Ok(BuildRoomDetails(room, scenes, roles: roomRoles));
}
/// <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,
};
}
// ── 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 ────────────────────────────────────────────────────
[HttpGet("api/rooms/v2/myrooms")]
[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/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")]
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>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>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 });
}
/// <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,319 @@
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.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,
});
}
}
@@ -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,143 @@
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/cancel</c> — Rec Room+
/// subscription cancel button. Verified URL at
/// Commerce.txt:2188. The private server has no real billing
/// integration (no Stripe, no platform IAP receipts) so the
/// subscription is purely cosmetic — we ack the cancel so the
/// watch flips its UI to "canceled" and stops the recurring
/// renewal nag. If a real subscription entity ever lands, this
/// is where to expire its CurrentPeriodEnd.</summary>
[HttpPost("/subscription/v1")]
[HttpPost("/subscription/v1/cancel")]
[HttpPost("/api/subscription/v1")]
[HttpPost("/api/subscription/v1/cancel")]
public IActionResult CancelSubscription()
{
logger.LogInformation("[subscription] cancel requested by player {Pid}",
this.CurrentPlayerId() ?? 0);
return Ok(new { success = true, error = "" });
}
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,358 @@
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.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));
}
// ── 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.
Id = giftId,
Platform = 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 },
},
},
};
}
}
@@ -0,0 +1,498 @@
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 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,
};
}
}
@@ -0,0 +1,22 @@
using Microsoft.AspNetCore.Mvc;
namespace DorkNet.Server.Controllers.API.Version;
[ApiController]
public class VersionController : ControllerBase
{
// VersionStatus enum: ValidForPlay=0, ValidForMenu=1, UpdateRequired=2
private static readonly object ValidForPlay = new { VersionStatus = 0 };
[HttpGet("api/version/v1")]
[HttpGet("api/version")]
public ActionResult<object> GetV1() => Ok(ValidForPlay);
// Actual path the 2020 build uses
[HttpGet("api/versioncheck/v4")]
[HttpGet("api/versioncheck/v3")]
[HttpGet("api/versioncheck/v2")]
[HttpGet("api/versioncheck/v1")]
[HttpGet("api/versioncheck")]
public ActionResult<object> GetCheck() => Ok(ValidForPlay);
}