mirror of
https://github.com/DorkSquadRR/DorkNet.git
synced 2026-10-25 23:18:44 -07:00
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:
@@ -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=<our_jwt>
|
||||
///
|
||||
/// 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<string>): 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+<DowloadUnlockedAvatarItems></c> coroutine
|
||||
/// (Cpp2IL_ISIL/.../Avatars_NestedType__DowloadUnlockedAvatarItems_d__21.txt:149
|
||||
/// hits <c>"{api/avatar/}v4/items"</c>) parses the result as
|
||||
/// <c>List<UnlockedAvatarItem></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<FeaturedRoom></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<long></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<GetFiltersResponse, List<string>></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<GetFiltersResponse,
|
||||
/// List`1<String>>)</c>. Returning a bare string array
|
||||
/// (the previous shape) tripped the LitJson importer's
|
||||
/// <c>List → Dictionary</c> cast inside <c>Util.Deserialize<T></c>
|
||||
/// and surfaced as "Failed to get tags: Malformed Response"
|
||||
/// (output_log.txt:1602+1639) — and that bubbles up as the
|
||||
/// dorm-load destabilisation we've been hunting.</summary>
|
||||
[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<Entry></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=…&
|
||||
/// Type=10&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<int>}</c>; response
|
||||
/// <c>BulkInviteResponse{FailedInvites,Result(CreateModifyPlayerEventStatus)}</c>.
|
||||
/// We insert one MessageEntity per recipient so the watch's inbox
|
||||
/// shows the invite, and push a per-recipient notification.</summary>
|
||||
[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<T>. 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<RecNetChallenge>,
|
||||
/// 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<ChecklistObjective></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<T>:
|
||||
/// "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<Relationship>.
|
||||
/// </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<bool></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<RoomDataHistoryDTO></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<{Key, Value}>
|
||||
/// 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<Setting>
|
||||
/// and reads each entry's "Key" and "Value" fields (verified by string literal
|
||||
/// references in the deserializer body — both are PascalCase).
|
||||
///
|
||||
/// Returning a Dictionary<string,string> 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<BalanceUpdateDTO></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<TestPass>
|
||||
/// 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<int>),
|
||||
/// AssignedPlayerNames, Tags, JiraUrl, JiraBugUrl</c>.
|
||||
/// • TestPass: <c>Id(uint), Name, Description, StartDate, EndDate?,
|
||||
/// WasManuallyClosed, TestCases(List<TestCase>), 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);
|
||||
}
|
||||
Reference in New Issue
Block a user