diff --git a/DorkNet.Server.Tests/QuestRewardChestTests.cs b/DorkNet.Server.Tests/QuestRewardChestTests.cs
new file mode 100644
index 0000000..63bfb2d
--- /dev/null
+++ b/DorkNet.Server.Tests/QuestRewardChestTests.cs
@@ -0,0 +1,127 @@
+using System.Net.Http.Headers;
+using System.Text;
+using System.Text.Json;
+using DorkNet.Server.Data;
+using DorkNet.Server.Data.Entities;
+using DorkNet.Server.Services;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace DorkNet.Server.Tests;
+
+///
+/// The end-of-quest reward chest is the gifts/generate + gifts/consume
+/// flow. This covers the load-bearing new behaviour: a gift stamped with
+/// a SourceStoreItemId (how quest chests are built) grants exactly
+/// that store item to the player's inventory when the box is opened,
+/// regardless of item kind. (The room-keyed pick itself is data-driven
+/// via data/quest_rewards.json and exercised once real per-quest lists
+/// are configured.)
+///
+public sealed class QuestRewardChestTests : IClassFixture
+{
+ private readonly DorkNetServerFactory _factory;
+
+ public QuestRewardChestTests(DorkNetServerFactory factory) => _factory = factory;
+
+ [Fact]
+ public async Task Consuming_quest_chest_grants_backing_item()
+ {
+ using var setup = ApiClient();
+ var player = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
+
+ long giftId;
+ string wardrobeGuid;
+ await using (var scope = _factory.Services.CreateAsyncScope())
+ {
+ var db = scope.ServiceProvider.GetRequiredService();
+ var item = await db.StoreItems.FirstAsync(i => i.IsActive && i.Slug.StartsWith("wardrobe-"));
+ wardrobeGuid = item.Slug["wardrobe-".Length..];
+
+ var gift = new GiftPackageEntity
+ {
+ RecipientPlayerId = player.PlayerId,
+ AvatarItemType = 0,
+ AvatarItemDescOrHairDyeDesc = wardrobeGuid + ",,,",
+ Message = "You earned it!",
+ SourceStoreItemId = item.Id,
+ IsValid = true,
+ };
+ db.GiftPackages.Add(gift);
+ await db.SaveChangesAsync();
+ giftId = gift.Id;
+ }
+
+ using var client = ApiClient(player);
+ using var consume = await client.PostAsync($"/api/avatar/v2/gifts/consume/{giftId}",
+ new StringContent("{}", Encoding.UTF8, "application/json"));
+ consume.EnsureSuccessStatusCode();
+
+ await using (var scope = _factory.Services.CreateAsyncScope())
+ {
+ var db = scope.ServiceProvider.GetRequiredService();
+ var avatar = await db.Avatars.FirstOrDefaultAsync(a => a.PlayerId == player.PlayerId);
+ Assert.NotNull(avatar);
+ var inv = JsonSerializer.Deserialize>(avatar!.InventoryJson) ?? new();
+ Assert.Contains(wardrobeGuid, inv);
+
+ var gift = await db.GiftPackages.FirstAsync(g => g.Id == giftId);
+ Assert.True(gift.Consumed);
+ }
+ }
+
+ [Fact]
+ public async Task Consuming_is_idempotent_and_does_not_double_grant()
+ {
+ using var setup = ApiClient();
+ var player = await GameClientSessionFactory.CreateAsync(setup, _factory.ApexDomain);
+
+ long giftId;
+ await using (var scope = _factory.Services.CreateAsyncScope())
+ {
+ var db = scope.ServiceProvider.GetRequiredService();
+ var item = await db.StoreItems.FirstAsync(i => i.IsActive && i.Slug.StartsWith("wardrobe-"));
+ var gift = new GiftPackageEntity
+ {
+ RecipientPlayerId = player.PlayerId,
+ AvatarItemType = 0,
+ AvatarItemDescOrHairDyeDesc = item.Slug["wardrobe-".Length..] + ",,,",
+ SourceStoreItemId = item.Id,
+ IsValid = true,
+ };
+ db.GiftPackages.Add(gift);
+ await db.SaveChangesAsync();
+ giftId = gift.Id;
+ }
+
+ using var client = ApiClient(player);
+ for (var i = 0; i < 2; i++)
+ {
+ using var r = await client.PostAsync($"/api/avatar/v2/gifts/consume/{giftId}",
+ new StringContent("{}", Encoding.UTF8, "application/json"));
+ r.EnsureSuccessStatusCode();
+ }
+
+ await using (var scope = _factory.Services.CreateAsyncScope())
+ {
+ var db = scope.ServiceProvider.GetRequiredService();
+ var avatar = await db.Avatars.FirstAsync(a => a.PlayerId == player.PlayerId);
+ var inv = JsonSerializer.Deserialize>(avatar.InventoryJson) ?? new();
+ // The wardrobe guid appears exactly once despite two consumes.
+ Assert.Equal(inv.Distinct().Count(), inv.Count);
+ }
+ }
+
+ private HttpClient ApiClient(GameClientSession? session = null)
+ {
+ var client = _factory.CreateClient(new() { AllowAutoRedirect = false });
+ client.BaseAddress = new Uri($"http://api.{_factory.ApexDomain}");
+ client.Timeout = TimeSpan.FromSeconds(30);
+ client.DefaultRequestHeaders.Accept.ParseAdd("application/json");
+ client.DefaultRequestHeaders.UserAgent.ParseAdd("RecRoom/2023.03.21");
+ client.DefaultRequestHeaders.TryAddWithoutValidation("X-DorkNet-Version", "march_2023_03_21");
+ if (session is not null)
+ client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", session.AccessToken);
+ return client;
+ }
+}
diff --git a/DorkNet.Server/Controllers/API/Avatar/V2/AvatarGiftsController.cs b/DorkNet.Server/Controllers/API/Avatar/V2/AvatarGiftsController.cs
index fea9e3f..b5d83b4 100644
--- a/DorkNet.Server/Controllers/API/Avatar/V2/AvatarGiftsController.cs
+++ b/DorkNet.Server/Controllers/API/Avatar/V2/AvatarGiftsController.cs
@@ -33,6 +33,7 @@ public class AvatarGiftsController(
DorkNetDbContext db,
NotificationService notifications,
LevelService level,
+ StoreService store,
QuestRewardService questRewards,
PlayerPresenceService presence) : ControllerBase
{
@@ -49,26 +50,42 @@ public class AvatarGiftsController(
return Ok(rows.Select(ToWire));
}
- public sealed record GenerateGiftRequest(int? GiftContext);
+ public sealed record GenerateGiftRequest(int? GiftContext, bool? IsGameGift, int? AlternateGiftContext, string? Message);
- /// POST api/avatar/v2/gifts/generate — server picks
- /// a random unlocked-avatar item and queues a gift package. The
- /// 2020 client posts to this when the user clicks a free-gift
- /// tile or hits a context the client maps to GiftContext.
+ /// POST api/avatar/v2/gifts/generate — the end-of-
+ /// activity gift box (chest). For a game/quest gift (IsGameGift) the
+ /// server first tries the quest's configured item pool, keyed by the
+ /// RRO room the player is in (the request carries no quest id). If
+ /// that room has no pool — or for non-game free-gift tiles — it falls
+ /// back to a random wardrobe item. The rolled item is stamped as a
+ /// gift package; opening the box (gifts/consume) grants it.
[HttpPost("api/avatar/v2/gifts/generate")]
+ [Consumes("application/json", "application/x-www-form-urlencoded", "multipart/form-data")]
public async Task Generate([FromBody] GenerateGiftRequest? req)
{
var pid = this.RequireCurrentPlayerId();
- // Pick a random wardrobe StoreItem (Slug starts with
- // "wardrobe-" — that's the convention StoreService uses for
- // catalogue rows derived from outfits_assets bundles).
+ var isGameGift = req?.IsGameGift ?? false;
+ var rng = new Random();
+
+ // Quest chest: pick from this room's configured reward pool.
+ if (isGameGift && presence.GetRoom(pid) is { RoomId: > 0 } room
+ && await questRewards.PickForRoomAsync(room.RoomId, rng.Next()) is { } questItem)
+ {
+ var gift = BuildGiftFromItem(pid, questItem, req?.GiftContext ?? 0, rng);
+ gift.SourceStoreItemId = questItem.Id;
+ db.GiftPackages.Add(gift);
+ await db.SaveChangesAsync();
+ await notifications.NotifyAsync(pid, PushNotificationId.GiftPackageReceived, ToWire(gift));
+ return Ok(ToWire(gift));
+ }
+
+ // Fallback: random wardrobe StoreItem (Slug "wardrobe-{guid}").
var pool = await db.StoreItems
.Where(s => s.IsActive && s.Slug.StartsWith("wardrobe-"))
.Select(s => new { s.Slug, s.DisplayName })
.ToListAsync();
if (pool.Count == 0) return StatusCode(503, "no wardrobe items in catalogue");
- var rng = new Random();
var pick = pool[rng.Next(pool.Count)];
var guid = pick.Slug["wardrobe-".Length..];
@@ -76,7 +93,7 @@ public class AvatarGiftsController(
// so the watch's parse-and-iterate doesn't blow up in
// GiftManager.DequeueGift. See AvatarItemsController.cs:31-40
// for the canonical wire-format note.
- var gift = new GiftPackageEntity
+ var randomGift = new GiftPackageEntity
{
RecipientPlayerId = pid,
FromPlayerId = null,
@@ -92,15 +109,57 @@ public class AvatarGiftsController(
IsValid = true,
SupportsCurrentPlatform = true,
};
- db.GiftPackages.Add(gift);
+ db.GiftPackages.Add(randomGift);
await db.SaveChangesAsync();
// Push so the watch shows the new-gift toast.
await notifications.NotifyAsync(pid,
- PushNotificationId.GiftPackageReceived, ToWire(gift));
- return Ok(ToWire(gift));
+ PushNotificationId.GiftPackageReceived, ToWire(randomGift));
+ return Ok(ToWire(randomGift));
}
+ /// Build a gift package for a specific store item, filling
+ /// the wire payload that matches its kind (avatar item vs equipment
+ /// vs consumable) so gifts/consume grants the right thing.
+ private static GiftPackageEntity BuildGiftFromItem(long pid, GiftPackageItem item, int giftContext, Random rng)
+ {
+ var gift = new GiftPackageEntity
+ {
+ RecipientPlayerId = pid,
+ FromPlayerId = null,
+ CurrencyType = 0,
+ GiftContext = giftContext,
+ GiftRarity = RollRarity(rng),
+ Message = $"You earned {item.DisplayName}!",
+ Platform = -1,
+ PackageVariant = "Standard",
+ PackageMaterial = string.Empty,
+ IsValid = true,
+ SupportsCurrentPlatform = true,
+ };
+
+ if (StoreService.TryGetAvatarItemPayload(item.Slug, out var avatarItemType, out var avatarItemDesc))
+ {
+ gift.AvatarItemType = avatarItemType;
+ gift.AvatarItemDescOrHairDyeDesc = avatarItemDesc;
+ }
+ else if (StoreService.TryGetEquipmentPayload(item.Slug, out var prefab, out var modGuid, out _))
+ {
+ gift.EquipmentPrefabName = prefab;
+ gift.EquipmentModificationGuid = modGuid;
+ }
+ else if (StoreService.TryGetConsumableItemDesc(item.Slug, out var consumableDesc))
+ {
+ gift.ConsumableItemDesc = consumableDesc;
+ }
+ return gift;
+ }
+
+ private static GiftPackageEntity BuildGiftFromItem(long pid, StoreItemEntity item, int giftContext, Random rng) =>
+ BuildGiftFromItem(pid, new GiftPackageItem(item.Slug, item.DisplayName), giftContext, rng);
+
+ private readonly record struct GiftPackageItem(string Slug, string DisplayName);
+
[HttpPost("api/avatar/v2/gifts/consume/{id:long}")]
public Task ConsumeViaPath(long id) => ConsumeImpl(id);
@@ -170,6 +229,19 @@ public class AvatarGiftsController(
if (gift.RecipientPlayerId != pid) return Forbid();
if (gift.Consumed) return Ok(ToWire(gift)); // idempotent
+ // Quest-reward chest: grant the exact backing store item (handles
+ // avatar / equipment / consumable uniformly, idempotently). The
+ // per-kind wire-derivation below is skipped for these — the wire
+ // fields are still populated for the client to render the box.
+ if (gift.SourceStoreItemId > 0)
+ {
+ await store.GrantItemAsync(pid, gift.SourceStoreItemId);
+ gift.Consumed = true;
+ gift.ConsumedAt = DateTime.UtcNow;
+ await db.SaveChangesAsync();
+ return Ok(ToWire(gift));
+ }
+
// Append avatar item to player's inventory list (JSON list of
// GUID strings). Defensive parse — corrupted JSON resets to
// empty list rather than 500.
diff --git a/DorkNet.Server/Data/Entities/GiftPackageEntity.cs b/DorkNet.Server/Data/Entities/GiftPackageEntity.cs
index 91b06db..ac72e07 100644
--- a/DorkNet.Server/Data/Entities/GiftPackageEntity.cs
+++ b/DorkNet.Server/Data/Entities/GiftPackageEntity.cs
@@ -93,4 +93,11 @@ public class GiftPackageEntity
public DateTime? ConsumedAt { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
+
+ /// When > 0, the exact StoreItem this gift grants on
+ /// consume — used by quest-reward chests so consuming grants the
+ /// right item regardless of kind (avatar / equipment / consumable)
+ /// via StoreService.GrantItemAsync. 0 = legacy gift whose
+ /// payload is derived from the wire fields above. Not on wire.
+ public long SourceStoreItemId { get; set; }
}
diff --git a/DorkNet.Server/Data/quest_rewards.json b/DorkNet.Server/Data/quest_rewards.json
index 1909d35..9d62cef 100644
--- a/DorkNet.Server/Data/quest_rewards.json
+++ b/DorkNet.Server/Data/quest_rewards.json
@@ -1,4 +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.",
+ "_comment": "Per-quest reward chest contents for RRO rooms. Keyed by the seeded room NAME (RoomEntity.Name, e.g. GoldenTrophy / Crescendo / StuntRunner / CrimsonCauldron / IsleOfLostSkulls / TheRiseofJumbotron) because the end-of-quest gift box (gifts/generate) carries no quest id, so the server keys off the room the player is in (via presence). Each entry lists the store-item slugs that quest's chest can award; the server picks one. Slugs use the store scheme: wardrobe-{guid} for outfit items, equipment-{prefab}:{modguid} for equipment skins, consumable-{name} for consumables (see data/consumables.json). Example: \"GoldenTrophy\": [\"wardrobe-\", \"equipment-:\"]. Fill in `rooms` with the per-quest item lists.",
"rooms": {}
}
diff --git a/DorkNet.Server/Services/QuestRewardService.cs b/DorkNet.Server/Services/QuestRewardService.cs
index 709ac1a..edc0980 100644
--- a/DorkNet.Server/Services/QuestRewardService.cs
+++ b/DorkNet.Server/Services/QuestRewardService.cs
@@ -14,10 +14,13 @@ namespace DorkNet.Server.Services;
/// The generate request carries no quest id (only GiftContext /
/// IsGameGift / Message), so the quest is identified by the room the
/// player is in — . This
-/// service maps that room id to the quest's item pool
-/// (data/quest_rewards.json) 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.
+/// service maps that room's name (the seeded RRO slug, e.g.
+/// GoldenTrophy / Crescendo / StuntRunner) to the
+/// quest's item pool (data/quest_rewards.json) and resolves one
+/// to a real store item to award. Keyed by name rather than the numeric
+/// id because seed ids are array-order-derived and brittle. When a room
+/// has no configured pool the caller falls back to the generic
+/// random-wardrobe gift.
///
public sealed class QuestRewardService(DorkNetDbContext db)
{
@@ -26,22 +29,23 @@ public sealed class QuestRewardService(DorkNetDbContext db)
public Dictionary> rooms { get; set; } = new();
}
- private static readonly Lazy>> _map = new(Load);
+ private static readonly Lazy>> _map = new(Load);
- /// Room id → candidate item slugs for that quest's chest.
- public static IReadOnlyDictionary> Map => _map.Value;
+ /// Room name (case-insensitive) → candidate item slugs.
+ public static IReadOnlyDictionary> Map => _map.Value;
- public static bool HasRewardsForRoom(long roomId) =>
- Map.TryGetValue(roomId, out var slugs) && slugs.Count > 0;
-
- /// Resolve one active store item for the quest chest in
- /// , chosen deterministically from
- /// 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.
+ /// Resolve one active store item for the quest chest in the
+ /// room with id , chosen deterministically
+ /// from 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.
public async Task PickForRoomAsync(long roomId, int seed)
{
- if (!Map.TryGetValue(roomId, out var slugs) || slugs.Count == 0) return null;
+ if (Map.Count == 0) return null;
+ var name = await db.Rooms.Where(r => r.Id == roomId).Select(r => r.Name).FirstOrDefaultAsync();
+ if (string.IsNullOrWhiteSpace(name) || !Map.TryGetValue(name, out var slugs) || slugs.Count == 0)
+ return null;
var items = await db.StoreItems
.Where(i => i.IsActive && slugs.Contains(i.Slug))
@@ -55,7 +59,7 @@ public sealed class QuestRewardService(DorkNetDbContext db)
.First();
}
- private static Dictionary> Load()
+ private static Dictionary> Load()
{
var candidates = new[]
{
@@ -65,7 +69,7 @@ public sealed class QuestRewardService(DorkNetDbContext db)
Path.Combine(Directory.GetCurrentDirectory(), "DorkNet.Server", "Data", "quest_rewards.json"),
};
var path = candidates.FirstOrDefault(File.Exists);
- if (path is null) return new();
+ if (path is null) return new(StringComparer.OrdinalIgnoreCase);
try
{
@@ -73,22 +77,22 @@ public sealed class QuestRewardService(DorkNetDbContext db)
var data = JsonSerializer.Deserialize(fs,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true }) ?? new QuestRewardFile();
- var map = new Dictionary>();
+ var map = new Dictionary>(StringComparer.OrdinalIgnoreCase);
foreach (var (key, slugs) in data.rooms)
{
- if (!long.TryParse(key, out var roomId)) continue;
+ if (string.IsNullOrWhiteSpace(key) || key.StartsWith('_')) 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;
+ if (clean.Count > 0) map[key.Trim()] = clean;
}
return map;
}
catch
{
- return new();
+ return new(StringComparer.OrdinalIgnoreCase);
}
}
}
diff --git a/DorkNet.Server/Startup/DatabaseBootstrap.cs b/DorkNet.Server/Startup/DatabaseBootstrap.cs
index a0e81ab..0bd1658 100644
--- a/DorkNet.Server/Startup/DatabaseBootstrap.cs
+++ b/DorkNet.Server/Startup/DatabaseBootstrap.cs
@@ -110,6 +110,7 @@ public static class DatabaseBootstrap
await EnsureRoomSceneColumnsAsync(db);
await EnsureInventionColumnsAsync(db);
await EnsureGameRewardColumnsAsync(db);
+ await EnsureGiftPackageColumnsAsync(db);
await EnsureMarch2023TablesAsync(db);
// Coach system account at Player.Id=1. The RR-Original room seeder
@@ -422,6 +423,26 @@ public static class DatabaseBootstrap
}
}
+ /// Post-Initial column on GiftPackages for quest-reward
+ /// chests (the exact store item the gift grants on consume).
+ private static async Task EnsureGiftPackageColumnsAsync(DorkNetDbContext db)
+ {
+ var provider = db.Database.ProviderName ?? string.Empty;
+ if (provider.Contains("Npgsql", StringComparison.OrdinalIgnoreCase))
+ {
+ await db.Database.ExecuteSqlRawAsync(
+ @"ALTER TABLE ""GiftPackages"" ADD COLUMN IF NOT EXISTS ""SourceStoreItemId"" bigint NOT NULL DEFAULT 0;");
+ return;
+ }
+
+ try
+ {
+ await db.Database.ExecuteSqlRawAsync(
+ @"ALTER TABLE ""GiftPackages"" ADD COLUMN ""SourceStoreItemId"" INTEGER NOT NULL DEFAULT 0;");
+ }
+ catch { }
+ }
+
private static async Task EnsureRoomSceneColumnsAsync(DorkNetDbContext db)
{
var provider = db.Database.ProviderName ?? string.Empty;