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:
@@ -78,6 +78,8 @@ public class AvatarV2Controller(DorkNetDbContext db, ILogger<AvatarV2Controller>
|
||||
string? hairColor = null;
|
||||
string? skinColor = null;
|
||||
string? faceFeatures = null;
|
||||
string? outfitSelectionsV2 = null;
|
||||
string? customAvatarItems = null;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(raw))
|
||||
{
|
||||
@@ -91,6 +93,11 @@ public class AvatarV2Controller(DorkNetDbContext db, ILogger<AvatarV2Controller>
|
||||
hairColor = ReadStringField(root, "HairColor");
|
||||
skinColor = ReadStringField(root, "SkinColor");
|
||||
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)
|
||||
@@ -116,6 +123,16 @@ public class AvatarV2Controller(DorkNetDbContext db, ILogger<AvatarV2Controller>
|
||||
if (hairColor is not null) a.HairColor = hairColor;
|
||||
if (skinColor is not null) a.SkinColor = skinColor;
|
||||
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;
|
||||
await db.SaveChangesAsync();
|
||||
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
|
||||
/// client uses this to render remote players' avatars in shared
|
||||
/// 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,
|
||||
HairColor = a?.HairColor ?? 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
|
||||
@@ -190,4 +246,28 @@ public class AvatarV2Dto
|
||||
|
||||
[JsonPropertyName("FaceFeatures")]
|
||||
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]
|
||||
public class InfluencerPartnerProgramController(DorkNetDbContext db) : ControllerBase
|
||||
{
|
||||
// The 2023 client's influencer API (RecNet.Runtime IBEEFFGHGND) reads
|
||||
// BARE scalars, NOT wrapped account objects:
|
||||
// influencers → List<Int32> (account ids)
|
||||
// influencer → Nullable<Int32> (the account id, or null)
|
||||
// myinfluencer → Nullable<Int32> (supported influencer's id, or null)
|
||||
// Returning an object here crashes the strict reader
|
||||
// ("expected:'Number Token', actual:'{'"), which is what broke the
|
||||
// profile view (and the cheer flow that opens it).
|
||||
// 2023 client influencer API (RecNet.Runtime IBEEFFGHGND) — REAL wire
|
||||
// shapes verified against the 2023.03.21 ISIL decompile:
|
||||
//
|
||||
// influencers → OBJECT {"InfluencerIds":[...],"ContinuationToken":null}
|
||||
// - IBEEFFGHGND.txt:1612 — response callback is
|
||||
// Func<FEAJJMACBMF, FGLDKEJLAKB<List<Int32>>>, i.e. the body is
|
||||
// deserialized as DTO object FEAJJMACBMF, NOT a bare array
|
||||
// (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")]
|
||||
[AllowAnonymous]
|
||||
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)
|
||||
.Concat(db.Inventions.Where(i => !i.IsDeleted).Select(i => i.CreatorPlayerId))
|
||||
.Concat(db.CustomAvatarItems.Select(i => i.CreatorPlayerId))
|
||||
.Distinct()
|
||||
.Take(take)
|
||||
.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")]
|
||||
[AllowAnonymous]
|
||||
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
|
||||
? 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();
|
||||
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")]
|
||||
@@ -52,7 +74,7 @@ public class InfluencerPartnerProgramController(DorkNetDbContext db) : Controlle
|
||||
return Content(
|
||||
setting is not null && long.TryParse(setting.Value, out var influencerId)
|
||||
? ((int)influencerId).ToString()
|
||||
: "null",
|
||||
: "0",
|
||||
"application/json");
|
||||
}
|
||||
|
||||
|
||||
@@ -1,134 +1,130 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using System.Text.Json;
|
||||
using System.Security.Cryptography;
|
||||
using DorkNet.Server.Auth;
|
||||
using DorkNet.Server.Data;
|
||||
using DorkNet.Server.Data.Entities;
|
||||
|
||||
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]
|
||||
[Route("api/itemWishlists")]
|
||||
[Authorize]
|
||||
public class ItemWishlistsController(DorkNetDbContext db) : ControllerBase
|
||||
{
|
||||
private long Me => this.RequireCurrentPlayerId();
|
||||
|
||||
[HttpGet]
|
||||
[HttpGet("v1/wishlist/me")]
|
||||
public async Task<IActionResult> Mine()
|
||||
[HttpGet("api/itemWishlists/v1/wishlist/me")]
|
||||
public async Task<IActionResult> Mine() => await ForPlayer(Me);
|
||||
|
||||
[HttpGet("api/itemWishlists/v1/wishlist/{accountId:long}")]
|
||||
public async Task<IActionResult> ForPlayer(long accountId)
|
||||
{
|
||||
var rows = await db.ItemWishlists
|
||||
.Where(w => w.PlayerId == Me)
|
||||
.Where(w => w.PlayerId == accountId)
|
||||
.OrderByDescending(w => w.CreatedAt)
|
||||
.ToListAsync();
|
||||
return Ok(rows.Select(ToWire));
|
||||
return Ok(rows.Select(ToWire).Where(w => w is not null).ToArray());
|
||||
}
|
||||
|
||||
[HttpGet("v1/wishlist")]
|
||||
public async Task<IActionResult> ForPlayer([FromQuery] long? playerId)
|
||||
[HttpPut("api/itemWishlists/v1/wishlist/me/{purchasableItemId:int}")]
|
||||
[HttpPost("api/itemWishlists/v1/wishlist/me/{purchasableItemId:int}")]
|
||||
public async Task<IActionResult> Add(int purchasableItemId)
|
||||
{
|
||||
var pid = playerId is > 0 ? playerId.Value : Me;
|
||||
var rows = await db.ItemWishlists
|
||||
.Where(w => w.PlayerId == pid)
|
||||
.OrderByDescending(w => w.CreatedAt)
|
||||
.ToListAsync();
|
||||
return Ok(rows.Select(ToWire));
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[HttpPost("v1/wishlist")]
|
||||
public async Task<IActionResult> Add()
|
||||
{
|
||||
var req = await ReadRequestAsync();
|
||||
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)
|
||||
var key = purchasableItemId.ToString();
|
||||
var row = await db.ItemWishlists.FirstOrDefaultAsync(w =>
|
||||
w.PlayerId == Me && w.ItemKey == key);
|
||||
if (row is null)
|
||||
{
|
||||
db.ItemWishlists.Add(new ItemWishlistEntity
|
||||
row = new ItemWishlistEntity
|
||||
{
|
||||
PlayerId = Me,
|
||||
ItemKey = key,
|
||||
ItemType = itemType,
|
||||
});
|
||||
ItemType = 0,
|
||||
};
|
||||
db.ItemWishlists.Add(row);
|
||||
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")]
|
||||
[HttpPost("v1/wishlist/remove")]
|
||||
public async Task<IActionResult> Remove()
|
||||
[HttpDelete("api/itemWishlists/v1/wishlist/me/{purchasableItemId:int}")]
|
||||
public async Task<IActionResult> Remove(int purchasableItemId)
|
||||
{
|
||||
var req = await ReadRequestAsync();
|
||||
var key = (req.ItemKey ?? req.ItemId ?? string.Empty).Trim();
|
||||
if (key.Length == 0) return BadRequest(new { error = "missing_item" });
|
||||
var itemType = req.ItemType ?? 0;
|
||||
var key = purchasableItemId.ToString();
|
||||
await db.ItemWishlists
|
||||
.Where(w => w.PlayerId == Me && w.ItemKey == key && w.ItemType == itemType)
|
||||
.Where(w => w.PlayerId == Me && w.ItemKey == key)
|
||||
.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();
|
||||
return new WishlistRequest
|
||||
{
|
||||
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();
|
||||
}
|
||||
WishlistItemId = StableGuid(row.Id, row.PlayerId),
|
||||
AccountId = (int)row.PlayerId,
|
||||
PurchasableItemId = purchasableItemId,
|
||||
row.CreatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
private static object ToWire(ItemWishlistEntity row) => new
|
||||
/// <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)
|
||||
{
|
||||
row.Id,
|
||||
row.PlayerId,
|
||||
row.ItemKey,
|
||||
ItemId = row.ItemKey,
|
||||
row.ItemType,
|
||||
row.CreatedAt,
|
||||
};
|
||||
|
||||
public sealed class WishlistRequest
|
||||
{
|
||||
public string? ItemKey { get; set; }
|
||||
public string? ItemId { get; set; }
|
||||
public int? ItemType { get; set; }
|
||||
Span<byte> input = stackalloc byte[16];
|
||||
BitConverter.TryWriteBytes(input, rowId);
|
||||
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();
|
||||
|
||||
/// <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>
|
||||
/// <summary>POST <c>api/PlayerCheer/v1/create</c> — caller cheers the
|
||||
/// target player. Request fields (verified from RecNet.Runtime
|
||||
/// KFJKDMGHKHE.EMKBFCLIOGN, KFJKDMGHKHE.txt:696-743, all posted via
|
||||
/// BestHTTP AddField → form): <c>PlayerIdTo</c> (target account id),
|
||||
/// <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")]
|
||||
[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)
|
||||
[FromForm(Name = "PlayerIdTo")] long? playerIdTo,
|
||||
[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)
|
||||
return Ok(new { success = false, error = "missing_target" });
|
||||
if (playerIdTo is not long target || target <= 0)
|
||||
return CheerResult(false, "Invalid cheer 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 =>
|
||||
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 });
|
||||
c.TargetRoomId == targetRoomId && c.Type == cheerCategory);
|
||||
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
|
||||
{
|
||||
FromPlayerId = Me,
|
||||
TargetPlayerId = target,
|
||||
Type = type,
|
||||
TargetRoomId = targetRoomId,
|
||||
Type = cheerCategory,
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
await level.AwardXpAsync(target, LevelService.CheerReceivedXp, $"cheer_from:{Me}");
|
||||
// Real notification: a PlayerCheer message the client renders as
|
||||
// "X cheered you" (resolved from FromPlayerId), persisted to the
|
||||
// recipient's notification feed. Replaces the old blank-account
|
||||
// SubscriptionUpdateProfile misuse.
|
||||
// Real notification: a PlayerCheer message the recipient renders as
|
||||
// "X cheered you". Anonymous cheers use the anonymous message type and
|
||||
// don't reveal the cheerer.
|
||||
await systemNotifications.SendAsync(target,
|
||||
SystemNotificationService.MessageType.PlayerCheer, fromPlayerId: Me);
|
||||
return Ok(new { success = true, error = "" });
|
||||
anonymous
|
||||
? 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
|
||||
/// a cheer category as the caller's selected badge. Stored on the
|
||||
/// PlayerSetting key <c>SelectedCheer</c> (string-form int).
|
||||
|
||||
Reference in New Issue
Block a user