mirror of
https://github.com/DorkSquadRR/DorkNet.git
synced 2026-10-25 23:18:44 -07:00
feat: Update PlayerCheer and Avatar handling; enhance API responses and add new fields for 2023 client compatibility
This commit is contained in:
@@ -29,11 +29,16 @@ public sealed class SystemNotificationTests : IClassFixture<DorkNetServerFactory
|
|||||||
var target = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
var target = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
|
||||||
|
|
||||||
using var client = ApiClient(cheerer);
|
using var client = ApiClient(cheerer);
|
||||||
|
// Real client field names (RecNet.Runtime KFJKDMGHKHE.EMKBFCLIOGN):
|
||||||
|
// PlayerIdTo + CheerCategory (+ RoomId, Anonymous). The old test used
|
||||||
|
// TargetAccountId/Type, which matched the buggy handler binding but
|
||||||
|
// NOT the game client — so it passed while cheers were broken live.
|
||||||
using var resp = await client.PostAsync("/api/PlayerCheer/v1/create",
|
using var resp = await client.PostAsync("/api/PlayerCheer/v1/create",
|
||||||
new FormUrlEncodedContent(new Dictionary<string, string>
|
new FormUrlEncodedContent(new Dictionary<string, string>
|
||||||
{
|
{
|
||||||
["TargetAccountId"] = target.PlayerId.ToString(),
|
["PlayerIdTo"] = target.PlayerId.ToString(),
|
||||||
["Type"] = "0",
|
["CheerCategory"] = "0",
|
||||||
|
["Anonymous"] = "false",
|
||||||
}));
|
}));
|
||||||
resp.EnsureSuccessStatusCode();
|
resp.EnsureSuccessStatusCode();
|
||||||
|
|
||||||
|
|||||||
@@ -78,6 +78,8 @@ public class AvatarV2Controller(DorkNetDbContext db, ILogger<AvatarV2Controller>
|
|||||||
string? hairColor = null;
|
string? hairColor = null;
|
||||||
string? skinColor = null;
|
string? skinColor = null;
|
||||||
string? faceFeatures = null;
|
string? faceFeatures = null;
|
||||||
|
string? outfitSelectionsV2 = null;
|
||||||
|
string? customAvatarItems = null;
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(raw))
|
if (!string.IsNullOrWhiteSpace(raw))
|
||||||
{
|
{
|
||||||
@@ -91,6 +93,11 @@ public class AvatarV2Controller(DorkNetDbContext db, ILogger<AvatarV2Controller>
|
|||||||
hairColor = ReadStringField(root, "HairColor");
|
hairColor = ReadStringField(root, "HairColor");
|
||||||
skinColor = ReadStringField(root, "SkinColor");
|
skinColor = ReadStringField(root, "SkinColor");
|
||||||
faceFeatures = ReadStringField(root, "FaceFeatures");
|
faceFeatures = ReadStringField(root, "FaceFeatures");
|
||||||
|
// 2023 client (HHDLNAPEMGP writer EKHJKDNHOPB) also
|
||||||
|
// uploads OutfitSelectionsV2 + CustomAvatarItems —
|
||||||
|
// persist them so remote-player fetches round-trip.
|
||||||
|
outfitSelectionsV2 = ReadStringField(root, "OutfitSelectionsV2");
|
||||||
|
customAvatarItems = ReadStringField(root, "CustomAvatarItems");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (JsonException ex)
|
catch (JsonException ex)
|
||||||
@@ -116,6 +123,16 @@ public class AvatarV2Controller(DorkNetDbContext db, ILogger<AvatarV2Controller>
|
|||||||
if (hairColor is not null) a.HairColor = hairColor;
|
if (hairColor is not null) a.HairColor = hairColor;
|
||||||
if (skinColor is not null) a.SkinColor = skinColor;
|
if (skinColor is not null) a.SkinColor = skinColor;
|
||||||
if (faceFeatures is not null) a.FaceFeatures = faceFeatures;
|
if (faceFeatures is not null) a.FaceFeatures = faceFeatures;
|
||||||
|
if (outfitSelectionsV2 is not null) a.OutfitSelectionsV2 = outfitSelectionsV2;
|
||||||
|
if (customAvatarItems is not null)
|
||||||
|
{
|
||||||
|
// CustomAvatarItems arrives as a JSON ARRAY of
|
||||||
|
// {"CustomAvatarItemId":guid,"BodyPart":byte}; ReadStringField
|
||||||
|
// re-encodes arrays as raw JSON. Guard against non-array junk.
|
||||||
|
a.CustomAvatarItemsJson = customAvatarItems.TrimStart().StartsWith('[')
|
||||||
|
? customAvatarItems
|
||||||
|
: "[]";
|
||||||
|
}
|
||||||
a.UpdatedAt = DateTime.UtcNow;
|
a.UpdatedAt = DateTime.UtcNow;
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
return Ok(ToDto(a));
|
return Ok(ToDto(a));
|
||||||
@@ -140,6 +157,25 @@ public class AvatarV2Controller(DorkNetDbContext db, ILogger<AvatarV2Controller>
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>GET <c>api/avatar/v2/{accountId}</c> — the 2023 client's
|
||||||
|
/// remote-player avatar settings fetch. Decompile evidence
|
||||||
|
/// (2023.03.21 ISIL, RecNet.Runtime FFFIMAGLKEG.txt:4845-5006,
|
||||||
|
/// method KAFJNMDBKDB): URL is String.Format("{0}v2/{1}",
|
||||||
|
/// "api/avatar/", accountId) — the account id is a bare path segment
|
||||||
|
/// directly under v2 (NOT under /equipped/). The response is parsed
|
||||||
|
/// as object HHDLNAPEMGP whose reader (EKHJKDNHOPB.txt:471-766)
|
||||||
|
/// registers OutfitSelections / OutfitSelectionsV2 / FaceFeatures /
|
||||||
|
/// SkinColor / HairColor / CustomAvatarItems. Without this route the
|
||||||
|
/// request 404'd with an empty body → "Failed to load Rec Room
|
||||||
|
/// remote player avatar settings" (Player.log:2597).</summary>
|
||||||
|
[HttpGet("api/avatar/v2/{playerId:long}")]
|
||||||
|
[AllowAnonymous]
|
||||||
|
public async Task<ActionResult<AvatarV2Dto>> GetSettingsFor(long playerId)
|
||||||
|
{
|
||||||
|
var a = await db.Avatars.FirstOrDefaultAsync(x => x.PlayerId == playerId);
|
||||||
|
return Ok(ToDto(a));
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Public lookup of another player's equipped avatar — the
|
/// <summary>Public lookup of another player's equipped avatar — the
|
||||||
/// client uses this to render remote players' avatars in shared
|
/// client uses this to render remote players' avatars in shared
|
||||||
/// rooms. No auth requirement on read so anonymous calls in the
|
/// rooms. No auth requirement on read so anonymous calls in the
|
||||||
@@ -174,7 +210,27 @@ public class AvatarV2Controller(DorkNetDbContext db, ILogger<AvatarV2Controller>
|
|||||||
FaceFeatures = a?.FaceFeatures ?? string.Empty,
|
FaceFeatures = a?.FaceFeatures ?? string.Empty,
|
||||||
HairColor = a?.HairColor ?? string.Empty,
|
HairColor = a?.HairColor ?? string.Empty,
|
||||||
SkinColor = a?.SkinColor ?? string.Empty,
|
SkinColor = a?.SkinColor ?? string.Empty,
|
||||||
|
OutfitSelectionsV2 = a?.OutfitSelectionsV2 ?? string.Empty,
|
||||||
|
CustomAvatarItems = ParseCustomAvatarItems(a?.CustomAvatarItemsJson),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// <summary>Re-hydrate the stored CustomAvatarItems JSON into typed
|
||||||
|
/// wire items so the response always carries a well-formed array
|
||||||
|
/// (never a raw string). Bad/legacy blobs degrade to [].</summary>
|
||||||
|
private static List<CustomAvatarItemRefDto> ParseCustomAvatarItems(string? json)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(json)) return new();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return JsonSerializer.Deserialize<List<CustomAvatarItemRefDto>>(
|
||||||
|
json,
|
||||||
|
new JsonSerializerOptions { PropertyNameCaseInsensitive = true }) ?? new();
|
||||||
|
}
|
||||||
|
catch (JsonException)
|
||||||
|
{
|
||||||
|
return new();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public class AvatarV2Dto
|
public class AvatarV2Dto
|
||||||
@@ -190,4 +246,28 @@ public class AvatarV2Dto
|
|||||||
|
|
||||||
[JsonPropertyName("FaceFeatures")]
|
[JsonPropertyName("FaceFeatures")]
|
||||||
public string FaceFeatures { get; set; } = string.Empty;
|
public string FaceFeatures { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>2023 client field — HHDLNAPEMGP reader key
|
||||||
|
/// "OutfitSelectionsV2" (EKHJKDNHOPB.txt:498-514). Extra keys are
|
||||||
|
/// skipped by the 2020 reader, so this is safe cross-version.</summary>
|
||||||
|
[JsonPropertyName("OutfitSelectionsV2")]
|
||||||
|
public string OutfitSelectionsV2 { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>2023 client field — HHDLNAPEMGP reader key
|
||||||
|
/// "CustomAvatarItems" (EKHJKDNHOPB.txt:594-610), a list of
|
||||||
|
/// BGNNOMBFMLH items.</summary>
|
||||||
|
[JsonPropertyName("CustomAvatarItems")]
|
||||||
|
public List<CustomAvatarItemRefDto> CustomAvatarItems { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>BGNNOMBFMLH — { Guid CustomAvatarItemId, Byte BodyPart }
|
||||||
|
/// (property types BGNNOMBFMLH.txt:3-39; JSON keys registered by reader
|
||||||
|
/// MKAIABAJIAJ.txt:215-258).</summary>
|
||||||
|
public class CustomAvatarItemRefDto
|
||||||
|
{
|
||||||
|
[JsonPropertyName("CustomAvatarItemId")]
|
||||||
|
public Guid CustomAvatarItemId { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("BodyPart")]
|
||||||
|
public byte BodyPart { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
+34
-12
@@ -10,28 +10,50 @@ namespace DorkNet.Server.Controllers.API.InfluencerPartnerProgram;
|
|||||||
[ApiController]
|
[ApiController]
|
||||||
public class InfluencerPartnerProgramController(DorkNetDbContext db) : ControllerBase
|
public class InfluencerPartnerProgramController(DorkNetDbContext db) : ControllerBase
|
||||||
{
|
{
|
||||||
// The 2023 client's influencer API (RecNet.Runtime IBEEFFGHGND) reads
|
// 2023 client influencer API (RecNet.Runtime IBEEFFGHGND) — REAL wire
|
||||||
// BARE scalars, NOT wrapped account objects:
|
// shapes verified against the 2023.03.21 ISIL decompile:
|
||||||
// influencers → List<Int32> (account ids)
|
//
|
||||||
// influencer → Nullable<Int32> (the account id, or null)
|
// influencers → OBJECT {"InfluencerIds":[...],"ContinuationToken":null}
|
||||||
// myinfluencer → Nullable<Int32> (supported influencer's id, or null)
|
// - IBEEFFGHGND.txt:1612 — response callback is
|
||||||
// Returning an object here crashes the strict reader
|
// Func<FEAJJMACBMF, FGLDKEJLAKB<List<Int32>>>, i.e. the body is
|
||||||
// ("expected:'Number Token', actual:'{'"), which is what broke the
|
// deserialized as DTO object FEAJJMACBMF, NOT a bare array
|
||||||
// profile view (and the cheer flow that opens it).
|
// (a bare array crashes the strict reader:
|
||||||
|
// "FGNDDNDAFCI: expected:'{', actual:'['").
|
||||||
|
// - FLEDNKHKIND.txt:215-265 — FEAJJMACBMF's generated reader
|
||||||
|
// registers keys "InfluencerIds" and "ContinuationToken"
|
||||||
|
// (Pascal/camel/lower probes, so camelCase is accepted).
|
||||||
|
// - IBEEFFGHGND_NestedType___c.txt:318-358
|
||||||
|
// (<GetAllInfluencersInternal>b__12_0) — a non-null
|
||||||
|
// ContinuationToken triggers a recursive next-page fetch, so it
|
||||||
|
// must be null once the single page is exhausted.
|
||||||
|
// - IBEEFFGHGND.txt:1547-1575 (OELDHDEIFGP) — client sends
|
||||||
|
// take=1000 + continuationToken query params.
|
||||||
[HttpGet("api/influencerpartnerprogram/influencers")]
|
[HttpGet("api/influencerpartnerprogram/influencers")]
|
||||||
[AllowAnonymous]
|
[AllowAnonymous]
|
||||||
public async Task<IActionResult> Influencers([FromQuery] int take = 50)
|
public async Task<IActionResult> Influencers([FromQuery] int take = 50)
|
||||||
{
|
{
|
||||||
take = Math.Clamp(take, 1, 100);
|
take = Math.Clamp(take, 1, 1000);
|
||||||
var creatorIds = await db.Rooms.Select(r => r.CreatorPlayerId)
|
var creatorIds = await db.Rooms.Select(r => r.CreatorPlayerId)
|
||||||
.Concat(db.Inventions.Where(i => !i.IsDeleted).Select(i => i.CreatorPlayerId))
|
.Concat(db.Inventions.Where(i => !i.IsDeleted).Select(i => i.CreatorPlayerId))
|
||||||
.Concat(db.CustomAvatarItems.Select(i => i.CreatorPlayerId))
|
.Concat(db.CustomAvatarItems.Select(i => i.CreatorPlayerId))
|
||||||
.Distinct()
|
.Distinct()
|
||||||
.Take(take)
|
.Take(take)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
return Ok(creatorIds.Select(id => (int)id).ToArray());
|
return Ok(new
|
||||||
|
{
|
||||||
|
InfluencerIds = creatorIds.Select(id => (int)id).ToArray(),
|
||||||
|
ContinuationToken = (string?)null,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// influencer / myinfluencer → bare Int32 scalar, "no influencer" = 0:
|
||||||
|
// - IBEEFFGHGND.txt:598/951 — both pipelines parse the body as Int32
|
||||||
|
// and convert via Func<Int32, Nullable<Int32>>.
|
||||||
|
// - IBEEFFGHGND_NestedType___c.txt:61-111
|
||||||
|
// (<GetMySupportedInfluencer>b__8_0) — the converter maps
|
||||||
|
// id == 0 → null, else (int?)id. So the correct "none" response is
|
||||||
|
// the literal number 0. A "null"/empty body throws
|
||||||
|
// CHGIJBGGJAG "Response was empty" (Player.log:1947-2031).
|
||||||
[HttpGet("api/influencerpartnerprogram/influencer")]
|
[HttpGet("api/influencerpartnerprogram/influencer")]
|
||||||
[AllowAnonymous]
|
[AllowAnonymous]
|
||||||
public async Task<IActionResult> Influencer([FromQuery] long? accountId = null, [FromQuery] string? username = null)
|
public async Task<IActionResult> Influencer([FromQuery] long? accountId = null, [FromQuery] string? username = null)
|
||||||
@@ -39,7 +61,7 @@ public class InfluencerPartnerProgramController(DorkNetDbContext db) : Controlle
|
|||||||
var playerId = accountId is long id
|
var playerId = accountId is long id
|
||||||
? await db.Players.Where(p => p.Id == id).Select(p => (long?)p.Id).FirstOrDefaultAsync()
|
? await db.Players.Where(p => p.Id == id).Select(p => (long?)p.Id).FirstOrDefaultAsync()
|
||||||
: await db.Players.Where(p => p.Username == username).Select(p => (long?)p.Id).FirstOrDefaultAsync();
|
: await db.Players.Where(p => p.Username == username).Select(p => (long?)p.Id).FirstOrDefaultAsync();
|
||||||
return Content(playerId is long pid ? ((int)pid).ToString() : "null", "application/json");
|
return Content(playerId is long pid ? ((int)pid).ToString() : "0", "application/json");
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("api/influencerpartnerprogram/myinfluencer")]
|
[HttpGet("api/influencerpartnerprogram/myinfluencer")]
|
||||||
@@ -52,7 +74,7 @@ public class InfluencerPartnerProgramController(DorkNetDbContext db) : Controlle
|
|||||||
return Content(
|
return Content(
|
||||||
setting is not null && long.TryParse(setting.Value, out var influencerId)
|
setting is not null && long.TryParse(setting.Value, out var influencerId)
|
||||||
? ((int)influencerId).ToString()
|
? ((int)influencerId).ToString()
|
||||||
: "null",
|
: "0",
|
||||||
"application/json");
|
"application/json");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,134 +1,130 @@
|
|||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using System.Text.Json;
|
using System.Security.Cryptography;
|
||||||
using DorkNet.Server.Auth;
|
using DorkNet.Server.Auth;
|
||||||
using DorkNet.Server.Data;
|
using DorkNet.Server.Data;
|
||||||
using DorkNet.Server.Data.Entities;
|
using DorkNet.Server.Data.Entities;
|
||||||
|
|
||||||
namespace DorkNet.Server.Controllers.API.ItemWishlists;
|
namespace DorkNet.Server.Controllers.API.ItemWishlists;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// api.rec.net/api/itemWishlists — consumable/gift-drop wishlist
|
||||||
|
/// (RecNet.Runtime BPAIBFBKKCN). Wire contract verified against the
|
||||||
|
/// 2023.03.21 ISIL decompile:
|
||||||
|
///
|
||||||
|
/// GET api/itemWishlists/v1/wishlist/{accountId|me}
|
||||||
|
/// - BPAIBFBKKCN.txt:700-763 (ELPBIBPOBJF) — URL is
|
||||||
|
/// String.Concat("api/itemWishlists/v1/wishlist/", idOrMe) where
|
||||||
|
/// idOrMe is "me" for the local account, else the numeric account
|
||||||
|
/// id as a PATH SEGMENT (not a query param). Missing this route
|
||||||
|
/// 404s with an empty body → CHGIJBGGJAG "Response was empty" →
|
||||||
|
/// "Failed to get wishlist for player" (Player.log:2832-2872).
|
||||||
|
/// - Return type FGLDKEJLAKB<List<BFJNGMGONED>> with no converter
|
||||||
|
/// Func → the body is a BARE ARRAY of BFJNGMGONED.
|
||||||
|
/// - BFJNGMGONED = { Guid WishlistItemId, Int32 AccountId,
|
||||||
|
/// Int32 PurchasableItemId, DateTime CreatedAt }
|
||||||
|
/// (property types BFJNGMGONED.txt:3-97; JSON keys registered by
|
||||||
|
/// reader MCDADMNCDEI.txt:343-434, Pascal/camel/lower probes).
|
||||||
|
///
|
||||||
|
/// PUT api/itemWishlists/v1/wishlist/me/{purchasableItemId}
|
||||||
|
/// - BPAIBFBKKCN.txt:161-215 (OOPPFFPOAFH) — format string
|
||||||
|
/// "{0}/v1/wishlist/me/{1}", verb field = 3 = PUT
|
||||||
|
/// (verb table HNLCIDLIIBO.txt:878-903). Response is parsed as the
|
||||||
|
/// AECMPGPHAII<BFJNGMGONED> envelope {"Value","Success","Error"}
|
||||||
|
/// (envelope reader keys LEEAJGDIOHI.txt:243-286).
|
||||||
|
///
|
||||||
|
/// DELETE api/itemWishlists/v1/wishlist/me/{purchasableItemId}
|
||||||
|
/// - BPAIBFBKKCN.txt:375-419 (IBLKDCAIODF) — same URL, verb 4 =
|
||||||
|
/// DELETE, fire-and-forget (LDGADANDBIO) — body ignored.
|
||||||
|
///
|
||||||
|
/// Storage: rows live in ItemWishlists with the purchasable item id
|
||||||
|
/// serialized into the existing ItemKey column (no schema change);
|
||||||
|
/// WishlistItemId is derived deterministically from the row id.
|
||||||
|
/// </summary>
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/itemWishlists")]
|
|
||||||
[Authorize]
|
[Authorize]
|
||||||
public class ItemWishlistsController(DorkNetDbContext db) : ControllerBase
|
public class ItemWishlistsController(DorkNetDbContext db) : ControllerBase
|
||||||
{
|
{
|
||||||
private long Me => this.RequireCurrentPlayerId();
|
private long Me => this.RequireCurrentPlayerId();
|
||||||
|
|
||||||
[HttpGet]
|
[HttpGet("api/itemWishlists/v1/wishlist/me")]
|
||||||
[HttpGet("v1/wishlist/me")]
|
public async Task<IActionResult> Mine() => await ForPlayer(Me);
|
||||||
public async Task<IActionResult> Mine()
|
|
||||||
|
[HttpGet("api/itemWishlists/v1/wishlist/{accountId:long}")]
|
||||||
|
public async Task<IActionResult> ForPlayer(long accountId)
|
||||||
{
|
{
|
||||||
var rows = await db.ItemWishlists
|
var rows = await db.ItemWishlists
|
||||||
.Where(w => w.PlayerId == Me)
|
.Where(w => w.PlayerId == accountId)
|
||||||
.OrderByDescending(w => w.CreatedAt)
|
.OrderByDescending(w => w.CreatedAt)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
return Ok(rows.Select(ToWire));
|
return Ok(rows.Select(ToWire).Where(w => w is not null).ToArray());
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("v1/wishlist")]
|
[HttpPut("api/itemWishlists/v1/wishlist/me/{purchasableItemId:int}")]
|
||||||
public async Task<IActionResult> ForPlayer([FromQuery] long? playerId)
|
[HttpPost("api/itemWishlists/v1/wishlist/me/{purchasableItemId:int}")]
|
||||||
|
public async Task<IActionResult> Add(int purchasableItemId)
|
||||||
{
|
{
|
||||||
var pid = playerId is > 0 ? playerId.Value : Me;
|
var key = purchasableItemId.ToString();
|
||||||
var rows = await db.ItemWishlists
|
var row = await db.ItemWishlists.FirstOrDefaultAsync(w =>
|
||||||
.Where(w => w.PlayerId == pid)
|
w.PlayerId == Me && w.ItemKey == key);
|
||||||
.OrderByDescending(w => w.CreatedAt)
|
if (row is null)
|
||||||
.ToListAsync();
|
|
||||||
return Ok(rows.Select(ToWire));
|
|
||||||
}
|
|
||||||
|
|
||||||
[HttpPost]
|
|
||||||
[HttpPost("v1/wishlist")]
|
|
||||||
public async Task<IActionResult> Add()
|
|
||||||
{
|
{
|
||||||
var req = await ReadRequestAsync();
|
row = new ItemWishlistEntity
|
||||||
var key = (req.ItemKey ?? req.ItemId ?? string.Empty).Trim();
|
|
||||||
if (key.Length == 0) return BadRequest(new { error = "missing_item" });
|
|
||||||
if (key.Length > 128) key = key[..128];
|
|
||||||
var itemType = req.ItemType ?? 0;
|
|
||||||
|
|
||||||
var exists = await db.ItemWishlists.AnyAsync(w =>
|
|
||||||
w.PlayerId == Me && w.ItemKey == key && w.ItemType == itemType);
|
|
||||||
if (!exists)
|
|
||||||
{
|
|
||||||
db.ItemWishlists.Add(new ItemWishlistEntity
|
|
||||||
{
|
{
|
||||||
PlayerId = Me,
|
PlayerId = Me,
|
||||||
ItemKey = key,
|
ItemKey = key,
|
||||||
ItemType = itemType,
|
ItemType = 0,
|
||||||
});
|
};
|
||||||
|
db.ItemWishlists.Add(row);
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
return await Mine();
|
// AECMPGPHAII<BFJNGMGONED> envelope: {"Value","Success","Error"}
|
||||||
|
// (LEEAJGDIOHI.txt:243-286).
|
||||||
|
return Ok(new
|
||||||
|
{
|
||||||
|
Value = ToWire(row),
|
||||||
|
Success = true,
|
||||||
|
Error = (string?)null,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpDelete("v1/wishlist")]
|
[HttpDelete("api/itemWishlists/v1/wishlist/me/{purchasableItemId:int}")]
|
||||||
[HttpPost("v1/wishlist/remove")]
|
public async Task<IActionResult> Remove(int purchasableItemId)
|
||||||
public async Task<IActionResult> Remove()
|
|
||||||
{
|
{
|
||||||
var req = await ReadRequestAsync();
|
var key = purchasableItemId.ToString();
|
||||||
var key = (req.ItemKey ?? req.ItemId ?? string.Empty).Trim();
|
|
||||||
if (key.Length == 0) return BadRequest(new { error = "missing_item" });
|
|
||||||
var itemType = req.ItemType ?? 0;
|
|
||||||
await db.ItemWishlists
|
await db.ItemWishlists
|
||||||
.Where(w => w.PlayerId == Me && w.ItemKey == key && w.ItemType == itemType)
|
.Where(w => w.PlayerId == Me && w.ItemKey == key)
|
||||||
.ExecuteDeleteAsync();
|
.ExecuteDeleteAsync();
|
||||||
return await Mine();
|
// Fire-and-forget on the client (LDGADANDBIO) — body ignored.
|
||||||
|
return Ok(new { Success = true });
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<WishlistRequest> ReadRequestAsync()
|
/// <summary>BFJNGMGONED wire item. Rows whose ItemKey isn't a
|
||||||
|
/// purchasable-item integer (legacy free-form keys) are skipped.</summary>
|
||||||
|
private static object? ToWire(ItemWishlistEntity row)
|
||||||
{
|
{
|
||||||
if (Request.HasFormContentType)
|
if (!int.TryParse(row.ItemKey, out var purchasableItemId)) return null;
|
||||||
|
return new
|
||||||
{
|
{
|
||||||
var form = await Request.ReadFormAsync();
|
WishlistItemId = StableGuid(row.Id, row.PlayerId),
|
||||||
return new WishlistRequest
|
AccountId = (int)row.PlayerId,
|
||||||
{
|
PurchasableItemId = purchasableItemId,
|
||||||
ItemKey = form["itemKey"].FirstOrDefault() ?? form["ItemKey"].FirstOrDefault(),
|
|
||||||
ItemId = form["itemId"].FirstOrDefault() ?? form["ItemId"].FirstOrDefault(),
|
|
||||||
ItemType = int.TryParse(form["itemType"].FirstOrDefault() ?? form["ItemType"].FirstOrDefault(), out var type) ? type : null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
var queryKey = Request.Query["itemKey"].FirstOrDefault() ?? Request.Query["ItemKey"].FirstOrDefault();
|
|
||||||
var queryId = Request.Query["itemId"].FirstOrDefault() ?? Request.Query["ItemId"].FirstOrDefault();
|
|
||||||
if (!string.IsNullOrWhiteSpace(queryKey) || !string.IsNullOrWhiteSpace(queryId))
|
|
||||||
{
|
|
||||||
return new WishlistRequest
|
|
||||||
{
|
|
||||||
ItemKey = queryKey,
|
|
||||||
ItemId = queryId,
|
|
||||||
ItemType = int.TryParse(Request.Query["itemType"].FirstOrDefault() ?? Request.Query["ItemType"].FirstOrDefault(), out var type) ? type : null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
return await JsonSerializer.DeserializeAsync<WishlistRequest>(
|
|
||||||
Request.Body,
|
|
||||||
new JsonSerializerOptions { PropertyNameCaseInsensitive = true })
|
|
||||||
?? new WishlistRequest();
|
|
||||||
}
|
|
||||||
catch (JsonException)
|
|
||||||
{
|
|
||||||
return new WishlistRequest();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static object ToWire(ItemWishlistEntity row) => new
|
|
||||||
{
|
|
||||||
row.Id,
|
|
||||||
row.PlayerId,
|
|
||||||
row.ItemKey,
|
|
||||||
ItemId = row.ItemKey,
|
|
||||||
row.ItemType,
|
|
||||||
row.CreatedAt,
|
row.CreatedAt,
|
||||||
};
|
};
|
||||||
|
}
|
||||||
|
|
||||||
public sealed class WishlistRequest
|
/// <summary>Deterministic per-row Guid — the client only uses
|
||||||
|
/// WishlistItemId as an opaque identity, but it must stay stable
|
||||||
|
/// across requests.</summary>
|
||||||
|
private static Guid StableGuid(long rowId, long playerId)
|
||||||
{
|
{
|
||||||
public string? ItemKey { get; set; }
|
Span<byte> input = stackalloc byte[16];
|
||||||
public string? ItemId { get; set; }
|
BitConverter.TryWriteBytes(input, rowId);
|
||||||
public int? ItemType { get; set; }
|
BitConverter.TryWriteBytes(input[8..], playerId);
|
||||||
|
Span<byte> hash = stackalloc byte[32];
|
||||||
|
SHA256.HashData(input, hash);
|
||||||
|
return new Guid(hash[..16]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,44 +25,78 @@ public class PlayerCheerController(
|
|||||||
{
|
{
|
||||||
private long Me => this.RequireCurrentPlayerId();
|
private long Me => this.RequireCurrentPlayerId();
|
||||||
|
|
||||||
/// <summary>POST <c>api/PlayerCheer/v1/create</c> — caller cheers
|
/// <summary>POST <c>api/PlayerCheer/v1/create</c> — caller cheers the
|
||||||
/// the target player. <c>Type</c> matches the same enum as
|
/// target player. Request fields (verified from RecNet.Runtime
|
||||||
/// <c>CheerEntity.Type</c>. Idempotent on (caller, target, type)
|
/// KFJKDMGHKHE.EMKBFCLIOGN, KFJKDMGHKHE.txt:696-743, all posted via
|
||||||
/// to match <c>PlayerStateController.CheerPlayer</c>, so the same
|
/// BestHTTP AddField → form): <c>PlayerIdTo</c> (target account id),
|
||||||
/// category click doesn't inflate the count.</summary>
|
/// <c>CheerCategory</c> (the cheer enum), <c>RoomId</c> (optional room
|
||||||
|
/// context), <c>Anonymous</c> (bool). The OLD binding read
|
||||||
|
/// <c>TargetAccountId</c>/<c>Type</c>, which never matched — the target
|
||||||
|
/// bound to null, the handler reported failure, and the watch surfaced
|
||||||
|
/// "Failed to cheer player — Something went wrong". The client parses the
|
||||||
|
/// response into PHMHCPEMABG { bool, string } and gates on the bool, so
|
||||||
|
/// the body must report success. Idempotent on (caller, target, room,
|
||||||
|
/// category).</summary>
|
||||||
[HttpPost("api/PlayerCheer/v1/create")]
|
[HttpPost("api/PlayerCheer/v1/create")]
|
||||||
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
|
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
|
||||||
public async Task<IActionResult> Create(
|
public async Task<IActionResult> Create(
|
||||||
[FromForm(Name = "TargetAccountId")] long? targetAccountId,
|
[FromForm(Name = "PlayerIdTo")] long? playerIdTo,
|
||||||
[FromForm(Name = "Type")] int type = 0)
|
[FromForm(Name = "CheerCategory")] int cheerCategory = 0,
|
||||||
|
[FromForm(Name = "RoomId")] long? roomId = null,
|
||||||
|
[FromForm(Name = "Anonymous")] bool anonymous = false)
|
||||||
{
|
{
|
||||||
if (targetAccountId is not long target || target <= 0)
|
if (playerIdTo is not long target || target <= 0)
|
||||||
return Ok(new { success = false, error = "missing_target" });
|
return CheerResult(false, "Invalid cheer target.");
|
||||||
if (Me == target)
|
if (Me == target)
|
||||||
return Ok(new { success = false, error = "cannot_cheer_self" });
|
return CheerResult(false, "You can't cheer yourself.");
|
||||||
|
|
||||||
|
var targetRoomId = roomId ?? 0;
|
||||||
var existing = await db.Cheers.FirstOrDefaultAsync(c =>
|
var existing = await db.Cheers.FirstOrDefaultAsync(c =>
|
||||||
c.FromPlayerId == Me && c.TargetPlayerId == target &&
|
c.FromPlayerId == Me && c.TargetPlayerId == target &&
|
||||||
c.TargetRoomId == 0 && c.Type == type);
|
c.TargetRoomId == targetRoomId && c.Type == cheerCategory);
|
||||||
if (existing is not null) return Ok(new { success = true, already_cheered = true });
|
if (existing is not null)
|
||||||
|
// Already cheered — still a success from the client's POV (the
|
||||||
|
// string carries the client's own "very recently" phrasing).
|
||||||
|
return CheerResult(true, "You have already cheered this player very recently.");
|
||||||
|
|
||||||
db.Cheers.Add(new CheerEntity
|
db.Cheers.Add(new CheerEntity
|
||||||
{
|
{
|
||||||
FromPlayerId = Me,
|
FromPlayerId = Me,
|
||||||
TargetPlayerId = target,
|
TargetPlayerId = target,
|
||||||
Type = type,
|
TargetRoomId = targetRoomId,
|
||||||
|
Type = cheerCategory,
|
||||||
});
|
});
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
await level.AwardXpAsync(target, LevelService.CheerReceivedXp, $"cheer_from:{Me}");
|
await level.AwardXpAsync(target, LevelService.CheerReceivedXp, $"cheer_from:{Me}");
|
||||||
// Real notification: a PlayerCheer message the client renders as
|
// Real notification: a PlayerCheer message the recipient renders as
|
||||||
// "X cheered you" (resolved from FromPlayerId), persisted to the
|
// "X cheered you". Anonymous cheers use the anonymous message type and
|
||||||
// recipient's notification feed. Replaces the old blank-account
|
// don't reveal the cheerer.
|
||||||
// SubscriptionUpdateProfile misuse.
|
|
||||||
await systemNotifications.SendAsync(target,
|
await systemNotifications.SendAsync(target,
|
||||||
SystemNotificationService.MessageType.PlayerCheer, fromPlayerId: Me);
|
anonymous
|
||||||
return Ok(new { success = true, error = "" });
|
? SystemNotificationService.MessageType.PlayerCheerAnonymous
|
||||||
|
: SystemNotificationService.MessageType.PlayerCheer,
|
||||||
|
fromPlayerId: anonymous ? 0 : Me);
|
||||||
|
return CheerResult(true, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>The create response deserializes into PHMHCPEMABG
|
||||||
|
/// { bool GJLFIFEJDEH, string ABGLLJPIMIO } (KFJKDMGHKHE.txt maps the raw
|
||||||
|
/// body via Func<PHMHCPEMABG, LDGADANDBIO>). The JSON keys are
|
||||||
|
/// obfuscated with no literals in the dump, so emit the success bool and
|
||||||
|
/// message under the common key spellings via a Dictionary (literal keys
|
||||||
|
/// bypass the camelCase policy; LitJson ignores the ones it doesn't
|
||||||
|
/// read).</summary>
|
||||||
|
private IActionResult CheerResult(bool success, string message) =>
|
||||||
|
Ok(new Dictionary<string, object?>
|
||||||
|
{
|
||||||
|
["Success"] = success,
|
||||||
|
["success"] = success,
|
||||||
|
["Message"] = message,
|
||||||
|
["message"] = message,
|
||||||
|
["Error"] = success ? string.Empty : message,
|
||||||
|
["error"] = success ? string.Empty : message,
|
||||||
|
});
|
||||||
|
|
||||||
/// <summary>POST <c>api/PlayerCheer/v1/SetSelectedCheer</c> — pin
|
/// <summary>POST <c>api/PlayerCheer/v1/SetSelectedCheer</c> — pin
|
||||||
/// a cheer category as the caller's selected badge. Stored on the
|
/// a cheer category as the caller's selected badge. Stored on the
|
||||||
/// PlayerSetting key <c>SelectedCheer</c> (string-form int).
|
/// PlayerSetting key <c>SelectedCheer</c> (string-form int).
|
||||||
|
|||||||
+109
-60
@@ -1,32 +1,114 @@
|
|||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using System.Text.Json;
|
||||||
using DorkNet.Server.Auth;
|
using DorkNet.Server.Auth;
|
||||||
using DorkNet.Server.Data;
|
using DorkNet.Server.Data;
|
||||||
using DorkNet.Server.Data.Entities;
|
using DorkNet.Server.Data.Entities;
|
||||||
|
|
||||||
namespace DorkNet.Server.Controllers.PlatformNotifications;
|
namespace DorkNet.Server.Controllers.PlatformNotifications;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// platformnotifications.rec.net — the 2023 client's platform push-
|
||||||
|
/// notification preference service (RecNet.Runtime LELAJKMOMIA; the
|
||||||
|
/// service host enum value 15 resolves to the PlatformNotifications
|
||||||
|
/// entry of the service map, relative paths "preferences",
|
||||||
|
/// "config/categories", "badge", "device").
|
||||||
|
///
|
||||||
|
/// Wire contracts verified against the 2023.03.21 ISIL decompile:
|
||||||
|
///
|
||||||
|
/// GET preferences → RecNet.PlatformNotificationPreferencesDTO — an
|
||||||
|
/// OBJECT whose only key is "MutedCategories" (a list of category
|
||||||
|
/// enum values). Generated reader JLHCGPANJCB.txt:151-185 registers
|
||||||
|
/// exactly that key. LELAJKMOMIA.txt:1102 (DCGHGFDPAMM) issues the
|
||||||
|
/// GET; an array/empty body throws the strict-reader error seen in
|
||||||
|
/// Player.log:1409-1711 ("expected:'{', actual:'['" /
|
||||||
|
/// "Malformed Response: '[]'" → "Failed to get notification
|
||||||
|
/// preferences").
|
||||||
|
///
|
||||||
|
/// PUT preferences → LELAJKMOMIA.txt:1480 (KKPAKGOCPEA) sends the
|
||||||
|
/// same DTO ({"MutedCategories":[...]}) with HTTP verb 3 = PUT
|
||||||
|
/// (verb table HNLCIDLIIBO.txt:878-903). Fire-and-forget
|
||||||
|
/// (LDGADANDBIO) — response body ignored.
|
||||||
|
///
|
||||||
|
/// GET config/categories → parsed as envelope MKIFDCBPEAD
|
||||||
|
/// {"Results":[...],"TotalResults":N} (reader JBGKGCLPCHP.txt:203-238)
|
||||||
|
/// then converted via Func<MKIFDCBPEAD, List<PlatformNotification-
|
||||||
|
/// CategoryConfigDTO>> (LELAJKMOMIA.txt:1930 region). Each item is
|
||||||
|
/// {"CategoryId","Importance","Name","Description","IsMuteable"}
|
||||||
|
/// (reader JNFDPPCIPHG.txt:371-462).
|
||||||
|
/// </summary>
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Authorize]
|
[Authorize]
|
||||||
public class PlatformNotificationsController(DorkNetDbContext db) : ControllerBase
|
public class PlatformNotificationsController(DorkNetDbContext db) : ControllerBase
|
||||||
{
|
{
|
||||||
private long Me => this.RequireCurrentPlayerId();
|
private long Me => this.RequireCurrentPlayerId();
|
||||||
|
|
||||||
|
/// <summary>The category catalogue we expose. CategoryId values are
|
||||||
|
/// round-tripped between config/categories and MutedCategories, so
|
||||||
|
/// they only need to be internally consistent.</summary>
|
||||||
|
private static readonly (int CategoryId, string Name, string Description)[] CategoryCatalog =
|
||||||
|
{
|
||||||
|
(1, "Messages", "Direct messages from other players"),
|
||||||
|
(2, "Friend Requests", "Incoming friend requests"),
|
||||||
|
(3, "Event Invites", "Invitations to events"),
|
||||||
|
(4, "Announcements", "News and announcements from Rec Room"),
|
||||||
|
};
|
||||||
|
|
||||||
[HttpGet("/preferences")]
|
[HttpGet("/preferences")]
|
||||||
[HttpGet("/platformnotifications/preferences")]
|
[HttpGet("/platformnotifications/preferences")]
|
||||||
public async Task<IActionResult> Preferences()
|
public async Task<IActionResult> Preferences()
|
||||||
{
|
{
|
||||||
var row = await db.NotificationPrefs.FirstOrDefaultAsync(p => p.PlayerId == Me);
|
var row = await db.NotificationPrefs.FirstOrDefaultAsync(p => p.PlayerId == Me);
|
||||||
return Ok(ToPreferencesWire(row));
|
// Exact DTO shape: {"MutedCategories":[...]} — see class doc.
|
||||||
|
return Ok(new { MutedCategories = MutedCategoriesFor(row) });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>PUT preferences — body is the same DTO
|
||||||
|
/// ({"MutedCategories":[1,4]}). Parsed manually so we tolerate any
|
||||||
|
/// key casing and missing/null lists. Response body is ignored by
|
||||||
|
/// the client (LDGADANDBIO), but we echo the stored DTO anyway.</summary>
|
||||||
[HttpPut("/preferences")]
|
[HttpPut("/preferences")]
|
||||||
[HttpPost("/preferences")]
|
[HttpPost("/preferences")]
|
||||||
[HttpPut("/platformnotifications/preferences")]
|
[HttpPut("/platformnotifications/preferences")]
|
||||||
[HttpPost("/platformnotifications/preferences")]
|
[HttpPost("/platformnotifications/preferences")]
|
||||||
public async Task<IActionResult> SetPreferences([FromBody] PlatformNotificationPrefsRequest? body)
|
public async Task<IActionResult> SetPreferences()
|
||||||
{
|
{
|
||||||
|
List<int>? muted = null;
|
||||||
|
string raw;
|
||||||
|
using (var reader = new StreamReader(Request.Body))
|
||||||
|
raw = await reader.ReadToEndAsync();
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(raw))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var doc = JsonDocument.Parse(raw);
|
||||||
|
if (doc.RootElement.ValueKind == JsonValueKind.Object)
|
||||||
|
{
|
||||||
|
foreach (var prop in doc.RootElement.EnumerateObject())
|
||||||
|
{
|
||||||
|
if (!prop.Name.Equals("MutedCategories", StringComparison.OrdinalIgnoreCase))
|
||||||
|
continue;
|
||||||
|
if (prop.Value.ValueKind != JsonValueKind.Array) continue;
|
||||||
|
muted = new List<int>();
|
||||||
|
foreach (var el in prop.Value.EnumerateArray())
|
||||||
|
{
|
||||||
|
if (el.ValueKind == JsonValueKind.Number && el.TryGetInt32(out var v))
|
||||||
|
muted.Add(v);
|
||||||
|
else if (el.ValueKind == JsonValueKind.String
|
||||||
|
&& int.TryParse(el.GetString(), out var s))
|
||||||
|
muted.Add(s);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (JsonException)
|
||||||
|
{
|
||||||
|
// Non-JSON body — treat as "no change".
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var row = await db.NotificationPrefs.FirstOrDefaultAsync(p => p.PlayerId == Me);
|
var row = await db.NotificationPrefs.FirstOrDefaultAsync(p => p.PlayerId == Me);
|
||||||
if (row is null)
|
if (row is null)
|
||||||
{
|
{
|
||||||
@@ -38,85 +120,52 @@ public class PlatformNotificationsController(DorkNetDbContext db) : ControllerBa
|
|||||||
db.NotificationPrefs.Add(row);
|
db.NotificationPrefs.Add(row);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (body is not null)
|
if (muted is not null)
|
||||||
{
|
{
|
||||||
row.AllowAll = body.AllowAll ?? body.Enabled ?? row.AllowAll;
|
row.AllowMessage = !muted.Contains(1);
|
||||||
row.AllowMessage = body.AllowMessages ?? body.Messages ?? row.AllowMessage;
|
row.AllowFriendRequest = !muted.Contains(2);
|
||||||
row.AllowFriendRequest = body.AllowFriendRequests ?? body.FriendRequests ?? row.AllowFriendRequest;
|
row.AllowEventInvite = !muted.Contains(3);
|
||||||
row.AllowEventInvite = body.AllowEventInvites ?? body.EventInvites ?? row.AllowEventInvite;
|
row.AllowAnnouncements = !muted.Contains(4);
|
||||||
row.AllowAnnouncements = body.AllowAnnouncements ?? body.Announcements ?? row.AllowAnnouncements;
|
|
||||||
row.UpdatedAt = DateTime.UtcNow;
|
row.UpdatedAt = DateTime.UtcNow;
|
||||||
}
|
}
|
||||||
|
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
return Ok(ToPreferencesWire(row));
|
return Ok(new { MutedCategories = MutedCategoriesFor(row) });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>GET config/categories — {"Results":[...],"TotalResults":N}
|
||||||
|
/// envelope of PlatformNotificationCategoryConfigDTO items.</summary>
|
||||||
[HttpGet("/config/categories")]
|
[HttpGet("/config/categories")]
|
||||||
[HttpGet("/platformnotifications/config/categories")]
|
[HttpGet("/platformnotifications/config/categories")]
|
||||||
[AllowAnonymous]
|
[AllowAnonymous]
|
||||||
public IActionResult Categories()
|
public IActionResult Categories()
|
||||||
{
|
{
|
||||||
var categories = new[]
|
var categories = CategoryCatalog
|
||||||
|
.Select(c => new
|
||||||
{
|
{
|
||||||
Category(1, "messages", "Messages"),
|
c.CategoryId,
|
||||||
Category(2, "friendRequests", "Friend Requests"),
|
Importance = 0,
|
||||||
Category(3, "eventInvites", "Event Invites"),
|
c.Name,
|
||||||
Category(4, "announcements", "Announcements"),
|
c.Description,
|
||||||
};
|
IsMuteable = true,
|
||||||
|
})
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
return Ok(new
|
return Ok(new
|
||||||
{
|
{
|
||||||
Categories = categories,
|
|
||||||
Results = categories,
|
Results = categories,
|
||||||
TotalResults = categories.Length,
|
TotalResults = categories.Length,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private static object Category(int id, string key, string name) => new
|
private static int[] MutedCategoriesFor(NotificationPrefsEntity? row)
|
||||||
{
|
{
|
||||||
Id = id,
|
if (row is null) return Array.Empty<int>();
|
||||||
CategoryId = id,
|
var muted = new List<int>(4);
|
||||||
Key = key,
|
if (!row.AllowMessage) muted.Add(1);
|
||||||
Name = name,
|
if (!row.AllowFriendRequest) muted.Add(2);
|
||||||
DisplayName = name,
|
if (!row.AllowEventInvite) muted.Add(3);
|
||||||
IsEnabled = true,
|
if (!row.AllowAnnouncements) muted.Add(4);
|
||||||
Enabled = true,
|
return muted.ToArray();
|
||||||
DefaultEnabled = true,
|
|
||||||
};
|
|
||||||
|
|
||||||
private static object ToPreferencesWire(NotificationPrefsEntity? row) => new
|
|
||||||
{
|
|
||||||
AllowAll = row?.AllowAll ?? true,
|
|
||||||
Enabled = row?.AllowAll ?? true,
|
|
||||||
AllowMessages = row?.AllowMessage ?? true,
|
|
||||||
Messages = row?.AllowMessage ?? true,
|
|
||||||
AllowFriendRequests = row?.AllowFriendRequest ?? true,
|
|
||||||
FriendRequests = row?.AllowFriendRequest ?? true,
|
|
||||||
AllowEventInvites = row?.AllowEventInvite ?? true,
|
|
||||||
EventInvites = row?.AllowEventInvite ?? true,
|
|
||||||
AllowAnnouncements = row?.AllowAnnouncements ?? true,
|
|
||||||
Announcements = row?.AllowAnnouncements ?? true,
|
|
||||||
Categories = new[]
|
|
||||||
{
|
|
||||||
new { CategoryId = 1, Key = "messages", Enabled = row?.AllowMessage ?? true },
|
|
||||||
new { CategoryId = 2, Key = "friendRequests", Enabled = row?.AllowFriendRequest ?? true },
|
|
||||||
new { CategoryId = 3, Key = "eventInvites", Enabled = row?.AllowEventInvite ?? true },
|
|
||||||
new { CategoryId = 4, Key = "announcements", Enabled = row?.AllowAnnouncements ?? true },
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
public sealed class PlatformNotificationPrefsRequest
|
|
||||||
{
|
|
||||||
public bool? AllowAll { get; set; }
|
|
||||||
public bool? Enabled { get; set; }
|
|
||||||
public bool? AllowMessages { get; set; }
|
|
||||||
public bool? Messages { get; set; }
|
|
||||||
public bool? AllowFriendRequests { get; set; }
|
|
||||||
public bool? FriendRequests { get; set; }
|
|
||||||
public bool? AllowEventInvites { get; set; }
|
|
||||||
public bool? EventInvites { get; set; }
|
|
||||||
public bool? AllowAnnouncements { get; set; }
|
|
||||||
public bool? Announcements { get; set; }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,6 +34,17 @@ public class AvatarEntity
|
|||||||
[MaxLength(2048)]
|
[MaxLength(2048)]
|
||||||
public string OutfitSelections { get; set; } = string.Empty;
|
public string OutfitSelections { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>2023 client's OutfitSelectionsV2 blob (HHDLNAPEMGP key
|
||||||
|
/// "OutfitSelectionsV2", reader EKHJKDNHOPB.txt:498-514 in the
|
||||||
|
/// 2023.03.21 ISIL dump). Opaque — round-tripped verbatim.</summary>
|
||||||
|
[MaxLength(8192)]
|
||||||
|
public string OutfitSelectionsV2 { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>2023 client's equipped custom avatar items — JSON array
|
||||||
|
/// of {"CustomAvatarItemId":guid,"BodyPart":byte} (BGNNOMBFMLH,
|
||||||
|
/// reader MKAIABAJIAJ.txt:215-258). Stored as raw JSON.</summary>
|
||||||
|
public string CustomAvatarItemsJson { get; set; } = "[]";
|
||||||
|
|
||||||
/// <summary>v2 face features — opaque JSON written by the face
|
/// <summary>v2 face features — opaque JSON written by the face
|
||||||
/// editor; we never parse it server-side, just round-trip.</summary>
|
/// editor; we never parse it server-side, just round-trip.</summary>
|
||||||
[MaxLength(8192)]
|
[MaxLength(8192)]
|
||||||
|
|||||||
@@ -111,6 +111,7 @@ public static class DatabaseBootstrap
|
|||||||
await EnsureInventionColumnsAsync(db);
|
await EnsureInventionColumnsAsync(db);
|
||||||
await EnsureGameRewardColumnsAsync(db);
|
await EnsureGameRewardColumnsAsync(db);
|
||||||
await EnsureGiftPackageColumnsAsync(db);
|
await EnsureGiftPackageColumnsAsync(db);
|
||||||
|
await EnsureAvatarColumnsAsync(db);
|
||||||
await EnsureMarch2023TablesAsync(db);
|
await EnsureMarch2023TablesAsync(db);
|
||||||
|
|
||||||
// Coach system account at Player.Id=1. The RR-Original room seeder
|
// Coach system account at Player.Id=1. The RR-Original room seeder
|
||||||
@@ -381,6 +382,38 @@ public static class DatabaseBootstrap
|
|||||||
catch { }
|
catch { }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Add the 2023-client avatar columns (OutfitSelectionsV2 +
|
||||||
|
/// CustomAvatarItemsJson on Avatars) — post-Initial, so existing DBs
|
||||||
|
/// need an idempotent ALTER on both providers.</summary>
|
||||||
|
private static async Task EnsureAvatarColumnsAsync(DorkNetDbContext db)
|
||||||
|
{
|
||||||
|
var provider = db.Database.ProviderName ?? string.Empty;
|
||||||
|
if (provider.Contains("Npgsql", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
await db.Database.ExecuteSqlRawAsync(
|
||||||
|
@"ALTER TABLE ""Avatars"" ADD COLUMN IF NOT EXISTS ""OutfitSelectionsV2"" character varying(8192) NOT NULL DEFAULT '';");
|
||||||
|
await db.Database.ExecuteSqlRawAsync(
|
||||||
|
@"ALTER TABLE ""Avatars"" ADD COLUMN IF NOT EXISTS ""CustomAvatarItemsJson"" text NOT NULL DEFAULT '[]';");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// SQLite has no ADD COLUMN IF NOT EXISTS; ignore the duplicate-
|
||||||
|
// column error when it already exists.
|
||||||
|
await db.Database.ExecuteSqlRawAsync(
|
||||||
|
@"ALTER TABLE ""Avatars"" ADD COLUMN ""OutfitSelectionsV2"" TEXT NOT NULL DEFAULT '';");
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await db.Database.ExecuteSqlRawAsync(
|
||||||
|
@"ALTER TABLE ""Avatars"" ADD COLUMN ""CustomAvatarItemsJson"" TEXT NOT NULL DEFAULT '[]';");
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
}
|
||||||
|
|
||||||
private static async Task EnsureInventionColumnsAsync(DorkNetDbContext db)
|
private static async Task EnsureInventionColumnsAsync(DorkNetDbContext db)
|
||||||
{
|
{
|
||||||
var provider = db.Database.ProviderName ?? string.Empty;
|
var provider = db.Database.ProviderName ?? string.Empty;
|
||||||
|
|||||||
Reference in New Issue
Block a user