feat: Implement quest reward system with item selection and storage; update related services and database schema

This commit is contained in:
Alexa
2026-07-08 01:51:55 +03:00
parent 0defa37928
commit 892c27ffea
13 changed files with 416 additions and 65 deletions
@@ -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();
}
}
}
+104 -22
View File
@@ -882,6 +882,79 @@ public class StoreService(DorkNetDbContext db, LevelService level, IConfiguratio
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)
{
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
// 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
{
@@ -1020,6 +1094,32 @@ public class StoreService(DorkNetDbContext db, LevelService level, IConfiguratio
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
// entries. Hand-curated <c>dorknet-*</c> SKUs leave AvatarItemDesc
// empty — they're cosmetic store tiles only.
@@ -1048,10 +1148,10 @@ public class StoreService(DorkNetDbContext db, LevelService level, IConfiguratio
consumableItemDesc = consumableHairDyeDesc;
}
var giftDrop = new
return new
{
// REQUIRED.
GiftDropId = itemId,
GiftDropId = giftDropId,
Rarity = MapRarity(i.Category),
// 2023 additions (EMBCEDNHFLB formatter): TagList is a CSV
// 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
// strict Utf8Json reader throw "Malformed Response" on the
// whole giftdropstore payload, so the Shop tab loaded nothing.
AvatarItemId = itemId,
AvatarItemId = giftDropId,
AvatarItemType = string.IsNullOrEmpty(avatarItemDesc) ? (int?)null : avatarItemType,
ConsumableItemDesc = consumableItemDesc,
EquipmentPrefabName = equipmentPrefabName,
@@ -1084,24 +1184,6 @@ public class StoreService(DorkNetDbContext db, LevelService level, IConfiguratio
Content = isHairDye ? GiftBoxContentHairDye : 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