mirror of
https://github.com/DorkSquadRR/DorkNet.git
synced 2026-10-26 07:28:45 -07:00
feat: Enhance UgcPurchasablesController to handle bulk requests with object arrays
- Updated Bulk method to parse incoming JSON object arrays for UGC item IDs. - Introduced ReadBulkIdsAsync to handle both object arrays and flat GUID lists. - Added synthetic placeholder responses for missing UGC items to prevent client errors. feat: Implement MatchmakeRoom and MatchmakeSubRoom endpoints in GoToController - Added MatchmakeRoom and MatchmakeSubRoom methods to facilitate joining public and sub-rooms by numeric ID. - Enhanced ApplySubRoomMutation to ensure proper state transitions for sub-rooms. - Improved logging for matchmaking actions. fix: Refactor PlayerSettingsController to handle database save exceptions - Introduced SaveSettingsChangesAsync to manage DbUpdateException for busy SQLite scenarios. - Updated methods to call SaveSettingsChangesAsync instead of direct SaveChangesAsync. chore: Update URL coverage documentation for new matchmaking endpoints - Documented new MatchmakeRoom and MatchmakeSubRoom endpoints in url-coverage.md. chore: Add default room data file for room loading - Included default_room.room to serve as a fallback for missing room data. fix: Improve SQLite concurrency handling in DatabaseBootstrap - Configured SQLite to use WAL mode and set a busy timeout for better concurrency. chore: Create SqliteErrors utility for handling SQLite exceptions - Added SqliteErrors class to encapsulate logic for detecting busy SQLite exceptions. refactor: Update RoomDataBlobService to serve default room data - Modified GetDefaultBlob to load and serve a default room blob with proper role data.
This commit is contained in:
@@ -151,9 +151,9 @@ public class PlayerEventsController(
|
||||
[AllowAnonymous]
|
||||
[HttpPost("api/playerevents/v1/bulk")]
|
||||
[HttpGet("api/playerevents/v1/bulk")]
|
||||
public async Task<ActionResult> Bulk([FromBody] EventBulkRequest? body)
|
||||
public async Task<ActionResult> Bulk()
|
||||
{
|
||||
var ids = await ReadEventIdsAsync(body);
|
||||
var ids = await ReadEventIdsAsync();
|
||||
if (ids.Count == 0) return Ok(Array.Empty<object>());
|
||||
var rows = await db.PlayerEvents
|
||||
.Where(e => ids.Contains(e.Id))
|
||||
@@ -614,29 +614,73 @@ public class PlayerEventsController(
|
||||
private async Task<PlayerEventEntity?> GetOwnedEventAsync(long eventId)
|
||||
=> await db.PlayerEvents.FirstOrDefaultAsync(e => e.Id == eventId && e.CreatorPlayerId == Me);
|
||||
|
||||
private async Task<List<long>> ReadEventIdsAsync(EventBulkRequest? body)
|
||||
private async Task<List<long>> ReadEventIdsAsync()
|
||||
{
|
||||
var ids = new List<long>();
|
||||
if (body?.PlayerEventIds is { Count: > 0 }) ids.AddRange(body.PlayerEventIds);
|
||||
if (body?.EventIds is { Count: > 0 }) ids.AddRange(body.EventIds);
|
||||
if (body?.Ids is { Count: > 0 }) ids.AddRange(body.Ids);
|
||||
|
||||
foreach (var value in Request.Query.SelectMany(q => q.Value))
|
||||
foreach (var part in (value ?? string.Empty).Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||||
if (long.TryParse(part, out var id) && id > 0) ids.Add(id);
|
||||
AddDelimitedIds(ids, value);
|
||||
|
||||
if (Request.HasFormContentType)
|
||||
{
|
||||
var form = await Request.ReadFormAsync();
|
||||
foreach (var key in new[] { "playerEventIds", "PlayerEventIds", "eventIds", "EventIds", "ids", "Ids" })
|
||||
foreach (var value in form[key])
|
||||
foreach (var part in (value ?? string.Empty).Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||||
if (long.TryParse(part, out var id) && id > 0) ids.Add(id);
|
||||
AddDelimitedIds(ids, value);
|
||||
}
|
||||
else if ((Request.ContentLength ?? 0) > 0
|
||||
&& Request.ContentType?.Contains("json", StringComparison.OrdinalIgnoreCase) == true)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var doc = await JsonDocument.ParseAsync(Request.Body);
|
||||
if (doc.RootElement.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
foreach (var key in new[] { "playerEventIds", "PlayerEventIds", "eventIds", "EventIds", "ids", "Ids" })
|
||||
{
|
||||
if (doc.RootElement.TryGetProperty(key, out var prop))
|
||||
AddJsonIds(ids, prop);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
AddJsonIds(ids, doc.RootElement);
|
||||
}
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
return ids.Distinct().Take(200).ToList();
|
||||
}
|
||||
|
||||
private static void AddDelimitedIds(List<long> ids, string? value)
|
||||
{
|
||||
foreach (var part in (value ?? string.Empty)
|
||||
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||||
{
|
||||
if (long.TryParse(part, out var id) && id > 0) ids.Add(id);
|
||||
}
|
||||
}
|
||||
|
||||
private static void AddJsonIds(List<long> ids, JsonElement element)
|
||||
{
|
||||
switch (element.ValueKind)
|
||||
{
|
||||
case JsonValueKind.Array:
|
||||
foreach (var item in element.EnumerateArray())
|
||||
AddJsonIds(ids, item);
|
||||
break;
|
||||
case JsonValueKind.Number:
|
||||
if (element.TryGetInt64(out var id) && id > 0) ids.Add(id);
|
||||
break;
|
||||
case JsonValueKind.String:
|
||||
AddDelimitedIds(ids, element.GetString());
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<string?> ReadStringFieldAsync(params string[] names)
|
||||
{
|
||||
var fields = await ReadRequestFieldsAsync();
|
||||
|
||||
@@ -23,7 +23,7 @@ namespace DorkNet.Server.Controllers.API.PlayerReporting;
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
public class ReportsController(DorkNetDbContext db) : ControllerBase
|
||||
public class ReportsController(DorkNetDbContext db, ILogger<ReportsController> logger) : ControllerBase
|
||||
{
|
||||
[HttpGet("api/PlayerReporting/v1/voteToKickReasons")]
|
||||
[AllowAnonymous]
|
||||
@@ -215,7 +215,15 @@ public class ReportsController(DorkNetDbContext db) : ControllerBase
|
||||
GameSessionId = gameSessionId ?? 0,
|
||||
Category = "hile",
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
try
|
||||
{
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
catch (DbUpdateException ex) when (SqliteErrors.IsBusy(ex))
|
||||
{
|
||||
db.ChangeTracker.Clear();
|
||||
logger.LogWarning(ex, "[hile] dropping low-priority hile heartbeat because sqlite is busy");
|
||||
}
|
||||
return HileResult();
|
||||
}
|
||||
|
||||
|
||||
@@ -74,6 +74,38 @@ public class ProgressionEventsController(DorkNetDbContext db, ServerSettingsServ
|
||||
[AllowAnonymous]
|
||||
public IActionResult Active() => Ok(CurrentProgressionEventId());
|
||||
|
||||
/// <summary>
|
||||
/// The current player's progress record for a given progression event.
|
||||
/// The 2023 client fetches this during InitialRoomLoad
|
||||
/// (<c>api/progressionEvents/record/{id}</c>, where <c>{id}</c> is the
|
||||
/// <c>yyyyMMdd</c> event id). A 404 here faults the room-load coroutine
|
||||
/// with a NullReferenceException and traps the client in a dorm
|
||||
/// matchmaking loop, so we always return a non-null record.
|
||||
/// </summary>
|
||||
[HttpGet("api/progressionEvents/record/{progressionEventId:long}")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> Record(long progressionEventId)
|
||||
{
|
||||
var me = this.RequireCurrentPlayerId();
|
||||
|
||||
// Count this player's completed progression-event objectives so the
|
||||
// record reflects real progress rather than a flat zero.
|
||||
var completed = await db.ObjectiveProgress
|
||||
.CountAsync(o => o.PlayerId == me
|
||||
&& o.IsCompleted
|
||||
&& o.Key.StartsWith("progressionEvent:"));
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
ProgressionEventId = progressionEventId,
|
||||
Xp = 0,
|
||||
ClaimedRewardIndex = completed > 0 ? completed - 1 : -1,
|
||||
PurchasedXpBoostCount = 0,
|
||||
DailyBoostGameplayMinutes = 0,
|
||||
XpBoostExpiresAt = (DateTime?)null,
|
||||
});
|
||||
}
|
||||
|
||||
private static long CurrentProgressionEventId()
|
||||
{
|
||||
var (start, _) = CurrentWeekWindow();
|
||||
|
||||
@@ -1184,32 +1184,16 @@ public class RoomsController(
|
||||
// no DeserializeRoomDataBlobCoroutine, the master init
|
||||
// proceeds with default-zero permissions.
|
||||
//
|
||||
// • Saved dorm / customisable rooms: DataBlobName is the
|
||||
// latest uploaded blob name (RoomEntity.CurrentDataBlobName,
|
||||
// written by StorageController on /upload). Unsaved dorms
|
||||
// deliberately use "" so the baked DormRoom scene loads
|
||||
// without deserializing the synthetic all-perms blob. The
|
||||
// role-only default blob made December wait forever on
|
||||
// StandaloneShapeContainer big-data during first-dorm entry.
|
||||
// • AG-Original rooms: DataBlobName="" → completed-promise
|
||||
// short-circuit → no download → no leaked stale-blob
|
||||
// behaviour → master flow proceeds → spawn fires. Maker
|
||||
// Pen stays locked, which matches public-server semantics
|
||||
// (you can't edit the rec center).
|
||||
//
|
||||
// Heuristic: "customisable" iff it's the dorm OR a user-created
|
||||
// room (CreatorPlayerId != 1, the seeded system account that
|
||||
// owns AG-Originals like the rec center). User clones inherit
|
||||
// the all-perms default blob until their first save replaces
|
||||
// it via StorageController; the rec center / paintball / etc.
|
||||
// serve "" so the persistence flow short-circuits.
|
||||
// If a room has a saved blob, use it. Otherwise point at
|
||||
// room_<id>_v1.dat, which misses S3 and is served by
|
||||
// RoomDataBlobService.GetDefaultBlob. That default is a captured
|
||||
// PersistedRoomData (v38); the older synthetic role-only blob and
|
||||
// empty DataBlobName path both leave the 2023 room-permissions
|
||||
// runtime without data it dereferences during spawn.
|
||||
var roleList = roles ?? Array.Empty<RoomRoleEntity>();
|
||||
var editableRro = room.IsAGRoom && roleList.Any(r => r.Accepted);
|
||||
var customisable = room.IsDormRoom || room.CreatorPlayerId != 1 || editableRro;
|
||||
var dataBlobName = !string.IsNullOrEmpty(room.CurrentDataBlobName)
|
||||
? room.CurrentDataBlobName
|
||||
: room.IsDormRoom ? ""
|
||||
: customisable ? $"room_{room.Id}_v1.dat" : "";
|
||||
: $"room_{room.Id}_v1.dat";
|
||||
|
||||
// Multi-scene rooms (imported via tools/import-room.ps1) carry one
|
||||
// RoomSceneEntity row per sub-room. Emit the full Scenes[] array
|
||||
@@ -1231,8 +1215,9 @@ public class RoomsController(
|
||||
DataBlobName = !string.IsNullOrWhiteSpace(overrideDataBlobName) &&
|
||||
(overrideSceneId == s.OrderIndex || sceneRows.Count == 1)
|
||||
? overrideDataBlobName
|
||||
: room.IsDormRoom ? dataBlobName
|
||||
: s.DataBlobName,
|
||||
: !string.IsNullOrWhiteSpace(s.DataBlobName)
|
||||
? s.DataBlobName
|
||||
: dataBlobName,
|
||||
MaxPlayers = s.MaxPlayers,
|
||||
CanMatchmakeInto = s.CanMatchmakeInto,
|
||||
DataModifiedAt = (s.DataModifiedAt == default ? DateTime.UtcNow : s.DataModifiedAt)
|
||||
@@ -1322,12 +1307,12 @@ public class RoomsController(
|
||||
IReadOnlyList<RoomRoleEntity>? roles = null)
|
||||
{
|
||||
var roleList = roles ?? Array.Empty<RoomRoleEntity>();
|
||||
var editableRro = room.IsAGRoom && roleList.Any(r => r.Accepted);
|
||||
var customisable = room.IsDormRoom || room.CreatorPlayerId != 1 || editableRro;
|
||||
// 2023's room-permissions runtime needs a real PersistedRoomData
|
||||
// source during spawn. If no saved blob exists, use the captured
|
||||
// default via the synthetic room_<id>_v1.dat CDN path.
|
||||
var dataBlobName = !string.IsNullOrEmpty(room.CurrentDataBlobName)
|
||||
? room.CurrentDataBlobName
|
||||
: room.IsDormRoom ? ""
|
||||
: customisable ? $"room_{room.Id}_v1.dat" : "";
|
||||
: $"room_{room.Id}_v1.dat";
|
||||
var updatedAt = (room.UpdatedAt == default ? DateTime.UtcNow : room.UpdatedAt)
|
||||
.ToString("yyyy-MM-ddTHH:mm:ssZ");
|
||||
|
||||
@@ -1339,8 +1324,9 @@ public class RoomsController(
|
||||
Name = s.Name,
|
||||
DataBlob = !string.IsNullOrWhiteSpace(overrideDataBlobName) && sceneRows.Count == 1
|
||||
? overrideDataBlobName
|
||||
: room.IsDormRoom ? dataBlobName
|
||||
: s.DataBlobName,
|
||||
: !string.IsNullOrWhiteSpace(s.DataBlobName)
|
||||
? s.DataBlobName
|
||||
: dataBlobName,
|
||||
DataSavedAt = (s.DataModifiedAt == default ? DateTime.UtcNow : s.DataModifiedAt)
|
||||
.ToString("yyyy-MM-ddTHH:mm:ssZ"),
|
||||
IsSandbox = s.IsSandbox,
|
||||
@@ -1364,27 +1350,11 @@ public class RoomsController(
|
||||
},
|
||||
};
|
||||
|
||||
static int WireRole(int role) => role switch
|
||||
{
|
||||
0 => 30, // CoOwner
|
||||
1 => 20, // Moderator
|
||||
2 => 10, // Host
|
||||
_ => 0,
|
||||
};
|
||||
var wireRoles = new List<object>
|
||||
{
|
||||
new { AccountId = room.CreatorPlayerId, Role = 255, InvitedRole = 0 },
|
||||
BuildRoomAccountRoleWire(room.CreatorPlayerId, 255),
|
||||
};
|
||||
wireRoles.AddRange(roleList.Select(r =>
|
||||
{
|
||||
var role = WireRole(r.Role);
|
||||
return (object)new
|
||||
{
|
||||
AccountId = r.PlayerId,
|
||||
Role = r.Accepted ? role : 0,
|
||||
InvitedRole = r.Accepted ? 0 : role,
|
||||
};
|
||||
}));
|
||||
wireRoles.AddRange(roleList.Select(BuildRoomRoleGrantWire));
|
||||
|
||||
var tags = string.IsNullOrEmpty(room.TagsCsv)
|
||||
? Array.Empty<object>()
|
||||
@@ -2710,9 +2680,8 @@ public class RoomsController(
|
||||
|
||||
// ── Bare-path roles ────────────────────────────────────────────────
|
||||
|
||||
/// <summary>GET <c>rooms/{id}/roles</c> — accepted role grants on
|
||||
/// the room. Owner is implicit (CreatorPlayerId) and surfaces under
|
||||
/// Role=30; co-owner/mod/host rows come from RoomRoles.</summary>
|
||||
/// <summary>GET <c>rooms/{id}/roles</c> — account role DTOs consumed by
|
||||
/// 2023's EFHPLDPNGIM deserializer.</summary>
|
||||
[HttpGet("rooms/{roomId:long}/roles")]
|
||||
public async Task<IActionResult> RolesList(long roomId)
|
||||
{
|
||||
@@ -2721,15 +2690,9 @@ public class RoomsController(
|
||||
var rows = await db.RoomRoles.Where(r => r.RoomId == roomId).ToListAsync();
|
||||
var list = new List<object>(rows.Count + 1)
|
||||
{
|
||||
new { RoomId = roomId, PlayerId = (int)room.CreatorPlayerId, Role = 30 /*Owner*/, Accepted = true },
|
||||
BuildRoomAccountRoleWire(room.CreatorPlayerId, 255),
|
||||
};
|
||||
list.AddRange(rows.Select(r => (object)new
|
||||
{
|
||||
RoomId = roomId,
|
||||
PlayerId = (int)r.PlayerId,
|
||||
Role = r.Role,
|
||||
Accepted = r.Accepted,
|
||||
}));
|
||||
list.AddRange(rows.Select(BuildRoomRoleGrantWire));
|
||||
return Ok(list);
|
||||
}
|
||||
|
||||
@@ -2739,11 +2702,11 @@ public class RoomsController(
|
||||
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
|
||||
if (room is null) return NotFound();
|
||||
if (room.CreatorPlayerId == playerId)
|
||||
return Ok(new { RoomId = roomId, PlayerId = (int)playerId, Role = 30, Accepted = true });
|
||||
return Ok(BuildRoomAccountRoleWire(playerId, 255));
|
||||
var row = await db.RoomRoles
|
||||
.FirstOrDefaultAsync(r => r.RoomId == roomId && r.PlayerId == playerId);
|
||||
if (row is null) return NotFound();
|
||||
return Ok(new { RoomId = roomId, PlayerId = (int)playerId, Role = row.Role, Accepted = row.Accepted });
|
||||
return Ok(BuildRoomRoleGrantWire(row));
|
||||
}
|
||||
|
||||
public sealed class GrantRoleRequest { public int? Role { get; set; } }
|
||||
@@ -2911,6 +2874,44 @@ public class RoomsController(
|
||||
return roomRole >= 0;
|
||||
}
|
||||
|
||||
private static object BuildRoomRoleGrantWire(RoomRoleEntity role)
|
||||
=> BuildRoomAccountRoleWire(
|
||||
role.PlayerId,
|
||||
ToClientRoomRole(role.Role),
|
||||
role.Accepted,
|
||||
role.GrantedByPlayerId);
|
||||
|
||||
private static object BuildRoomAccountRoleWire(
|
||||
long accountId,
|
||||
int role,
|
||||
bool accepted = true,
|
||||
long? lastChangedByAccountId = null)
|
||||
=> new
|
||||
{
|
||||
AccountId = ToClientAccountId(accountId),
|
||||
Role = accepted ? role : 0,
|
||||
LastChangedByAccountId = ToClientAccountId(lastChangedByAccountId),
|
||||
InvitedRole = accepted ? 0 : role,
|
||||
};
|
||||
|
||||
private static int ToClientRoomRole(int roomRole) => roomRole switch
|
||||
{
|
||||
0 => 30, // CoOwner
|
||||
1 => 20, // Moderator
|
||||
2 => 10, // Host
|
||||
_ => 0,
|
||||
};
|
||||
|
||||
private static int ToClientAccountId(long accountId)
|
||||
=> accountId > int.MaxValue
|
||||
? int.MaxValue
|
||||
: accountId < int.MinValue
|
||||
? int.MinValue
|
||||
: (int)accountId;
|
||||
|
||||
private static int? ToClientAccountId(long? accountId)
|
||||
=> accountId.HasValue ? ToClientAccountId(accountId.Value) : null;
|
||||
|
||||
private async Task<object> BuildRoomServerDetailsWithRolesAsync(long roomId)
|
||||
{
|
||||
var room = await db.Rooms.FirstAsync(r => r.Id == roomId);
|
||||
|
||||
@@ -100,15 +100,132 @@ public class UgcPurchasablesController(DorkNetDbContext db) : ControllerBase
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> Bulk()
|
||||
{
|
||||
var fields = await ReadFieldsAsync();
|
||||
var ids = ReadGuidList(fields, "ids", "Ids", "itemIds", "ItemIds", "ugcPurchasableIds", "UgcPurchasableIds");
|
||||
if (ids.Count == 0) return Ok(Array.Empty<object>());
|
||||
// The 2023 client posts {"RoomId":N,"Ids":[{"itemType":1,
|
||||
// "itemId":"<guid>"}]} — Ids is an array of OBJECTS, not flat guid
|
||||
// strings. The generic field-flattener collapses each object to its
|
||||
// JSON text, so the old ReadGuidList parsed ZERO guids and this
|
||||
// endpoint always returned [] — the client's UGC resolver then
|
||||
// re-queried the same ids forever (the growing-list flood in the
|
||||
// server log), the referenced items never resolved, and the online
|
||||
// player/avatar spawn null-reffed on an unresolved custom item
|
||||
// (reportjoinresult FailedToSpawnPlayer → infinite dorm bounce).
|
||||
var requested = await ReadBulkIdsAsync();
|
||||
if (requested.Count == 0) return Ok(Array.Empty<object>());
|
||||
|
||||
var ids = requested.Select(r => r.Id).ToList();
|
||||
var rows = await db.UgcPurchasables
|
||||
.Where(i => !i.IsDeleted && ids.Contains(i.PublicId))
|
||||
.ToListAsync();
|
||||
return Ok(rows.Select(ToWire));
|
||||
var byId = rows.ToDictionary(r => r.PublicId);
|
||||
|
||||
// Return an entry for EVERY requested id, in request order. Items not
|
||||
// in our DB (custom UGC the player owns from real Rec Room that we
|
||||
// never ingested) get a synthetic placeholder so the client treats
|
||||
// the reference as resolved instead of spinning.
|
||||
var result = requested
|
||||
.Select(r => byId.TryGetValue(r.Id, out var row) ? ToWire(row) : SyntheticWire(r.Id, r.ItemType))
|
||||
.ToList();
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>Parse the bulk request's item ids. Handles the 2023 client's
|
||||
/// JSON object-array shape (<c>Ids:[{itemType,itemId}]</c>) as well as
|
||||
/// flat guid lists in the body/form/query for older callers.</summary>
|
||||
private async Task<List<(Guid Id, int ItemType)>> ReadBulkIdsAsync()
|
||||
{
|
||||
var list = new List<(Guid, int)>();
|
||||
var seen = new HashSet<Guid>();
|
||||
void Add(Guid id, int itemType)
|
||||
{
|
||||
if (id != Guid.Empty && seen.Add(id)) list.Add((id, itemType));
|
||||
}
|
||||
|
||||
if ((Request.ContentLength ?? 0) > 0
|
||||
&& Request.ContentType?.Contains("json", StringComparison.OrdinalIgnoreCase) == true)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var doc = await JsonDocument.ParseAsync(Request.Body);
|
||||
var root = doc.RootElement;
|
||||
if (root.ValueKind == JsonValueKind.Object
|
||||
&& (TryGetPropertyCI(root, "Ids", out var idsEl)
|
||||
|| TryGetPropertyCI(root, "itemIds", out idsEl)
|
||||
|| TryGetPropertyCI(root, "ugcPurchasableIds", out idsEl))
|
||||
&& idsEl.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var el in idsEl.EnumerateArray())
|
||||
{
|
||||
if (el.ValueKind == JsonValueKind.String && Guid.TryParse(el.GetString(), out var flat))
|
||||
{
|
||||
Add(flat, 0);
|
||||
}
|
||||
else if (el.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
var itemType = TryGetPropertyCI(el, "itemType", out var it) && it.TryGetInt32(out var n) ? n : 0;
|
||||
if ((TryGetPropertyCI(el, "itemId", out var idEl)
|
||||
|| TryGetPropertyCI(el, "id", out idEl)
|
||||
|| TryGetPropertyCI(el, "ugcPurchasableId", out idEl))
|
||||
&& Guid.TryParse(idEl.GetString(), out var guid))
|
||||
{
|
||||
Add(guid, itemType);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: flat guid lists in form/query (older callers).
|
||||
if (list.Count == 0)
|
||||
{
|
||||
var fields = await ReadFieldsAsync();
|
||||
foreach (var id in ReadGuidList(fields, "ids", "Ids", "itemIds", "ItemIds", "ugcPurchasableIds", "UgcPurchasableIds"))
|
||||
Add(id, 0);
|
||||
}
|
||||
|
||||
return list.Take(200).ToList();
|
||||
}
|
||||
|
||||
private static bool TryGetPropertyCI(JsonElement obj, string name, out JsonElement value)
|
||||
{
|
||||
foreach (var prop in obj.EnumerateObject())
|
||||
{
|
||||
if (string.Equals(prop.Name, name, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
value = prop.Value;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
value = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>Minimal placeholder for a requested ugc item we don't have a
|
||||
/// row for. Same wire shape as <see cref="ToWire"/> so the client's
|
||||
/// deserializer resolves the reference; zero price / empty metadata.</summary>
|
||||
private static object SyntheticWire(Guid id, int itemType) => new
|
||||
{
|
||||
UgcPurchasableId = id,
|
||||
PurchasableItemId = id,
|
||||
Id = id,
|
||||
InternalId = 0L,
|
||||
RoomId = 0L,
|
||||
CreatorPlayerId = 0,
|
||||
Name = string.Empty,
|
||||
Description = string.Empty,
|
||||
ImageName = string.Empty,
|
||||
Price = 0,
|
||||
CurrencyType = 2,
|
||||
ItemType = itemType,
|
||||
IsFeatured = false,
|
||||
SortOrder = 0,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
UpdatedAt = DateTime.UtcNow,
|
||||
};
|
||||
|
||||
private async Task<UgcPurchasableEntity?> FindItemAsync(Dictionary<string, string> fields)
|
||||
{
|
||||
var publicId = ReadGuid(fields, "ugcPurchasableId", "UgcPurchasableId", "itemId", "ItemId", "id", "Id");
|
||||
|
||||
Reference in New Issue
Block a user