mirror of
https://github.com/DorkSquadRR/DorkNet.git
synced 2026-10-25 23:18:44 -07:00
feat: Implement quest reward system with item selection and storage; update related services and database schema
This commit is contained in:
@@ -76,6 +76,16 @@ public sealed class RoomSave2023Tests : IClassFixture<DorkNetServerFactory>
|
|||||||
using var json = JsonDocument.Parse(responseText);
|
using var json = JsonDocument.Parse(responseText);
|
||||||
Assert.True(json.RootElement.GetProperty("success").GetBoolean(), responseText);
|
Assert.True(json.RootElement.GetProperty("success").GetBoolean(), responseText);
|
||||||
|
|
||||||
|
// 2023 commit-response contract (NEOPBOMGIOG): value must carry
|
||||||
|
// BOTH a Room and a SubRoomDataSave or the client's post-parse
|
||||||
|
// validator NREs and the watch shows "Failed to save room".
|
||||||
|
var value = json.RootElement.GetProperty("value");
|
||||||
|
Assert.Equal(JsonValueKind.Object, value.GetProperty("Room").ValueKind);
|
||||||
|
var save = value.GetProperty("SubRoomDataSave");
|
||||||
|
Assert.Equal(blobName, save.GetProperty("DataBlob").GetString());
|
||||||
|
Assert.True(save.TryGetProperty("SubRoomDataSaveId", out _), responseText);
|
||||||
|
Assert.True(save.TryGetProperty("CreatedAt", out _), responseText);
|
||||||
|
|
||||||
await using (var scope = _factory.Services.CreateAsyncScope())
|
await using (var scope = _factory.Services.CreateAsyncScope())
|
||||||
{
|
{
|
||||||
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
|
||||||
|
|||||||
@@ -68,27 +68,25 @@ public sealed class WatchShop2023Tests : IClassFixture<DorkNetServerFactory>
|
|||||||
var storefront = await GetJsonAsync(client, "/api/storefronts/v3/giftdropstore/3");
|
var storefront = await GetJsonAsync(client, "/api/storefronts/v3/giftdropstore/3");
|
||||||
var rows = storefront.GetProperty("StoreItems");
|
var rows = storefront.GetProperty("StoreItems");
|
||||||
|
|
||||||
// Every consumable tile carries a bracketed ConsumableItemDesc (the
|
// Food / KO / camera consumables surface with the client's literal
|
||||||
// client's literal item key) and no avatar item — that's what routes
|
// ConsumableItemDesc and no avatar item — that's what routes them to
|
||||||
// it to the Shop's Consumables tab and binds the baked prefab.
|
// the Shop's Consumables tab and binds the baked prefab. (Hair dyes
|
||||||
var consumables = new List<string>();
|
// are a hybrid: they also carry an AvatarItemDesc colour guid, so
|
||||||
|
// they're excluded from the no-avatar-item check.)
|
||||||
|
var byDesc = new Dictionary<string, JsonElement>();
|
||||||
foreach (var row in rows.EnumerateArray())
|
foreach (var row in rows.EnumerateArray())
|
||||||
{
|
{
|
||||||
var gd = row.GetProperty("GiftDrop");
|
var gd = row.GetProperty("GiftDrop");
|
||||||
var desc = gd.GetProperty("ConsumableItemDesc").GetString() ?? "";
|
var desc = gd.GetProperty("ConsumableItemDesc").GetString() ?? "";
|
||||||
if (desc.StartsWith("[") || desc.StartsWith("("))
|
if (desc.Length > 0) byDesc[desc] = gd;
|
||||||
{
|
|
||||||
consumables.Add(desc);
|
|
||||||
// Consumables are not avatar items: AvatarItemDesc empty and
|
|
||||||
// AvatarItemType null (a non-null enum here would tab it as
|
|
||||||
// clothing).
|
|
||||||
Assert.Equal("", gd.GetProperty("AvatarItemDesc").GetString());
|
|
||||||
Assert.Equal(JsonValueKind.Null, gd.GetProperty("AvatarItemType").ValueKind);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Assert.Contains("[FoodConsumable_RootBeer]", consumables);
|
foreach (var key in new[] { "[FoodConsumable_RootBeer]", "[KOConsumable_Cola]" })
|
||||||
Assert.Contains("[KOConsumable_Cola]", consumables);
|
{
|
||||||
|
Assert.True(byDesc.TryGetValue(key, out var gd), $"missing consumable {key}");
|
||||||
|
Assert.Equal("", gd.GetProperty("AvatarItemDesc").GetString());
|
||||||
|
Assert.Equal(JsonValueKind.Null, gd.GetProperty("AvatarItemType").ValueKind);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private HttpClient ApiClient()
|
private HttpClient ApiClient()
|
||||||
|
|||||||
@@ -32,7 +32,9 @@ namespace DorkNet.Server.Controllers.API.Avatar.V2;
|
|||||||
public class AvatarGiftsController(
|
public class AvatarGiftsController(
|
||||||
DorkNetDbContext db,
|
DorkNetDbContext db,
|
||||||
NotificationService notifications,
|
NotificationService notifications,
|
||||||
LevelService level) : ControllerBase
|
LevelService level,
|
||||||
|
QuestRewardService questRewards,
|
||||||
|
PlayerPresenceService presence) : ControllerBase
|
||||||
{
|
{
|
||||||
[HttpGet("api/avatar/v2/gifts")]
|
[HttpGet("api/avatar/v2/gifts")]
|
||||||
[HttpGet("api/avatar/v3/gifts")]
|
[HttpGet("api/avatar/v3/gifts")]
|
||||||
|
|||||||
@@ -9,12 +9,31 @@ using DorkNet.Server.Services;
|
|||||||
|
|
||||||
namespace DorkNet.Server.Controllers.API.GameRewards;
|
namespace DorkNet.Server.Controllers.API.GameRewards;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// api.rec.net/api/gamerewards/v1/* — the post-activity "choose 1 of 3"
|
||||||
|
/// item reward the client's <c>RewardManager</c> shows after a Stunt
|
||||||
|
/// Runner run (RewardType <c>PostGameActivity=2</c>) or an RRO quest
|
||||||
|
/// (<c>PostQuestActivity=3</c>). The client POSTs <c>/request</c> with a
|
||||||
|
/// rewardType + giftContext (no score/rank on the wire — the rank scales
|
||||||
|
/// the separate <b>currency</b> reward, which arrives via
|
||||||
|
/// <c>storefronts/v2/balance</c> as a rank-derived multiplier that
|
||||||
|
/// <see cref="Store.StorefrontsController.ModifyBalance"/> honours), then
|
||||||
|
/// polls <c>/pending</c> and picks one via <c>/select</c>.
|
||||||
|
///
|
||||||
|
/// Each of GiftDrop1/2/3 is a real store item built through
|
||||||
|
/// <see cref="StoreService.BuildGiftDrop"/> — the SAME wire-verified
|
||||||
|
/// EFFIEFEFHHB shape the storefront uses (notably AvatarItemId is an
|
||||||
|
/// Int32), so the strict 2023 reader doesn't reject the selection. On
|
||||||
|
/// <c>/select</c> the chosen item is granted to inventory for free.
|
||||||
|
/// </summary>
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Authorize]
|
[Authorize]
|
||||||
public class GameRewardsController(DorkNetDbContext db, LevelService level) : ControllerBase
|
public class GameRewardsController(DorkNetDbContext db, LevelService level, StoreService store) : ControllerBase
|
||||||
{
|
{
|
||||||
private long Me => this.RequireCurrentPlayerId();
|
private long Me => this.RequireCurrentPlayerId();
|
||||||
|
|
||||||
|
private const int OfferCount = 3;
|
||||||
|
|
||||||
[HttpGet("api/gamerewards/v1/pending")]
|
[HttpGet("api/gamerewards/v1/pending")]
|
||||||
public async Task<IActionResult> Pending()
|
public async Task<IActionResult> Pending()
|
||||||
{
|
{
|
||||||
@@ -24,7 +43,8 @@ public class GameRewardsController(DorkNetDbContext db, LevelService level) : Co
|
|||||||
.OrderBy(r => r.CreatedAt)
|
.OrderBy(r => r.CreatedAt)
|
||||||
.Take(20)
|
.Take(20)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
return Ok(rows.Select(ToWire));
|
var items = await LoadOfferedItemsAsync(rows);
|
||||||
|
return Ok(rows.Select(r => ToWire(r, items)));
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("api/gamerewards/v1/request")]
|
[HttpPost("api/gamerewards/v1/request")]
|
||||||
@@ -32,15 +52,27 @@ public class GameRewardsController(DorkNetDbContext db, LevelService level) : Co
|
|||||||
public async Task<IActionResult> RequestReward()
|
public async Task<IActionResult> RequestReward()
|
||||||
{
|
{
|
||||||
var req = await ReadRewardRequestAsync();
|
var req = await ReadRewardRequestAsync();
|
||||||
|
var pid = Me;
|
||||||
var row = new GameRewardSelectionEntity
|
var row = new GameRewardSelectionEntity
|
||||||
{
|
{
|
||||||
PlayerId = Me,
|
PlayerId = pid,
|
||||||
RewardType = req.RewardType,
|
RewardType = req.RewardType,
|
||||||
GiftContext = req.GiftContext,
|
GiftContext = req.GiftContext,
|
||||||
Message = string.IsNullOrWhiteSpace(req.Message) ? "Choose a reward" : req.Message[..Math.Min(req.Message.Length, 256)],
|
Message = string.IsNullOrWhiteSpace(req.Message)
|
||||||
|
? "Choose a reward"
|
||||||
|
: req.Message[..Math.Min(req.Message.Length, 256)],
|
||||||
};
|
};
|
||||||
db.GameRewardSelections.Add(row);
|
db.GameRewardSelections.Add(row);
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
// Pick the three offered items now that the row has an id (used as
|
||||||
|
// the deterministic seed) and persist them so /pending is stable
|
||||||
|
// and /select grants exactly what was shown.
|
||||||
|
var offered = await store.PickRewardItemsAsync(OfferCount, unchecked((int)row.Id));
|
||||||
|
if (offered.Count > 0) row.Offer1ItemId = offered[0].Id;
|
||||||
|
if (offered.Count > 1) row.Offer2ItemId = offered[1].Id;
|
||||||
|
if (offered.Count > 2) row.Offer3ItemId = offered[2].Id;
|
||||||
|
await db.SaveChangesAsync();
|
||||||
return Ok(new { Success = true, Error = string.Empty });
|
return Ok(new { Success = true, Error = string.Empty });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,46 +86,61 @@ public class GameRewardsController(DorkNetDbContext db, LevelService level) : Co
|
|||||||
.FirstOrDefaultAsync(r => r.Id == req.RewardSelectionId && r.PlayerId == pid && r.SelectedAt == null);
|
.FirstOrDefaultAsync(r => r.Id == req.RewardSelectionId && r.PlayerId == pid && r.SelectedAt == null);
|
||||||
if (row is null) return Ok(new { Success = false, Error = "reward_not_found" });
|
if (row is null) return Ok(new { Success = false, Error = "reward_not_found" });
|
||||||
|
|
||||||
|
// Map the client's chosen GiftDropId back to one of the offered
|
||||||
|
// store items. The GiftDropId is the item's PurchasableItemId.
|
||||||
|
var offeredIds = new[] { row.Offer1ItemId, row.Offer2ItemId, row.Offer3ItemId }
|
||||||
|
.Where(id => id > 0)
|
||||||
|
.ToList();
|
||||||
|
var items = await db.StoreItems.Where(i => offeredIds.Contains(i.Id)).ToListAsync();
|
||||||
|
var chosen = items.FirstOrDefault(i => StoreService.PurchasableItemIdFor(i) == req.GiftDropId)
|
||||||
|
?? items.FirstOrDefault();
|
||||||
|
|
||||||
row.SelectedAt = DateTime.UtcNow;
|
row.SelectedAt = DateTime.UtcNow;
|
||||||
row.SelectedGiftDropId = req.GiftDropId;
|
row.SelectedGiftDropId = req.GiftDropId;
|
||||||
|
if (chosen is not null)
|
||||||
|
{
|
||||||
|
await store.GrantItemAsync(pid, chosen.Id);
|
||||||
|
row.GrantedItemId = chosen.Id;
|
||||||
|
}
|
||||||
|
// Small token + XP kicker on top of the item, matching the
|
||||||
|
// post-activity "you also banked a little" feel.
|
||||||
await level.GrantCurrencyAsync(pid, 2, 25, $"gameReward:{row.Id}");
|
await level.GrantCurrencyAsync(pid, 2, 25, $"gameReward:{row.Id}");
|
||||||
await level.AwardXpAsync(pid, 25, $"gameReward:{row.Id}");
|
await level.AwardXpAsync(pid, 25, $"gameReward:{row.Id}");
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
return Ok(new { Success = true, Error = string.Empty });
|
return Ok(new { Success = true, Error = string.Empty });
|
||||||
}
|
}
|
||||||
|
|
||||||
private static object ToWire(GameRewardSelectionEntity row) => new
|
private async Task<Dictionary<long, StoreItemEntity>> LoadOfferedItemsAsync(
|
||||||
|
IEnumerable<GameRewardSelectionEntity> rows)
|
||||||
|
{
|
||||||
|
var ids = rows
|
||||||
|
.SelectMany(r => new[] { r.Offer1ItemId, r.Offer2ItemId, r.Offer3ItemId })
|
||||||
|
.Where(id => id > 0)
|
||||||
|
.Distinct()
|
||||||
|
.ToList();
|
||||||
|
if (ids.Count == 0) return new();
|
||||||
|
return await db.StoreItems
|
||||||
|
.Where(i => ids.Contains(i.Id))
|
||||||
|
.ToDictionaryAsync(i => i.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static object ToWire(GameRewardSelectionEntity row, IReadOnlyDictionary<long, StoreItemEntity> items) => new
|
||||||
{
|
{
|
||||||
RewardSelectionId = row.Id,
|
RewardSelectionId = row.Id,
|
||||||
row.Message,
|
row.Message,
|
||||||
row.GiftContext,
|
row.GiftContext,
|
||||||
row.RewardType,
|
row.RewardType,
|
||||||
GiftDrop1 = GiftDrop(row.Id * 10 + 1, "Tokens", currency: 25),
|
GiftDrop1 = OfferDrop(row.Offer1ItemId, items),
|
||||||
GiftDrop2 = GiftDrop(row.Id * 10 + 2, "XP", currency: 0),
|
GiftDrop2 = OfferDrop(row.Offer2ItemId, items),
|
||||||
GiftDrop3 = GiftDrop(row.Id * 10 + 3, "Bonus Tokens", currency: 25),
|
GiftDrop3 = OfferDrop(row.Offer3ItemId, items),
|
||||||
row.CreatedAt,
|
row.CreatedAt,
|
||||||
};
|
};
|
||||||
|
|
||||||
private static object GiftDrop(long id, string name, int currency) => new
|
private static object? OfferDrop(long itemId, IReadOnlyDictionary<long, StoreItemEntity> items)
|
||||||
{
|
{
|
||||||
GiftDropId = id,
|
if (itemId <= 0 || !items.TryGetValue(itemId, out var item)) return null;
|
||||||
FriendlyName = name,
|
return StoreService.BuildGiftDrop(item, StoreService.PurchasableItemIdFor(item));
|
||||||
Tooltip = string.Empty,
|
}
|
||||||
ConsumableItemDesc = string.Empty,
|
|
||||||
AvatarItemDesc = string.Empty,
|
|
||||||
AvatarItemType = 0,
|
|
||||||
EquipmentPrefabName = string.Empty,
|
|
||||||
EquipmentModificationGuid = string.Empty,
|
|
||||||
IsQuery = false,
|
|
||||||
Unique = false,
|
|
||||||
SubscribersOnly = false,
|
|
||||||
Rarity = 0,
|
|
||||||
CurrencyType = 2,
|
|
||||||
Currency = currency,
|
|
||||||
Context = 0,
|
|
||||||
ItemSetId = 0,
|
|
||||||
ItemSetFriendlyName = string.Empty,
|
|
||||||
};
|
|
||||||
|
|
||||||
private async Task<RewardRequest> ReadRewardRequestAsync()
|
private async Task<RewardRequest> ReadRewardRequestAsync()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -705,6 +705,9 @@ public class RoomsController(
|
|||||||
SavedByAccountId = room.CreatorPlayerId,
|
SavedByAccountId = room.CreatorPlayerId,
|
||||||
SavedOnPlatform = 7,
|
SavedOnPlatform = 7,
|
||||||
SavedOnDeviceClass = 2,
|
SavedOnDeviceClass = 2,
|
||||||
|
CreatedAt = (scene?.DataModifiedAt ?? room.UpdatedAt) is var created && created != default
|
||||||
|
? created.ToString("yyyy-MM-ddTHH:mm:ssZ")
|
||||||
|
: DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ssZ"),
|
||||||
CreatedByAccountId = room.CreatorPlayerId,
|
CreatedByAccountId = room.CreatorPlayerId,
|
||||||
UnityAssetHash = string.Empty,
|
UnityAssetHash = string.Empty,
|
||||||
UnityAsset = unityAsset,
|
UnityAsset = unityAsset,
|
||||||
@@ -1074,6 +1077,30 @@ public class RoomsController(
|
|||||||
};
|
};
|
||||||
if (wrapCreateModifyResponse)
|
if (wrapCreateModifyResponse)
|
||||||
{
|
{
|
||||||
|
// 2023 nested-JSON save commit: the response deserializer
|
||||||
|
// (NEOPBOMGIOG via KMBHAKAHNGH mapper) REQUIRES value =
|
||||||
|
// { Room: <room DTO>, SubRoomDataSave: <save DTO> } — both
|
||||||
|
// non-null, or its post-parse validator NREs and the watch
|
||||||
|
// reports "Failed to save room" even though the save
|
||||||
|
// persisted. Room reuses the GET rooms/{id} shape; the save
|
||||||
|
// reuses BuildStudioSaveWire (JKIFFPPAJNK keys per
|
||||||
|
// PELEHJLMKJO mapper: SubRoomDataSaveId/SubRoomId/
|
||||||
|
// UnityAssetId/DataBlob/DataBlobHash/SavedByAccountId/
|
||||||
|
// SavedOnPlatform/SavedOnDeviceClass/Description/CreatedAt).
|
||||||
|
if (body.SubRoomData is not null)
|
||||||
|
{
|
||||||
|
var subRoomId = (long)(sceneRow?.OrderIndex ?? (int)body.RoomSceneId);
|
||||||
|
var value = new
|
||||||
|
{
|
||||||
|
Room = BuildRoomServerDetails(pushRoom, sceneRowsForRoom, newBlob),
|
||||||
|
SubRoomDataSave = BuildStudioSaveWire(
|
||||||
|
room, sceneRow, subRoomId,
|
||||||
|
sceneRow?.StudioSubRoomDataSaveId ?? 0L,
|
||||||
|
unityAssetTarget: null, unityAssetVersion: null),
|
||||||
|
};
|
||||||
|
return Ok(new { success = true, value, error = string.Empty });
|
||||||
|
}
|
||||||
|
|
||||||
var roomServerDetails = BuildRoomServerDetails(pushRoom, sceneRowsForRoom, newBlob);
|
var roomServerDetails = BuildRoomServerDetails(pushRoom, sceneRowsForRoom, newBlob);
|
||||||
return Ok(new { success = true, value = roomServerDetails, error = string.Empty });
|
return Ok(new { success = true, value = roomServerDetails, error = string.Empty });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,4 +12,15 @@ public class GameRewardSelectionEntity
|
|||||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||||
public DateTime? SelectedAt { get; set; }
|
public DateTime? SelectedAt { get; set; }
|
||||||
public int? SelectedGiftDropId { get; set; }
|
public int? SelectedGiftDropId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>The three store-item ids offered as the choose-1-of-3
|
||||||
|
/// reward. Persisted so re-fetching the pending reward is stable and
|
||||||
|
/// <c>/select</c> can grant exactly what was shown. 0 = empty slot.</summary>
|
||||||
|
public long Offer1ItemId { get; set; }
|
||||||
|
public long Offer2ItemId { get; set; }
|
||||||
|
public long Offer3ItemId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Set once the chosen item has been granted to inventory, so
|
||||||
|
/// a replayed <c>/select</c> can't grant twice.</summary>
|
||||||
|
public long GrantedItemId { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"_comment": "Per-quest reward chest contents. Keyed by the RRO room id the player is in when they open the end-of-quest gift box (the gifts/generate request carries no quest id, so the server keys off the player's current room via presence). Each entry lists the store-item slugs that quest can award; the server picks one at random from the list. Slugs use the same scheme as the store: wardrobe-{guid} for outfits, consumable-{name} for consumables, equipment-{prefab}:{guid} for equipment. Populate `rooms` with real roomId -> item-slug lists.",
|
||||||
|
"rooms": {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using DorkNet.Server.Data;
|
||||||
|
using DorkNet.Server.Data.Entities;
|
||||||
|
|
||||||
|
namespace DorkNet.Server.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Per-quest reward chest contents. The end-of-quest gift box
|
||||||
|
/// (RRO quest / Stunt Runner) is the existing <c>gifts/generate</c> +
|
||||||
|
/// <c>gifts/consume</c> flow: the client asks the server to generate a
|
||||||
|
/// gift, the box spawns showing it, and opening it grants the item.
|
||||||
|
///
|
||||||
|
/// The generate request carries no quest id (only GiftContext /
|
||||||
|
/// IsGameGift / Message), so the quest is identified by the room the
|
||||||
|
/// player is in — <see cref="PlayerPresenceService.GetRoom"/>. This
|
||||||
|
/// service maps that room id to the quest's item pool
|
||||||
|
/// (<c>data/quest_rewards.json</c>) and resolves one to a real store
|
||||||
|
/// item to award. When a room has no configured pool the caller falls
|
||||||
|
/// back to the generic random-wardrobe gift.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class QuestRewardService(DorkNetDbContext db)
|
||||||
|
{
|
||||||
|
private sealed class QuestRewardFile
|
||||||
|
{
|
||||||
|
public Dictionary<string, List<string>> rooms { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static readonly Lazy<Dictionary<long, List<string>>> _map = new(Load);
|
||||||
|
|
||||||
|
/// <summary>Room id → candidate item slugs for that quest's chest.</summary>
|
||||||
|
public static IReadOnlyDictionary<long, List<string>> Map => _map.Value;
|
||||||
|
|
||||||
|
public static bool HasRewardsForRoom(long roomId) =>
|
||||||
|
Map.TryGetValue(roomId, out var slugs) && slugs.Count > 0;
|
||||||
|
|
||||||
|
/// <summary>Resolve one active store item for the quest chest in
|
||||||
|
/// <paramref name="roomId"/>, chosen deterministically from
|
||||||
|
/// <paramref name="seed"/> so repeated generate calls in the same
|
||||||
|
/// context are stable. Returns null when the room has no configured
|
||||||
|
/// pool or none of its slugs resolve to an active item.</summary>
|
||||||
|
public async Task<StoreItemEntity?> PickForRoomAsync(long roomId, int seed)
|
||||||
|
{
|
||||||
|
if (!Map.TryGetValue(roomId, out var slugs) || slugs.Count == 0) return null;
|
||||||
|
|
||||||
|
var items = await db.StoreItems
|
||||||
|
.Where(i => i.IsActive && slugs.Contains(i.Slug))
|
||||||
|
.ToListAsync();
|
||||||
|
if (items.Count == 0) return null;
|
||||||
|
|
||||||
|
// Order deterministically by (id, seed) hash so the same room +
|
||||||
|
// seed always yields the same pick without persisting state.
|
||||||
|
return items
|
||||||
|
.OrderBy(i => HashCode.Combine(i.Id, seed))
|
||||||
|
.First();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Dictionary<long, List<string>> Load()
|
||||||
|
{
|
||||||
|
var candidates = new[]
|
||||||
|
{
|
||||||
|
Path.Combine(AppContext.BaseDirectory, "data", "quest_rewards.json"),
|
||||||
|
Path.Combine(AppContext.BaseDirectory, "Data", "quest_rewards.json"),
|
||||||
|
Path.Combine(Directory.GetCurrentDirectory(), "data", "quest_rewards.json"),
|
||||||
|
Path.Combine(Directory.GetCurrentDirectory(), "DorkNet.Server", "Data", "quest_rewards.json"),
|
||||||
|
};
|
||||||
|
var path = candidates.FirstOrDefault(File.Exists);
|
||||||
|
if (path is null) return new();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var fs = File.OpenRead(path);
|
||||||
|
var data = JsonSerializer.Deserialize<QuestRewardFile>(fs,
|
||||||
|
new JsonSerializerOptions { PropertyNameCaseInsensitive = true }) ?? new QuestRewardFile();
|
||||||
|
|
||||||
|
var map = new Dictionary<long, List<string>>();
|
||||||
|
foreach (var (key, slugs) in data.rooms)
|
||||||
|
{
|
||||||
|
if (!long.TryParse(key, out var roomId)) continue;
|
||||||
|
var clean = (slugs ?? new())
|
||||||
|
.Where(s => !string.IsNullOrWhiteSpace(s))
|
||||||
|
.Select(s => s.Trim())
|
||||||
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToList();
|
||||||
|
if (clean.Count > 0) map[roomId] = clean;
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return new();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -882,6 +882,79 @@ public class StoreService(DorkNetDbContext db, LevelService level, IConfiguratio
|
|||||||
return new(true, null, newBalance, item.Slug);
|
return new(true, null, newBalance, item.Slug);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Grant a store item to a player WITHOUT charging currency —
|
||||||
|
/// the payout path for game rewards (RRO quest / Stunt Runner loot).
|
||||||
|
/// Mirrors <see cref="PurchaseAsync"/>'s inventory writes (wardrobe
|
||||||
|
/// GUID list for avatar items, stack list for everything else) but
|
||||||
|
/// skips the balance check + debit. Idempotent for unique avatar
|
||||||
|
/// items; stacks consumables.</summary>
|
||||||
|
public async Task<PurchaseResult> GrantItemAsync(long playerId, long itemId)
|
||||||
|
{
|
||||||
|
var item = await GetByIdAsync(itemId);
|
||||||
|
if (item is null || !item.IsActive)
|
||||||
|
return new(false, "item_not_available", null, null);
|
||||||
|
|
||||||
|
var avatar = await db.Avatars.FirstOrDefaultAsync(a => a.PlayerId == playerId);
|
||||||
|
if (avatar is null)
|
||||||
|
{
|
||||||
|
avatar = new AvatarEntity { PlayerId = playerId };
|
||||||
|
db.Avatars.Add(avatar);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (TryGetAvatarItemPayload(item.Slug, out _, out var avatarItemDesc))
|
||||||
|
{
|
||||||
|
var guid = InventoryAvatarItemDesc(avatarItemDesc);
|
||||||
|
var ownedGuids = ParseWardrobeInventory(avatar.InventoryJson);
|
||||||
|
if (!ownedGuids.Contains(guid, StringComparer.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
ownedGuids.Add(guid);
|
||||||
|
avatar.InventoryJson = System.Text.Json.JsonSerializer.Serialize(ownedGuids);
|
||||||
|
avatar.UpdatedAt = DateTime.UtcNow;
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
return new(true, null, null, item.Slug);
|
||||||
|
}
|
||||||
|
|
||||||
|
var inventory = ParseInventory(avatar.InventoryJson);
|
||||||
|
var existing = inventory.FirstOrDefault(e => e.ItemId == item.Slug);
|
||||||
|
var isConsumable = string.Equals(item.Category, "consumable", StringComparison.OrdinalIgnoreCase);
|
||||||
|
if (existing is not null)
|
||||||
|
{
|
||||||
|
if (!isConsumable) return new(true, "already_owned", null, item.Slug);
|
||||||
|
existing.Quantity += 1;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
inventory.Add(new InventoryEntry { ItemId = item.Slug, Quantity = 1 });
|
||||||
|
}
|
||||||
|
avatar.InventoryJson = System.Text.Json.JsonSerializer.Serialize(inventory);
|
||||||
|
avatar.UpdatedAt = DateTime.UtcNow;
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
return new(true, null, null, item.Slug);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Pick <paramref name="count"/> distinct renderable store
|
||||||
|
/// items to offer as a game-reward choice. Deterministic given the
|
||||||
|
/// seed (we key off the reward-selection id) so re-fetching the same
|
||||||
|
/// pending reward always offers the same items.</summary>
|
||||||
|
public async Task<List<StoreItemEntity>> PickRewardItemsAsync(int count, int seed)
|
||||||
|
{
|
||||||
|
var pool = await db.StoreItems
|
||||||
|
.Where(i => i.IsActive &&
|
||||||
|
(i.AvailableUntil == null || i.AvailableUntil > DateTime.UtcNow))
|
||||||
|
.ToListAsync();
|
||||||
|
var renderable = pool.Where(IsRenderableGiftDrop).ToList();
|
||||||
|
if (renderable.Count == 0) return new();
|
||||||
|
|
||||||
|
// Stable shuffle: order by a hash of (id, seed) so the same seed
|
||||||
|
// reproduces the same picks without persisting the offered set.
|
||||||
|
var picked = renderable
|
||||||
|
.OrderBy(i => HashCode.Combine(i.Id, seed))
|
||||||
|
.Take(Math.Min(count, renderable.Count))
|
||||||
|
.ToList();
|
||||||
|
return picked;
|
||||||
|
}
|
||||||
|
|
||||||
private static List<string> ParseWardrobeInventory(string? json)
|
private static List<string> ParseWardrobeInventory(string? json)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(json) || json == "[]") return new();
|
if (string.IsNullOrWhiteSpace(json) || json == "[]") return new();
|
||||||
@@ -1012,7 +1085,8 @@ public class StoreService(DorkNetDbContext db, LevelService level, IConfiguratio
|
|||||||
{
|
{
|
||||||
// PurchasableItemId is an int and must be unique within the
|
// PurchasableItemId is an int and must be unique within the
|
||||||
// storefront — we use the StoreItem row id, capped to int range.
|
// storefront — we use the StoreItem row id, capped to int range.
|
||||||
var itemId = (int)(i.Id & 0x7fffffff);
|
var itemId = PurchasableItemIdFor(i);
|
||||||
|
var giftDrop = BuildGiftDrop(i, itemId);
|
||||||
|
|
||||||
var price = new
|
var price = new
|
||||||
{
|
{
|
||||||
@@ -1020,6 +1094,32 @@ public class StoreService(DorkNetDbContext db, LevelService level, IConfiguratio
|
|||||||
Price = (int)i.Price,
|
Price = (int)i.Price,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
return new
|
||||||
|
{
|
||||||
|
// Base PurchasableItem keys.
|
||||||
|
PurchasableItemId = itemId,
|
||||||
|
Type = 0, // PurchasableItemType.GiftDrop
|
||||||
|
IsFeatured = positionalIndex < 12, // first dozen show in Featured tab
|
||||||
|
Prices = new[] { price },
|
||||||
|
SubscriberPrices = Array.Empty<object>(),
|
||||||
|
// 2020 PurchasableGiftDrop carries a GiftDrops LIST; the 2023
|
||||||
|
// row (POGBAGAHGIA formatter) reads a single "GiftDrop" object
|
||||||
|
// instead. Ship both: whichever formatter parses the row skips
|
||||||
|
// the key it doesn't know.
|
||||||
|
GiftDrop = giftDrop,
|
||||||
|
GiftDrops = new[] { giftDrop },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Build one StorefrontGiftDrop (EFFIEFEFHHB) object for a
|
||||||
|
/// store row. Shared by the storefront rows and the game-reward
|
||||||
|
/// selection so both go through the same, wire-verified 2023 shape —
|
||||||
|
/// notably <c>AvatarItemId</c> is an <b>Int32</b> (the descriptor
|
||||||
|
/// string lives in <c>AvatarItemDesc</c>); a string there makes the
|
||||||
|
/// strict reader throw "Malformed Response" for the whole
|
||||||
|
/// payload.</summary>
|
||||||
|
public static object BuildGiftDrop(StoreItemEntity i, int giftDropId)
|
||||||
|
{
|
||||||
// Pull the AvatarItem GUID out of the slug for wardrobe-imported
|
// Pull the AvatarItem GUID out of the slug for wardrobe-imported
|
||||||
// entries. Hand-curated <c>dorknet-*</c> SKUs leave AvatarItemDesc
|
// entries. Hand-curated <c>dorknet-*</c> SKUs leave AvatarItemDesc
|
||||||
// empty — they're cosmetic store tiles only.
|
// empty — they're cosmetic store tiles only.
|
||||||
@@ -1048,10 +1148,10 @@ public class StoreService(DorkNetDbContext db, LevelService level, IConfiguratio
|
|||||||
consumableItemDesc = consumableHairDyeDesc;
|
consumableItemDesc = consumableHairDyeDesc;
|
||||||
}
|
}
|
||||||
|
|
||||||
var giftDrop = new
|
return new
|
||||||
{
|
{
|
||||||
// REQUIRED.
|
// REQUIRED.
|
||||||
GiftDropId = itemId,
|
GiftDropId = giftDropId,
|
||||||
Rarity = MapRarity(i.Category),
|
Rarity = MapRarity(i.Category),
|
||||||
// 2023 additions (EMBCEDNHFLB formatter): TagList is a CSV
|
// 2023 additions (EMBCEDNHFLB formatter): TagList is a CSV
|
||||||
// string, Currency is the CurrencyType enum repeated, ItemSet*
|
// string, Currency is the CurrencyType enum repeated, ItemSet*
|
||||||
@@ -1071,7 +1171,7 @@ public class StoreService(DorkNetDbContext db, LevelService level, IConfiguratio
|
|||||||
// +0x38). Shipping the {guid},,, descriptor here made the
|
// +0x38). Shipping the {guid},,, descriptor here made the
|
||||||
// strict Utf8Json reader throw "Malformed Response" on the
|
// strict Utf8Json reader throw "Malformed Response" on the
|
||||||
// whole giftdropstore payload, so the Shop tab loaded nothing.
|
// whole giftdropstore payload, so the Shop tab loaded nothing.
|
||||||
AvatarItemId = itemId,
|
AvatarItemId = giftDropId,
|
||||||
AvatarItemType = string.IsNullOrEmpty(avatarItemDesc) ? (int?)null : avatarItemType,
|
AvatarItemType = string.IsNullOrEmpty(avatarItemDesc) ? (int?)null : avatarItemType,
|
||||||
ConsumableItemDesc = consumableItemDesc,
|
ConsumableItemDesc = consumableItemDesc,
|
||||||
EquipmentPrefabName = equipmentPrefabName,
|
EquipmentPrefabName = equipmentPrefabName,
|
||||||
@@ -1084,24 +1184,6 @@ public class StoreService(DorkNetDbContext db, LevelService level, IConfiguratio
|
|||||||
Content = isHairDye ? GiftBoxContentHairDye : 0,
|
Content = isHairDye ? GiftBoxContentHairDye : 0,
|
||||||
EquipmentRarity = equipmentEntry?.rarity ?? 0,
|
EquipmentRarity = equipmentEntry?.rarity ?? 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
return new
|
|
||||||
{
|
|
||||||
// Base PurchasableItem keys.
|
|
||||||
PurchasableItemId = itemId,
|
|
||||||
Type = 0, // PurchasableItemType.GiftDrop
|
|
||||||
IsFeatured = positionalIndex < 12, // first dozen show in Featured tab
|
|
||||||
Prices = new[] { price },
|
|
||||||
SubscriberPrices = Array.Empty<object>(),
|
|
||||||
// 2020 PurchasableGiftDrop carries a GiftDrops LIST; the 2023
|
|
||||||
// row (POGBAGAHGIA formatter) reads a single "GiftDrop" object
|
|
||||||
// instead. Ship both: whichever formatter parses the row skips
|
|
||||||
// the key it doesn't know. Missing "GiftDrop" leaves the 2023
|
|
||||||
// row's inner desc null and StoreItemListModel's filter
|
|
||||||
// predicate NREs on every item — the Shop tab shows nothing.
|
|
||||||
GiftDrop = giftDrop,
|
|
||||||
GiftDrops = new[] { giftDrop },
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>The wire PurchasableItemId for a store row — must match
|
/// <summary>The wire PurchasableItemId for a store row — must match
|
||||||
|
|||||||
@@ -109,6 +109,7 @@ public static class DatabaseBootstrap
|
|||||||
await EnsureRoomColumnsAsync(db);
|
await EnsureRoomColumnsAsync(db);
|
||||||
await EnsureRoomSceneColumnsAsync(db);
|
await EnsureRoomSceneColumnsAsync(db);
|
||||||
await EnsureInventionColumnsAsync(db);
|
await EnsureInventionColumnsAsync(db);
|
||||||
|
await EnsureGameRewardColumnsAsync(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
|
||||||
@@ -397,6 +398,30 @@ public static class DatabaseBootstrap
|
|||||||
catch { }
|
catch { }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Post-Initial columns on GameRewardSelections for the real
|
||||||
|
/// item-reward flow (offered store-item ids + granted marker).</summary>
|
||||||
|
private static async Task EnsureGameRewardColumnsAsync(DorkNetDbContext db)
|
||||||
|
{
|
||||||
|
var provider = db.Database.ProviderName ?? string.Empty;
|
||||||
|
if (provider.Contains("Npgsql", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
foreach (var col in new[] { "Offer1ItemId", "Offer2ItemId", "Offer3ItemId", "GrantedItemId" })
|
||||||
|
await db.Database.ExecuteSqlRawAsync(
|
||||||
|
$@"ALTER TABLE ""GameRewardSelections"" ADD COLUMN IF NOT EXISTS ""{col}"" bigint NOT NULL DEFAULT 0;");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var col in new[] { "Offer1ItemId", "Offer2ItemId", "Offer3ItemId", "GrantedItemId" })
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await db.Database.ExecuteSqlRawAsync(
|
||||||
|
$@"ALTER TABLE ""GameRewardSelections"" ADD COLUMN ""{col}"" INTEGER NOT NULL DEFAULT 0;");
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static async Task EnsureRoomSceneColumnsAsync(DorkNetDbContext db)
|
private static async Task EnsureRoomSceneColumnsAsync(DorkNetDbContext db)
|
||||||
{
|
{
|
||||||
var provider = db.Database.ProviderName ?? string.Empty;
|
var provider = db.Database.ProviderName ?? string.Empty;
|
||||||
|
|||||||
@@ -201,6 +201,7 @@ public static class ServiceCollectionExtensions
|
|||||||
services.AddScoped<ClubService>();
|
services.AddScoped<ClubService>();
|
||||||
services.AddScoped<LevelService>();
|
services.AddScoped<LevelService>();
|
||||||
services.AddScoped<StoreService>();
|
services.AddScoped<StoreService>();
|
||||||
|
services.AddScoped<QuestRewardService>();
|
||||||
services.AddScoped<SignupCodeService>();
|
services.AddScoped<SignupCodeService>();
|
||||||
// Singleton — the protobuf blob is identical for every room and built
|
// Singleton — the protobuf blob is identical for every room and built
|
||||||
// once at startup; no need to rebuild per request.
|
// once at startup; no need to rebuild per request.
|
||||||
|
|||||||
@@ -180,6 +180,30 @@ since 2020:
|
|||||||
|
|
||||||
Coverage: `DorkNet.Server.Tests/WatchShop2023Tests.cs`.
|
Coverage: `DorkNet.Server.Tests/WatchShop2023Tests.cs`.
|
||||||
|
|
||||||
|
## Shop Consumables tab (food / potions / KO / camera film)
|
||||||
|
|
||||||
|
The Shop's Consumables tab is fed by the same `giftdropstore` rows as
|
||||||
|
Clothing; a row is a consumable when its `GiftDrop.ConsumableItemDesc`
|
||||||
|
is set (bracketed literal item key, e.g. `[FoodConsumable_RootBeer]`)
|
||||||
|
and it carries no avatar item (`AvatarItemDesc` empty, `AvatarItemType`
|
||||||
|
null). Hair dyes are a hybrid — they set BOTH `ConsumableItemDesc` and
|
||||||
|
an `AvatarItemDesc` colour guid.
|
||||||
|
|
||||||
|
The catalog of literal item keys lives in the client's baked
|
||||||
|
`ConsumableCollectionRuntimeConfig` MonoBehaviour (in `resources.assets`,
|
||||||
|
path_id 234695 for 2023.03.21). Each record is
|
||||||
|
`[ desc "[Family_Name]" ][ guid 22ch ][ display name ][ int family ]
|
||||||
|
[ tool desc ]`; families: CameraFilter, Food, KO, HandPotion,
|
||||||
|
HeadPotion, VFX, HairDye, Coupon. Extracted (minus hair dyes, coupons,
|
||||||
|
and promo-only bubbly variants) to `DorkNet.Server/Data/consumables.json`
|
||||||
|
and seeded as `Category="consumable"` store rows;
|
||||||
|
`StoreService.TryGetConsumableItemDesc` resolves a seeded slug to its
|
||||||
|
literal desc so it flows through the gift-drop builder like a hair dye.
|
||||||
|
|
||||||
|
The exact per-item render gate (`INJHHPOOMKB.EEJKFKPAPAH`) dispatches
|
||||||
|
through a runtime service and could not be resolved statically — confirm
|
||||||
|
the tab actually populates in-game after seeding.
|
||||||
|
|
||||||
## Related: unprefixed 2023 commerce probes
|
## Related: unprefixed 2023 commerce probes
|
||||||
|
|
||||||
The 2023 client also calls these commerce paths **without** the `api/`
|
The 2023 client also calls these commerce paths **without** the `api/`
|
||||||
|
|||||||
@@ -34,6 +34,31 @@ on 2026-07-07. Diagnosed while fixing "Failed to save room" /
|
|||||||
`CurrentDataBlobName` (and the dorm-state row for dorms).
|
`CurrentDataBlobName` (and the dorm-state row for dorms).
|
||||||
`RoomData.Filename` is the FileType=6 metadata blob.
|
`RoomData.Filename` is the FileType=6 metadata blob.
|
||||||
|
|
||||||
|
**Response contract** (deserializer `NEOPBOMGIOG`, mapper
|
||||||
|
`KMBHAKAHNGH`): legacy `{success, value, error}` envelope where
|
||||||
|
`value` MUST contain BOTH keys, non-null:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"value": {
|
||||||
|
"Room": { …same DTO as GET rooms/{id} (BuildRoomServerDetails)… },
|
||||||
|
"SubRoomDataSave": { "SubRoomDataSaveId": 0, "SubRoomId": 0,
|
||||||
|
"UnityAssetId": "", "DataBlob": "<blob>.dat",
|
||||||
|
"SavedByAccountId": 1, "SavedOnPlatform": 7,
|
||||||
|
"SavedOnDeviceClass": 2, "CreatedAt": "…", … }
|
||||||
|
},
|
||||||
|
"error": ""
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The client's post-parse validator NREs if `Room` or
|
||||||
|
`SubRoomDataSave` is missing — the save persists server-side but the
|
||||||
|
watch shows "Failed to save room". `SubRoomDataSave` keys per the
|
||||||
|
`JKIFFPPAJNK` mapper (`PELEHJLMKJO`): SubRoomDataSaveId, SubRoomId,
|
||||||
|
UnityAssetId, DataBlob, DataBlobHash, SavedByAccountId,
|
||||||
|
SavedOnPlatform, SavedOnDeviceClass, Description, CreatedAt.
|
||||||
|
|
||||||
Server handling: `StorageController.Upload` (FileType 6 →
|
Server handling: `StorageController.Upload` (FileType 6 →
|
||||||
`UploadGenericAsync("roommeta")`, CDN-servable) and
|
`UploadGenericAsync("roommeta")`, CDN-servable) and
|
||||||
`RoomsController.SubRoomData` → `ReadSaveRoomSceneRequestAsync` (nested
|
`RoomsController.SubRoomData` → `ReadSaveRoomSceneRequestAsync` (nested
|
||||||
@@ -45,6 +70,7 @@ Server handling: `StorageController.Upload` (FileType 6 →
|
|||||||
|---|---|
|
|---|---|
|
||||||
| `NDIKGKCFOCG: An error occurred` at `UploadRoomDataBlobAndSyncReload`, upload frame (`KPLOPGMJOLE`) in stack | `storage.<apex>` host not routed at the edge (Traefik `404 page not found`) — the save dies before reaching DorkNet. Probe `https://storage.<apex>/healthz`. |
|
| `NDIKGKCFOCG: An error occurred` at `UploadRoomDataBlobAndSyncReload`, upload frame (`KPLOPGMJOLE`) in stack | `storage.<apex>` host not routed at the edge (Traefik `404 page not found`) — the save dies before reaching DorkNet. Probe `https://storage.<apex>/healthz`. |
|
||||||
| `NDIKGKCFOCG: Failed to save room`, no upload frame in stack | Commit POST rejected — historically 400 `missing_room_data_filename` because the server parsed only the flat 2020 body and missed `SubRoomData.Filename`. |
|
| `NDIKGKCFOCG: Failed to save room`, no upload frame in stack | Commit POST rejected — historically 400 `missing_room_data_filename` because the server parsed only the flat 2020 body and missed `SubRoomData.Filename`. |
|
||||||
|
| `NDIKGKCFOCG: Failed to save room` preceded by a `NullReferenceException` at `NEOPBOMGIOG.FKDDCNLJOLF` | Commit returned 200 but `value` wasn't `{Room, SubRoomDataSave}` — the save actually persisted; only the response parse failed. |
|
||||||
|
|
||||||
## Related 2023 quirks fixed alongside
|
## Related 2023 quirks fixed alongside
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user