mirror of
https://github.com/DorkSquadRR/DorkNet.git
synced 2026-10-25 23:18:44 -07:00
feat: Enhance API endpoints and add new features
- Added support for v3 gift generation in AvatarGiftsController. - Updated club report messages to include club IDs for better tracking in CompatibilityFeatureController. - Introduced new endpoints for voice moderation and whitelisted emojis in ConfigController. - Modified image retrieval logic to support single image lookups by name in ImagesController. - Adjusted incentivized referrals to return an empty array when no rewards are available in IncentivizedReferralsController. - Implemented trial invention functionality in InventionsController. - Added top creators today endpoint in PlayerSubscriptionsController. - Expanded room earnings distribution functionality in RoomEarningsDistributionsController. - Enhanced room search capabilities with pagination in RoomsController. - Improved account name generation options in AccountsController. - Added party invite privacy settings in PlayerSettingsController. - Created CommentsController for handling unread comment counts. - Established PurchaseCampaignController for handling purchase campaign telemetry.
This commit is contained in:
@@ -59,7 +59,11 @@ public class AvatarGiftsController(
|
||||
/// that room has no pool — or for non-game free-gift tiles — it falls
|
||||
/// back to a random wardrobe item. The rolled item is stamped as a
|
||||
/// gift package; opening the box (<c>gifts/consume</c>) grants it.</summary>
|
||||
// The 2023 client posts the gift-chest generate to v3 (GET gifts is
|
||||
// already fanned across v2/v3/v4, but generate only routed v2 — the v3
|
||||
// POST 404'd, breaking reward chests on that path). Same request body.
|
||||
[HttpPost("api/avatar/v2/gifts/generate")]
|
||||
[HttpPost("api/avatar/v3/gifts/generate")]
|
||||
[Consumes("application/json", "application/x-www-form-urlencoded", "multipart/form-data")]
|
||||
public async Task<IActionResult> Generate([FromBody] GenerateGiftRequest? req)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using DorkNet.Server.Auth;
|
||||
|
||||
namespace DorkNet.Server.Controllers.API.Comments;
|
||||
|
||||
/// <summary>
|
||||
/// Room-comment unread counts. Client contract (RecNet.Runtime
|
||||
/// <c>DJLNMANKMAA(IReadOnlyCollection<long>)</c>): the caller sends a
|
||||
/// collection of roomIds and expects a top-level JSON OBJECT
|
||||
/// <c>Dictionary<long, uint></c> mapping roomId → unread count.
|
||||
/// We don't track per-player comment read-state yet, so every requested
|
||||
/// room maps to 0 — a correct, non-crashing "nothing unread" answer that
|
||||
/// matches the wire shape (an object, never an array).
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
public class CommentsController : ControllerBase
|
||||
{
|
||||
[HttpPost("/comments/unreadcounts")]
|
||||
[HttpGet("/comments/unreadcounts")]
|
||||
[Consumes("application/json", "application/x-www-form-urlencoded", "multipart/form-data")]
|
||||
public async Task<IActionResult> UnreadCounts()
|
||||
{
|
||||
var roomIds = await ReadRoomIdsAsync();
|
||||
// Keys must be strings in JSON; values are the (currently always 0)
|
||||
// unread counts.
|
||||
var result = new Dictionary<string, uint>();
|
||||
foreach (var id in roomIds) result[id.ToString()] = 0;
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
private async Task<HashSet<long>> ReadRoomIdsAsync()
|
||||
{
|
||||
var ids = new HashSet<long>();
|
||||
|
||||
void AddCsv(string? s)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(s)) return;
|
||||
foreach (var part in s.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||||
if (long.TryParse(part, out var v) && v > 0) ids.Add(v);
|
||||
}
|
||||
|
||||
// Query / form: roomIds=1,2,3
|
||||
foreach (var k in new[] { "roomIds", "roomId", "ids", "id" })
|
||||
{
|
||||
AddCsv(Request.Query[k].ToString());
|
||||
if (Request.HasFormContentType) AddCsv(Request.Form[k].ToString());
|
||||
}
|
||||
|
||||
// JSON body: either a bare array [1,2,3] or { "roomIds": [1,2,3] }.
|
||||
if (!Request.HasFormContentType)
|
||||
{
|
||||
try
|
||||
{
|
||||
Request.EnableBuffering();
|
||||
Request.Body.Position = 0;
|
||||
using var doc = await JsonDocument.ParseAsync(Request.Body);
|
||||
var root = doc.RootElement;
|
||||
JsonElement arr = default;
|
||||
var haveArr = false;
|
||||
if (root.ValueKind == JsonValueKind.Array) { arr = root; haveArr = true; }
|
||||
else if (root.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
foreach (var k in new[] { "roomIds", "RoomIds", "ids", "Ids" })
|
||||
if (root.TryGetProperty(k, out arr) && arr.ValueKind == JsonValueKind.Array) { haveArr = true; break; }
|
||||
}
|
||||
if (haveArr)
|
||||
foreach (var el in arr.EnumerateArray())
|
||||
if (el.ValueKind == JsonValueKind.Number && el.TryGetInt64(out var v) && v > 0) ids.Add(v);
|
||||
}
|
||||
catch { /* non-JSON / empty body */ }
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
}
|
||||
@@ -108,7 +108,9 @@ public class CompatibilityFeatureController(DorkNetDbContext db) : ControllerBas
|
||||
: null;
|
||||
if (club is null) return Ok(new { Success = false, Message = "club_not_found" });
|
||||
|
||||
var message = $"Club report: {req.Message ?? string.Empty}".Trim();
|
||||
// Prefix with "[club {id}]" so admins can tell club reports apart
|
||||
// from player reports in the shared ReportEntity queue.
|
||||
var message = $"[club {club.Id}] {req.Message ?? string.Empty}".Trim();
|
||||
if (message.Length > 1000) message = message[..1000];
|
||||
db.Reports.Add(new ReportEntity
|
||||
{
|
||||
|
||||
@@ -117,6 +117,27 @@ public class ConfigController(ConfigService configService) : ControllerBase
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>GET <c>/voice/requiresModeration</c> — bare Boolean the
|
||||
/// client reads to decide whether voice needs moderation. ToxMod is
|
||||
/// disabled server-side, so no moderation is required.</summary>
|
||||
[HttpGet("/voice/requiresModeration")]
|
||||
[Microsoft.AspNetCore.Authorization.AllowAnonymous]
|
||||
public IActionResult VoiceRequiresModeration() => Content("false", "application/json");
|
||||
|
||||
/// <summary>GET <c>/emojiConfig/whitelistedEmojis</c> — the allow-list of
|
||||
/// emoji permitted in text chat (RRUI.EmojiPicker, IReadOnlyList<string>).
|
||||
/// Returns a broad default set so the picker is populated.</summary>
|
||||
[HttpGet("/emojiConfig/whitelistedEmojis")]
|
||||
[Microsoft.AspNetCore.Authorization.AllowAnonymous]
|
||||
public IActionResult WhitelistedEmojis() => Ok(new[]
|
||||
{
|
||||
"😀", "😁", "😂", "🤣", "😊", "😇", "🙂", "😉", "😍", "😘",
|
||||
"😜", "🤪", "😎", "🤩", "🥳", "😏", "😢", "😭", "😡", "🤯",
|
||||
"😱", "😴", "🤔", "🤗", "🙄", "😬", "👍", "👎", "👏", "🙌",
|
||||
"🙏", "💪", "✌️", "🤞", "👋", "🤝", "❤️", "🔥", "⭐", "✨",
|
||||
"🎉", "🎊", "💯", "😈", "👻", "🤖", "🎮", "🏆",
|
||||
});
|
||||
|
||||
/// <summary>GET <c>api/config/v1/freegiftbutton</c> — the
|
||||
/// 2020.12 client deserializes this endpoint as a raw Boolean.
|
||||
/// This server does not run the timed free-gift campaign, so the
|
||||
|
||||
@@ -303,17 +303,23 @@ public class ImagesController(
|
||||
return Ok(rows.Select(p => BuildImageInfo(p, p.UploaderPlayerId)));
|
||||
}
|
||||
|
||||
/// <summary>GET <c>api/images/v6?name={blobName}</c> — single-image
|
||||
/// lookup by name. Client contract (RecNet.Runtime
|
||||
/// <c>KLJOGJHBONK.INHMKKAJJKO(string)</c>) returns ONE
|
||||
/// <c>ICOFKEGOGOD</c> ImageDTO, not a list; a bare array crashes its
|
||||
/// reader. Not found (or private + not the owner) → 404 so the promise
|
||||
/// rejects cleanly rather than delivering a wrong-shaped body.</summary>
|
||||
[HttpGet("api/images/v6")]
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> ImagesV6([FromQuery] int take = 50)
|
||||
public async Task<IActionResult> ImagesV6([FromQuery] string? name)
|
||||
{
|
||||
take = Math.Clamp(take, 1, 100);
|
||||
var rows = await db.Photos
|
||||
.Where(p => p.IsPublic && p.DeletedAt == null)
|
||||
.OrderByDescending(p => p.CreatedAt)
|
||||
.Take(take)
|
||||
.ToListAsync();
|
||||
return Ok(rows.Select(p => BuildImageInfo(p, p.UploaderPlayerId)));
|
||||
if (string.IsNullOrWhiteSpace(name)) return NotFound();
|
||||
var pid = this.CurrentPlayerId();
|
||||
var photo = await db.Photos.FirstOrDefaultAsync(p =>
|
||||
p.BlobName == name && p.DeletedAt == null &&
|
||||
(p.IsPublic || (pid != null && p.UploaderPlayerId == pid.Value)));
|
||||
if (photo is null) return NotFound();
|
||||
return Ok(BuildImageInfo(photo, photo.UploaderPlayerId));
|
||||
}
|
||||
|
||||
/// <summary>GET <c>api/images/v2/named</c> — the watch's
|
||||
|
||||
+17
-6
@@ -34,17 +34,23 @@ public class IncentivizedReferralsController(DorkNetDbContext db, LevelService l
|
||||
});
|
||||
}
|
||||
|
||||
// Client contract: the claim response deserializes as a top-level
|
||||
// List<RewardSelection> where each entry is
|
||||
// { rewardSelectionId: long, rewardType: string } (RecNet.Runtime
|
||||
// AFEFBIKADAP/GLOFCEJBIGB). Returning a {Success,...} object crashes the
|
||||
// strict array reader. When there's nothing to grant, return an empty
|
||||
// array — a valid empty list, not an error object.
|
||||
[HttpPost("claim")]
|
||||
public async Task<IActionResult> Claim()
|
||||
{
|
||||
var referralCount = await db.PlayerSettings
|
||||
.CountAsync(s => s.Key.StartsWith($"referral:credited:{Me}:"));
|
||||
if (referralCount < 1)
|
||||
return Ok(new { Success = false, Error = "not_enough_referrals" });
|
||||
if (await db.PlayerSettings.AnyAsync(s => s.PlayerId == Me && s.Key == "referral:reward_claimed"))
|
||||
return Ok(new { Success = false, Error = "already_claimed" });
|
||||
var alreadyClaimed = await db.PlayerSettings
|
||||
.AnyAsync(s => s.PlayerId == Me && s.Key == "referral:reward_claimed");
|
||||
if (referralCount < 1 || alreadyClaimed)
|
||||
return Ok(Array.Empty<object>());
|
||||
|
||||
var balance = await level.GrantCurrencyAsync(Me, 2, 250, "incentivized_referral");
|
||||
await level.GrantCurrencyAsync(Me, 2, 250, "incentivized_referral");
|
||||
db.PlayerSettings.Add(new PlayerSettingEntity
|
||||
{
|
||||
PlayerId = Me,
|
||||
@@ -52,6 +58,11 @@ public class IncentivizedReferralsController(DorkNetDbContext db, LevelService l
|
||||
Value = DateTime.UtcNow.ToString("O"),
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new { Success = true, Balance = balance, CurrencyType = 2, Currency = 250 });
|
||||
|
||||
// One granted reward selection: 250 of currency type 2.
|
||||
return Ok(new[]
|
||||
{
|
||||
new { rewardSelectionId = unchecked(Me * 397L + 2), rewardType = "Currency" },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -334,6 +334,39 @@ public class InventionsController(
|
||||
return Ok(ToWire(i));
|
||||
}
|
||||
|
||||
/// <summary>POST <c>api/storefronts/v1/trialInvention?inventionId={id}</c>
|
||||
/// — start a free trial of a store invention. Client
|
||||
/// (RecNet.Runtime <c>DCFKEFHJAGC.PPCLLFMHJLD(long)</c>) expects a SINGLE
|
||||
/// Invention DTO back (same <see cref="ToWire"/> shape it uses for
|
||||
/// search), which it spawns for the trial window (length from
|
||||
/// <c>trialInvention/duration</c>). Hosted here — not in
|
||||
/// StorefrontsController — so it can reuse the invention wire builder.
|
||||
/// We record the trial start so it's an auditable action.</summary>
|
||||
[HttpPost("api/storefronts/v1/trialInvention")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult> TrialInvention([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();
|
||||
|
||||
if (CurrentPlayerIdOrNull is long me)
|
||||
{
|
||||
var key = $"invention:trial:{inventionId}";
|
||||
var row = await db.PlayerSettings.FirstOrDefaultAsync(s => s.PlayerId == me && s.Key == key);
|
||||
if (row is null)
|
||||
db.PlayerSettings.Add(new Data.Entities.PlayerSettingEntity
|
||||
{
|
||||
PlayerId = me, Key = key, Value = DateTime.UtcNow.ToString("O"),
|
||||
});
|
||||
else
|
||||
row.Value = DateTime.UtcNow.ToString("O");
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
return Ok(ToWire(i));
|
||||
}
|
||||
|
||||
[HttpGet("api/inventions/v1/details")]
|
||||
public async Task<ActionResult> Details([FromQuery] long inventionId)
|
||||
{
|
||||
|
||||
@@ -44,6 +44,51 @@ public class PlayerSubscriptionsController(
|
||||
return Ok(subs);
|
||||
}
|
||||
|
||||
/// <summary>GET <c>/subscription/top/creators/today</c> — the
|
||||
/// subscriptions tab's "top creators" carousel. Client contract
|
||||
/// (RecNet.Runtime <c>JDBPOMCAIIG()</c>) is a bare
|
||||
/// <c>List<creator></c> of <c>{ accountId:int, subscriberCount:long,
|
||||
/// … }</c>. Ranks creators by total subscriber count. The leading "/"
|
||||
/// makes this route absolute, bypassing the class's
|
||||
/// <c>api/[controller]/v1</c> prefix.</summary>
|
||||
[HttpGet("/subscription/top/creators/today")]
|
||||
[AllowAnonymous]
|
||||
public async Task<ActionResult> TopCreatorsToday([FromQuery] int? take)
|
||||
{
|
||||
var limit = Math.Clamp(take ?? 20, 1, 100);
|
||||
var top = await db.Subscriptions
|
||||
.GroupBy(s => s.TargetPlayerId)
|
||||
.Select(g => new { AccountId = g.Key, SubscriberCount = (long)g.Count() })
|
||||
.OrderByDescending(x => x.SubscriberCount)
|
||||
.Take(limit)
|
||||
.ToListAsync();
|
||||
|
||||
// A day-window boundary for "today"'s new-subscriber tallies.
|
||||
var since = DateTime.UtcNow.Date;
|
||||
var todayByTarget = await db.Subscriptions
|
||||
.Where(s => s.CreatedAt >= since)
|
||||
.GroupBy(s => s.TargetPlayerId)
|
||||
.Select(g => new { g.Key, Count = g.Count() })
|
||||
.ToDictionaryAsync(x => x.Key, x => x.Count);
|
||||
|
||||
// Dictionaries preserve literal key casing (anonymous objects would be
|
||||
// forced to camelCase by the server policy); the creator DTO's keys
|
||||
// are obfuscated in the dump, so emit both cases to be safe.
|
||||
return Ok(top.Select(c =>
|
||||
{
|
||||
var newToday = todayByTarget.GetValueOrDefault(c.AccountId);
|
||||
return new Dictionary<string, object?>
|
||||
{
|
||||
["AccountId"] = (int)c.AccountId,
|
||||
["accountId"] = (int)c.AccountId,
|
||||
["SubscriberCount"] = c.SubscriberCount,
|
||||
["subscriberCount"] = c.SubscriberCount,
|
||||
["NewSubscribersToday"] = newToday,
|
||||
["newSubscribersToday"] = newToday,
|
||||
};
|
||||
}).ToList());
|
||||
}
|
||||
|
||||
[HttpPost("subscribe/{targetId:long}")]
|
||||
public async Task<ActionResult> Subscribe(long targetId)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace DorkNet.Server.Controllers.API.PurchaseCampaign;
|
||||
|
||||
/// <summary>
|
||||
/// Purchase-campaign impression telemetry. Client contract (RecNet.Runtime
|
||||
/// <c>JHAEJJEGPKJ(int)</c>) is a fire-and-forget POST carrying the campaign
|
||||
/// id; the return type is the response handle whose body the client ignores.
|
||||
/// DorkNet doesn't run ad/purchase campaigns, so there's nothing to record —
|
||||
/// we acknowledge the impression so the client's promise resolves instead of
|
||||
/// 404-rejecting. This is a genuine no-op telemetry sink, not a stub masking
|
||||
/// an unimplemented feature.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
public class PurchaseCampaignController : ControllerBase
|
||||
{
|
||||
[HttpPost("/purchasecampaign/shown")]
|
||||
[HttpPost("/purchasecampaign/shown/{campaignId:int}")]
|
||||
public IActionResult Shown() => Ok();
|
||||
}
|
||||
+180
-34
@@ -1,55 +1,201 @@
|
||||
using System.Text.Json;
|
||||
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.RoomEarningsDistributions;
|
||||
|
||||
/// <summary>
|
||||
/// Co-owner earnings-split configuration for a room. NOT gross-earnings
|
||||
/// totals — this is how a room's token earnings are divided among its
|
||||
/// co-owners. Client contract (RecNet.Runtime HPEBLAHNBLC / FEJOMKJBOOE):
|
||||
/// <list type="bullet">
|
||||
/// <item>GET <c>api/roomEarningsDistributions/v1/earningsDistribution/{roomId}</c>
|
||||
/// → a SINGLE object <c>{ RoomId, EarningsDistributionMapping:
|
||||
/// Dictionary<accountId,percent>, EarningsDistributionMethod: 0|1 }</c>
|
||||
/// (0 = Equal, 1 = Custom).</item>
|
||||
/// <item>POST the bare <c>.../v1/earningsDistribution</c> with that object
|
||||
/// to save.</item>
|
||||
/// </list>
|
||||
/// Persisted as JSON on the room creator's settings row, keyed per room.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
public class RoomEarningsDistributionsController(DorkNetDbContext db) : ControllerBase
|
||||
{
|
||||
private const int MethodEqual = 0;
|
||||
private const int MethodCustom = 1;
|
||||
private static string SettingKey(long roomId) => $"room:earningsdist:{roomId}";
|
||||
|
||||
/// <summary>GET the split config for one room (client's per-room GET).</summary>
|
||||
[HttpGet("api/roomEarningsDistributions/v1/earningsDistribution/{roomId:long}")]
|
||||
public async Task<IActionResult> GetDistribution(long roomId)
|
||||
{
|
||||
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
|
||||
if (room is null) return NotFound();
|
||||
var (method, mapping) = await LoadAsync(room.CreatorPlayerId, roomId, room.CreatorPlayerId);
|
||||
return Ok(BuildDto(roomId, method, mapping));
|
||||
}
|
||||
|
||||
/// <summary>Bare-URL GET kept for compatibility: returns the caller's
|
||||
/// own rooms' split configs as a list. The 2023 client doesn't hit this
|
||||
/// (it GETs the per-room path above), but tooling may.</summary>
|
||||
[HttpGet("api/roomEarningsDistributions")]
|
||||
[HttpGet("api/roomEarningsDistributions/v1/earningsDistribution")]
|
||||
public async Task<IActionResult> EarningsDistribution([FromQuery] long? roomId = null)
|
||||
public async Task<IActionResult> ListMine([FromQuery] long? roomId = null)
|
||||
{
|
||||
var me = this.RequireCurrentPlayerId();
|
||||
var rooms = db.Rooms.Where(r => r.CreatorPlayerId == me);
|
||||
if (roomId is long rid && rid > 0)
|
||||
rooms = rooms.Where(r => r.Id == rid);
|
||||
var q = db.Rooms.Where(r => r.CreatorPlayerId == me);
|
||||
if (roomId is long rid && rid > 0) q = q.Where(r => r.Id == rid);
|
||||
var rooms = await q.Select(r => new { r.Id, r.CreatorPlayerId }).ToListAsync();
|
||||
|
||||
var roomRows = await rooms
|
||||
.Select(r => new { r.Id, r.Name })
|
||||
.ToListAsync();
|
||||
var roomIds = roomRows.Select(r => r.Id).ToList();
|
||||
var earnings = await db.RoomKeyPurchases
|
||||
.Join(db.RoomKeys, p => p.RoomKeyId, k => k.Id, (p, k) => new { p, k })
|
||||
.Where(x => roomIds.Contains(x.k.RoomId))
|
||||
.GroupBy(x => x.k.RoomId)
|
||||
.Select(g => new
|
||||
{
|
||||
RoomId = g.Key,
|
||||
Gross = g.Sum(x => x.p.PaidPrice),
|
||||
PurchaseCount = g.Count(),
|
||||
})
|
||||
.ToListAsync();
|
||||
|
||||
var byRoom = earnings.ToDictionary(e => e.RoomId);
|
||||
return Ok(roomRows.Select(room =>
|
||||
var result = new List<object>();
|
||||
foreach (var r in rooms)
|
||||
{
|
||||
byRoom.TryGetValue(room.Id, out var value);
|
||||
var gross = value?.Gross ?? 0;
|
||||
return new
|
||||
var (method, mapping) = await LoadAsync(me, r.Id, r.CreatorPlayerId);
|
||||
result.Add(BuildDto(r.Id, method, mapping));
|
||||
}
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>POST the bare URL to save a room's split config (client's
|
||||
/// save method). Only the room creator may change it.</summary>
|
||||
[HttpPost("api/roomEarningsDistributions/v1/earningsDistribution")]
|
||||
[HttpPut("api/roomEarningsDistributions/v1/earningsDistribution")]
|
||||
[Consumes("application/json", "application/x-www-form-urlencoded", "multipart/form-data")]
|
||||
public async Task<IActionResult> SaveDistribution()
|
||||
{
|
||||
var me = this.RequireCurrentPlayerId();
|
||||
var (roomId, method, mapping) = await ReadBodyAsync();
|
||||
if (roomId <= 0) return BadRequest(new { error = "missing_room_id" });
|
||||
|
||||
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
|
||||
if (room is null) return NotFound();
|
||||
if (room.CreatorPlayerId != me) return Forbid();
|
||||
|
||||
var payload = JsonSerializer.Serialize(new StoredDistribution
|
||||
{
|
||||
Method = method,
|
||||
Mapping = mapping,
|
||||
});
|
||||
var key = SettingKey(roomId);
|
||||
var row = await db.PlayerSettings
|
||||
.FirstOrDefaultAsync(s => s.PlayerId == room.CreatorPlayerId && s.Key == key);
|
||||
if (row is null)
|
||||
db.PlayerSettings.Add(new PlayerSettingEntity
|
||||
{
|
||||
RoomId = room.Id,
|
||||
room.Name,
|
||||
GrossEarnings = gross,
|
||||
NetEarnings = gross,
|
||||
PurchaseCount = value?.PurchaseCount ?? 0,
|
||||
CurrencyType = 2,
|
||||
UpdatedAt = DateTime.UtcNow,
|
||||
};
|
||||
}).ToList());
|
||||
PlayerId = room.CreatorPlayerId, Key = key, Value = payload,
|
||||
});
|
||||
else
|
||||
row.Value = payload;
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return Ok(BuildDto(roomId, method, mapping));
|
||||
}
|
||||
|
||||
// ── helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
private async Task<(int Method, Dictionary<long, int> Mapping)> LoadAsync(
|
||||
long ownerId, long roomId, long creatorId)
|
||||
{
|
||||
var row = await db.PlayerSettings
|
||||
.FirstOrDefaultAsync(s => s.PlayerId == ownerId && s.Key == SettingKey(roomId));
|
||||
if (row is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
var stored = JsonSerializer.Deserialize<StoredDistribution>(row.Value);
|
||||
if (stored is not null)
|
||||
return (stored.Method, stored.Mapping ?? new Dictionary<long, int>());
|
||||
}
|
||||
catch { /* fall through to default */ }
|
||||
}
|
||||
// Default: Equal method, creator takes 100% (solo room).
|
||||
return (MethodEqual, new Dictionary<long, int> { [creatorId] = 100 });
|
||||
}
|
||||
|
||||
private static object BuildDto(long roomId, int method, Dictionary<long, int> mapping)
|
||||
{
|
||||
// Mapping is keyed by accountId (as a string in JSON) → percent.
|
||||
var mappingWire = mapping.ToDictionary(kv => kv.Key.ToString(), kv => kv.Value);
|
||||
// A Dictionary preserves literal key casing (unlike anonymous objects,
|
||||
// which the server serializes camelCase). The client dump carries BOTH
|
||||
// "EarningsDistributionMapping"/"earningsDistributionMapping" (and the
|
||||
// Method variants), so emit both cases to be safe.
|
||||
return new Dictionary<string, object?>
|
||||
{
|
||||
["RoomId"] = roomId,
|
||||
["roomId"] = roomId,
|
||||
["EarningsDistributionMethod"] = method,
|
||||
["earningsDistributionMethod"] = method,
|
||||
["EarningsDistributionMapping"] = mappingWire,
|
||||
["earningsDistributionMapping"] = mappingWire,
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<(long RoomId, int Method, Dictionary<long, int> Mapping)> ReadBodyAsync()
|
||||
{
|
||||
long roomId = 0;
|
||||
int method = MethodEqual;
|
||||
var mapping = new Dictionary<long, int>();
|
||||
|
||||
if (Request.HasFormContentType)
|
||||
{
|
||||
if (long.TryParse(Request.Form["RoomId"], out var rf) ||
|
||||
long.TryParse(Request.Form["roomId"], out rf)) roomId = rf;
|
||||
if (int.TryParse(Request.Form["EarningsDistributionMethod"], out var mf) ||
|
||||
int.TryParse(Request.Form["earningsDistributionMethod"], out mf)) method = mf;
|
||||
}
|
||||
else
|
||||
{
|
||||
try
|
||||
{
|
||||
Request.EnableBuffering();
|
||||
Request.Body.Position = 0;
|
||||
using var doc = await JsonDocument.ParseAsync(Request.Body);
|
||||
var root = doc.RootElement;
|
||||
if (root.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
roomId = ReadLong(root, "RoomId", "roomId") ?? 0;
|
||||
method = (int)(ReadLong(root, "EarningsDistributionMethod", "earningsDistributionMethod") ?? MethodEqual);
|
||||
if (TryGetProp(root, out var map, "EarningsDistributionMapping", "earningsDistributionMapping")
|
||||
&& map.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
foreach (var p in map.EnumerateObject())
|
||||
if (long.TryParse(p.Name, out var acc) && p.Value.TryGetInt32(out var pct))
|
||||
mapping[acc] = pct;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch { /* non-JSON / empty */ }
|
||||
}
|
||||
|
||||
if (roomId == 0 && long.TryParse(Request.Query["roomId"], out var rq)) roomId = rq;
|
||||
if (mapping.Count == 0 && method == MethodCustom) method = MethodEqual;
|
||||
return (roomId, method, mapping);
|
||||
}
|
||||
|
||||
private static long? ReadLong(JsonElement root, params string[] names)
|
||||
{
|
||||
if (TryGetProp(root, out var v, names) &&
|
||||
v.ValueKind == JsonValueKind.Number && v.TryGetInt64(out var n)) return n;
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool TryGetProp(JsonElement root, out JsonElement value, params string[] names)
|
||||
{
|
||||
foreach (var n in names)
|
||||
if (root.TryGetProperty(n, out value)) return true;
|
||||
value = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
private sealed class StoredDistribution
|
||||
{
|
||||
public int Method { get; set; }
|
||||
public Dictionary<long, int>? Mapping { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -253,13 +253,37 @@ public class RoomsController(
|
||||
/// </summary>
|
||||
[HttpGet("api/rooms/v1/search")]
|
||||
[HttpGet("api/rooms/v2/search")]
|
||||
[HttpGet("rooms/search")]
|
||||
public async Task<IActionResult> Search(
|
||||
[FromQuery(Name = "query")] string? query,
|
||||
[FromQuery(Name = "value")] string? value)
|
||||
=> Ok((await rooms.SearchAsync(query ?? value ?? string.Empty))
|
||||
.Select(RoomService.ToWireRoom).ToList());
|
||||
|
||||
/// <summary>GET <c>rooms/search</c> — the room-server search route.
|
||||
/// Unlike the <c>api/rooms/v*</c> variants (flat <c>List<Room></c>),
|
||||
/// this one's client contract is a PAGED wrapper object
|
||||
/// <c>{ Results, TotalResults }</c> (IDLBPALJJDJ : PagedResult<Room>);
|
||||
/// returning a bare array crashes the watch's key-based reader.</summary>
|
||||
[HttpGet("rooms/search")]
|
||||
public async Task<IActionResult> SearchRoomServer(
|
||||
[FromQuery(Name = "query")] string? query,
|
||||
[FromQuery(Name = "value")] string? value,
|
||||
[FromQuery] int skip = 0,
|
||||
[FromQuery] int take = 100)
|
||||
{
|
||||
var all = (await rooms.SearchAsync(query ?? value ?? string.Empty)).ToList();
|
||||
var page = all
|
||||
.Skip(Math.Max(skip, 0))
|
||||
.Take(Math.Clamp(take, 1, 100))
|
||||
.Select(RoomService.ToWireRoom)
|
||||
.ToList();
|
||||
return Ok(new
|
||||
{
|
||||
Results = page,
|
||||
TotalResults = all.Count,
|
||||
});
|
||||
}
|
||||
|
||||
/// <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
|
||||
|
||||
Reference in New Issue
Block a user