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;