mirror of
https://github.com/DorkSquadRR/DorkNet.git
synced 2026-10-25 23:18:44 -07:00
feat: Implement quest reward chest functionality; enhance gift generation and consumption logic, update database schema, and add tests for new behavior
This commit is contained in:
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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 <c>SourceStoreItemId</c> (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.)
|
||||
/// </summary>
|
||||
public sealed class QuestRewardChestTests : IClassFixture<DorkNetServerFactory>
|
||||
{
|
||||
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<DorkNetDbContext>();
|
||||
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<DorkNetDbContext>();
|
||||
var avatar = await db.Avatars.FirstOrDefaultAsync(a => a.PlayerId == player.PlayerId);
|
||||
Assert.NotNull(avatar);
|
||||
var inv = JsonSerializer.Deserialize<List<string>>(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<DorkNetDbContext>();
|
||||
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<DorkNetDbContext>();
|
||||
var avatar = await db.Avatars.FirstAsync(a => a.PlayerId == player.PlayerId);
|
||||
var inv = JsonSerializer.Deserialize<List<string>>(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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
/// <summary>POST <c>api/avatar/v2/gifts/generate</c> — 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 <c>GiftContext</c>.</summary>
|
||||
/// <summary>POST <c>api/avatar/v2/gifts/generate</c> — 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 (<c>gifts/consume</c>) grants it.</summary>
|
||||
[HttpPost("api/avatar/v2/gifts/generate")]
|
||||
[Consumes("application/json", "application/x-www-form-urlencoded", "multipart/form-data")]
|
||||
public async Task<IActionResult> 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));
|
||||
}
|
||||
|
||||
/// <summary>Build a gift package for a specific store item, filling
|
||||
/// the wire payload that matches its kind (avatar item vs equipment
|
||||
/// vs consumable) so <c>gifts/consume</c> grants the right thing.</summary>
|
||||
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<IActionResult> 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.
|
||||
|
||||
@@ -93,4 +93,11 @@ public class GiftPackageEntity
|
||||
public DateTime? ConsumedAt { get; set; }
|
||||
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
/// <summary>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 <c>StoreService.GrantItemAsync</c>. 0 = legacy gift whose
|
||||
/// payload is derived from the wire fields above. Not on wire.</summary>
|
||||
public long SourceStoreItemId { get; set; }
|
||||
}
|
||||
|
||||
@@ -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-<trophyGuid>\", \"equipment-<prefab>:<guid>\"]. Fill in `rooms` with the per-quest item lists.",
|
||||
"rooms": {}
|
||||
}
|
||||
|
||||
@@ -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 — <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.
|
||||
/// service maps that room's <b>name</b> (the seeded RRO slug, e.g.
|
||||
/// <c>GoldenTrophy</c> / <c>Crescendo</c> / <c>StuntRunner</c>) to the
|
||||
/// quest's item pool (<c>data/quest_rewards.json</c>) 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.
|
||||
/// </summary>
|
||||
public sealed class QuestRewardService(DorkNetDbContext db)
|
||||
{
|
||||
@@ -26,22 +29,23 @@ public sealed class QuestRewardService(DorkNetDbContext db)
|
||||
public Dictionary<string, List<string>> rooms { get; set; } = new();
|
||||
}
|
||||
|
||||
private static readonly Lazy<Dictionary<long, List<string>>> _map = new(Load);
|
||||
private static readonly Lazy<Dictionary<string, 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;
|
||||
/// <summary>Room name (case-insensitive) → candidate item slugs.</summary>
|
||||
public static IReadOnlyDictionary<string, 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>
|
||||
/// <summary>Resolve one active store item for the quest chest in the
|
||||
/// room with id <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;
|
||||
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<long, List<string>> Load()
|
||||
private static Dictionary<string, List<string>> 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<QuestRewardFile>(fs,
|
||||
new JsonSerializerOptions { PropertyNameCaseInsensitive = true }) ?? new QuestRewardFile();
|
||||
|
||||
var map = new Dictionary<long, List<string>>();
|
||||
var map = new Dictionary<string, List<string>>(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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Post-Initial column on GiftPackages for quest-reward
|
||||
/// chests (the exact store item the gift grants on consume).</summary>
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user