diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index 8d74243d..87686faf 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -8,6 +8,13 @@ "csharpier" ], "rollForward": false + }, + "dotnet-ef": { + "version": "9.0.8", + "commands": [ + "dotnet-ef" + ], + "rollForward": false } } -} +} \ No newline at end of file diff --git a/Turbo.Catalog/CatalogService.cs b/Turbo.Catalog/CatalogService.cs index 92944b98..8bcdabdb 100644 --- a/Turbo.Catalog/CatalogService.cs +++ b/Turbo.Catalog/CatalogService.cs @@ -1,5 +1,10 @@ using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; +using Turbo.Database.Context; using Turbo.Primitives.Catalog; using Turbo.Primitives.Catalog.Enums; using Turbo.Primitives.Catalog.Providers; @@ -10,12 +15,14 @@ namespace Turbo.Catalog; public sealed class CatalogService( ILogger logger, - ICatalogSnapshotProvider normalCatalogProvider + ICatalogSnapshotProvider normalCatalogProvider, + IDbContextFactory dbCtxFactory ) : ICatalogService { private readonly ILogger _logger = logger; private readonly ICatalogSnapshotProvider _normalCatalogProvider = normalCatalogProvider; + private readonly IDbContextFactory _dbCtxFactory = dbCtxFactory; public CatalogSnapshot GetCatalogSnapshot(CatalogType catalogType) { @@ -28,4 +35,40 @@ public CatalogSnapshot GetCatalogSnapshot(CatalogType catalogType) _ => throw new NotSupportedException($"Catalog type {catalogType} is not supported."), }; } + + public async Task GetUpcomingLtdAsync(CancellationToken ct) + { + await using var dbCtx = await _dbCtxFactory.CreateDbContextAsync(ct); + + // Find the nearest upcoming active LTD drop + var now = DateTime.UtcNow; + var nextSeries = await dbCtx + .LtdSeries.AsNoTracking() + .Where(s => s.IsActive && s.StartsAt > now) + .OrderBy(s => s.StartsAt) + .FirstOrDefaultAsync(ct); + + if (nextSeries == null) + return null; + + var catalogSnap = GetCatalogSnapshot(CatalogType.Normal); + var product = catalogSnap.ProductsById.Values.FirstOrDefault(p => + p.LtdSeriesId == nextSeries.Id + ); + + if (product == null) + return null; + + // Resolve PageId from Offer + if (!catalogSnap.OffersById.TryGetValue(product.OfferId, out var offer)) + return null; + + return new UpcomingLtdSnapshot + { + SecondsUntil = (int)(nextSeries.StartsAt!.Value - now).TotalSeconds, + PageId = offer.PageId, + OfferId = offer.Id, + ClassName = product.ClassName, + }; + } } diff --git a/Turbo.Catalog/Configuration/CatalogConfig.cs b/Turbo.Catalog/Configuration/CatalogConfig.cs index 49873f37..8c26e573 100644 --- a/Turbo.Catalog/Configuration/CatalogConfig.cs +++ b/Turbo.Catalog/Configuration/CatalogConfig.cs @@ -1,6 +1,164 @@ +using System; + namespace Turbo.Catalog.Configuration; public class CatalogConfig { public const string SECTION_NAME = "Turbo:Catalog"; + + /// + /// Configuration for LTD raffle weighting criteria. + /// + public LtdRaffleWeightConfig LtdRaffle { get; set; } = new(); +} + +/// +/// Configuration for LTD raffle weighting criteria. +/// Hotel owners can tune these values to define their own fairness criteria. +/// +public class LtdRaffleWeightConfig +{ + /// + /// Base weight given to all participants (ensures everyone has a chance). + /// + public double BaseWeight { get; set; } = 1.0; + + /// + /// Default buffer window in seconds. + /// + public int DefaultBufferSeconds { get; set; } = 20; + + /// + /// If true, uses pure random selection (equal chance for all). + /// Set to true to disable all weighting. + /// + public bool UsePureRandom { get; set; } = false; + + /// + /// If true, serial numbers are assigned randomly (Habbo style). + /// If false, they are assigned sequentially (1, 2, 3...). + /// + public bool RandomizeSerials { get; set; } = true; + + /// + /// If true, each player can only win one item per LTD series. + /// If false, players can buy as many as they want (if stock permits). + /// + public bool LimitOnePerCustomer { get; set; } = true; + + /// + /// Maximum number of entries accepted per raffle batch. + /// Prevents unbounded memory growth during the buffer window. + /// + public int MaxEntriesPerBatch { get; set; } = 5000; + + /// + /// Badge count weighting configuration. + /// + public WeightCriterion BadgeCount { get; set; } = + new() + { + Enabled = true, + BonusPerUnit = 0.02, + MaxBonus = 1.0, + }; + + /// + /// Account age (days) weighting configuration. + /// + public WeightCriterion AccountAgeDays { get; set; } = + new() + { + Enabled = true, + BonusPerUnit = 0.00137, // ~0.5 per year + MaxBonus = 0.5, + }; + + /// + /// Online time (minutes) weighting configuration. + /// Note: Requires online time tracking to be implemented. + /// + public WeightCriterion OnlineTimeMinutes { get; set; } = + new() + { + Enabled = false, + BonusPerUnit = 0.00005, + MaxBonus = 0.5, + }; + + /// + /// Room count weighting configuration. + /// + public WeightCriterion RoomCount { get; set; } = + new() + { + Enabled = false, + BonusPerUnit = 0.05, + MaxBonus = 0.5, + }; + + /// + /// Furniture count weighting configuration. + /// + public WeightCriterion FurnitureCount { get; set; } = + new() + { + Enabled = false, + BonusPerUnit = 0.001, + MaxBonus = 0.5, + }; + + /// + /// Friend count weighting configuration. + /// + public WeightCriterion FriendCount { get; set; } = + new() + { + Enabled = false, + BonusPerUnit = 0.01, + MaxBonus = 0.5, + }; + + /// + /// Respects earned (from other users) weighting configuration. + /// + public WeightCriterion RespectsReceived { get; set; } = + new() + { + Enabled = false, + BonusPerUnit = 0.005, + MaxBonus = 0.5, + }; + + /// + /// Achievement score weighting configuration. + /// + public WeightCriterion AchievementScore { get; set; } = + new() + { + Enabled = false, + BonusPerUnit = 0.0001, + MaxBonus = 0.5, + }; +} + +/// +/// Configuration for a single weighting criterion. +/// +public class WeightCriterion +{ + /// + /// Whether this criterion is used in weight calculation. + /// + public bool Enabled { get; set; } + + /// + /// Bonus weight added per unit of this criterion. + /// + public double BonusPerUnit { get; set; } + + /// + /// Maximum bonus that can be gained from this criterion. + /// + public double MaxBonus { get; set; } } diff --git a/Turbo.Catalog/Grains/LtdRaffleGrain.cs b/Turbo.Catalog/Grains/LtdRaffleGrain.cs new file mode 100644 index 00000000..170dd826 --- /dev/null +++ b/Turbo.Catalog/Grains/LtdRaffleGrain.cs @@ -0,0 +1,566 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Orleans; +using Turbo.Catalog.Configuration; +using Turbo.Database.Context; +using Turbo.Database.Entities.Catalog; +using Turbo.Primitives.Catalog; +using Turbo.Primitives.Catalog.Enums; +using Turbo.Primitives.Catalog.Grains; +using Turbo.Primitives.Catalog.Snapshots; +using Turbo.Primitives.Messages.Outgoing.Catalog; +using Turbo.Primitives.Messages.Outgoing.Collectibles; +using Turbo.Primitives.Orleans; +using Turbo.Primitives.Players; +using Turbo.Primitives.Players.Enums.Wallet; +using Turbo.Primitives.Players.Wallet; + +namespace Turbo.Catalog.Grains; + +public sealed class LtdRaffleGrain( + IGrainFactory grainFactory, + IDbContextFactory dbCtxFactory, + ILogger logger, + ICatalogService catalogService, + IOptions config +) : Grain, ILtdRaffleGrain +{ + private readonly CatalogConfig _config = config.Value; + + private readonly Dictionary _currentBatchEntries = []; + + private LtdSeriesSnapshot? _series; + private string? _currentBatchId; + private bool _isInBufferPeriod; + private bool _raffleFinished; + private IDisposable? _raffleTimer; + + public override async Task OnActivateAsync(CancellationToken ct) + { + await ReloadSeriesAsync(ct); + + if (_series != null) + _raffleFinished = _series.IsRaffleFinished; + } + + public override async Task OnDeactivateAsync(DeactivationReason reason, CancellationToken ct) + { + _raffleTimer?.Dispose(); + _raffleTimer = null; + + if (_currentBatchEntries.Count > 0) + { + try + { + await ExecuteRaffleAsync(); + } + catch (Exception ex) + { + logger.LogError( + ex, + "Failed to execute raffle during deactivation for series {SeriesId}", + this.GetPrimaryKeyLong() + ); + } + } + } + + public async Task EnterRaffleAsync(int playerId, CancellationToken ct) + { + if (_series is not { IsAvailable: true }) + { + return LtdRaffleEntryResult.Failed( + _series?.RemainingQuantity <= 0 + ? LtdRaffleEntryError.SoldOut + : LtdRaffleEntryError.SeriesNotFound + ); + } + + var snap = catalogService.GetCatalogSnapshot(CatalogType.Normal); + var product = snap.ProductsById.Values.FirstOrDefault(p => p.LtdSeriesId == _series.Id); + + if (product == null || !snap.OffersById.TryGetValue(product.OfferId, out var offer)) + return LtdRaffleEntryResult.Failed(LtdRaffleEntryError.None); + + var walletGrain = grainFactory.GetPlayerWalletGrain(playerId); + var credits = await walletGrain.GetAmountForCurrencyAsync( + new CurrencyKind { CurrencyType = CurrencyType.Credits }, + ct + ); + var activityPoints = await walletGrain.GetActivityPointsAsync(ct); + + var hasInsufficientCredits = offer.CostCredits > credits; + var hasInsufficientActivityPoints = + offer is { CostCurrency: > 0, CurrencyTypeId: not null } + && activityPoints.GetValueOrDefault(offer.CurrencyTypeId.Value) < offer.CostCurrency; + + if (hasInsufficientCredits || hasInsufficientActivityPoints) + { + return LtdRaffleEntryResult.Failed( + LtdRaffleEntryError.InsufficientFunds, + new CatalogBalanceFailure + { + NotEnoughCredits = hasInsufficientCredits, + NotEnoughActivityPoints = hasInsufficientActivityPoints, + ActivityPointType = offer.CurrencyTypeId ?? 0, + } + ); + } + + var buffer = + _series.RaffleWindowSeconds > 0 + ? _series.RaffleWindowSeconds + : _config.LtdRaffle.DefaultBufferSeconds; + + if (buffer <= 0 || _raffleFinished) + { + var instantWin = await TryFinalizeWinnerAsync(playerId, null, false); + return instantWin + ? LtdRaffleEntryResult.Succeeded("instant") + : LtdRaffleEntryResult.Failed(LtdRaffleEntryError.SoldOut); + } + + if (_config.LtdRaffle.LimitOnePerCustomer) + { + await using var dbCtx = await dbCtxFactory.CreateDbContextAsync(ct); + + var alreadyWon = await dbCtx.LtdRaffleEntries.AnyAsync( + e => + e.SeriesEntityId == _series.Id + && e.PlayerEntityId == playerId + && e.Result == "won", + ct + ); + + if (alreadyWon) + return LtdRaffleEntryResult.Failed(LtdRaffleEntryError.AlreadyWon); + } + + if (_currentBatchEntries.ContainsKey(playerId)) + return LtdRaffleEntryResult.Failed(LtdRaffleEntryError.AlreadyInQueue); + + if (_currentBatchId == null) + { + _currentBatchId = Guid.NewGuid().ToString(); + _isInBufferPeriod = true; + _raffleTimer = this.RegisterGrainTimer( + async _ => await ExecuteRaffleAsync(), + null, + TimeSpan.FromSeconds(buffer), + Timeout.InfiniteTimeSpan + ); + } + + if (!_isInBufferPeriod) + return LtdRaffleEntryResult.Failed(LtdRaffleEntryError.RaffleProcessing); + + if (_currentBatchEntries.Count >= _config.LtdRaffle.MaxEntriesPerBatch) + return LtdRaffleEntryResult.Failed(LtdRaffleEntryError.RaffleProcessing); + + _currentBatchEntries[playerId] = await CalculateWeightAsync(playerId, ct); + await PersistEntryAsync(playerId, _currentBatchId, ct); + + await grainFactory + .GetPlayerPresenceGrain(playerId) + .SendComposerAsync( + new LtdRaffleEnteredMessageComposer { ClassName = product.ClassName ?? "LTD" } + ); + + return LtdRaffleEntryResult.Succeeded(_currentBatchId); + } + + private async Task ExecuteRaffleAsync() + { + if (_currentBatchId == null || _currentBatchEntries.Count == 0) + { + _isInBufferPeriod = false; + return; + } + + var batchId = _currentBatchId; + var entries = _currentBatchEntries.ToList(); + + _currentBatchId = null; + _currentBatchEntries.Clear(); + _isInBufferPeriod = false; + _raffleFinished = true; + _raffleTimer?.Dispose(); + _raffleTimer = null; + + await PersistFinishedAsync(); + await ReloadSeriesAsync(CancellationToken.None); + + var winnersCount = Math.Min(entries.Count, _series?.RemainingQuantity ?? 0); + var winners = _config.LtdRaffle.UsePureRandom + ? [.. entries.OrderBy(_ => Random.Shared.Next()).Take(winnersCount).Select(e => e.Key)] + : SelectWeighted(entries, winnersCount); + + var loserIds = new List(); + + // Winners must be sequential (row lock + quantity decrement per winner) + foreach (var entry in entries) + { + if (winners.Contains(entry.Key)) + await TryFinalizeWinnerAsync(entry.Key, batchId, true); + else + loserIds.Add(entry.Key); + } + + // Loser notifications go to different presence grains — parallelize + if (loserIds.Count > 0) + { + await Task.WhenAll( + loserIds.Select(id => NotifyLoserAsync(id, LtdRaffleResultCode.Lost)) + ); + } + + if (loserIds.Count > 0) + { + await using var db = await dbCtxFactory.CreateDbContextAsync(CancellationToken.None); + + await db + .LtdRaffleEntries.Where(e => + e.BatchId == batchId && loserIds.Contains(e.PlayerEntityId) + ) + .ExecuteUpdateAsync(u => + u.SetProperty(e => e.Result, "lost") + .SetProperty(e => e.ProcessedAt, DateTime.UtcNow) + ); + } + + await ReloadSeriesAsync(CancellationToken.None); + } + + private async Task TryFinalizeWinnerAsync(int playerId, string? batchId, bool isRaffle) + { + await using var dbCtx = await dbCtxFactory.CreateDbContextAsync(); + await using var tx = await dbCtx.Database.BeginTransactionAsync(); + + try + { + var series = await dbCtx + .LtdSeries.FromSqlRaw( + "SELECT * FROM ltd_series WHERE id = {0} FOR UPDATE", + (int)this.GetPrimaryKeyLong() + ) + .OrderBy(x => x.Id) + .FirstOrDefaultAsync(); + + if (series is not { RemainingQuantity: > 0 }) + return false; + + var snap = catalogService.GetCatalogSnapshot(CatalogType.Normal); + var prod = snap.ProductsById.Values.First(p => p.LtdSeriesId == series.Id); + var offer = snap.OffersById[prod.OfferId]; + + var debitResult = await grainFactory + .GetPlayerWalletGrain(playerId) + .TryDebitAsync(BuildDebits(offer), CancellationToken.None); + + if (!debitResult.Succeeded) + { + await NotifyLoserAsync(playerId, LtdRaffleResultCode.Lost); + return false; + } + + var serial = _config.LtdRaffle.RandomizeSerials + ? (await GetAvailableSerialsAsync(dbCtx, series))[ + Random.Shared.Next(series.RemainingQuantity) + ] + : (series.TotalQuantity - series.RemainingQuantity) + 1; + + series.RemainingQuantity--; + + if (batchId != null) + { + var entry = await dbCtx + .LtdRaffleEntries.OrderBy(e => e.Id) + .FirstOrDefaultAsync(e => e.BatchId == batchId && e.PlayerEntityId == playerId); + + if (entry != null) + { + entry.Result = "won"; + entry.SerialNumber = serial; + entry.ProcessedAt = DateTime.UtcNow; + } + } + + await dbCtx.SaveChangesAsync(); + await tx.CommitAsync(); + + await grainFactory + .GetInventoryGrain(playerId) + .GrantLtdFurnitureAsync( + series.CatalogProductEntityId, + serial, + series.TotalQuantity, + CancellationToken.None + ); + + var presence = grainFactory.GetPlayerPresenceGrain(playerId); + + if (isRaffle) + { + await presence.SendComposerAsync( + new LtdRaffleResultMessageComposer + { + ClassName = prod.ClassName ?? "LTD", + ResultCode = LtdRaffleResultCode.Won, + } + ); + } + else + { + await presence.SendComposerAsync(new PurchaseOKMessageComposer { Offer = offer }); + } + + return true; + } + catch (Exception ex) + { + logger.LogError( + ex, + "Failed to finalize LTD raffle winner for player {PlayerId} in series {SeriesId}", + playerId, + this.GetPrimaryKeyLong() + ); + + await tx.RollbackAsync(); + return false; + } + } + + private async Task> GetAvailableSerialsAsync(TurboDbContext db, LtdSeriesEntity s) + { + var usedSerials = await db + .LtdRaffleEntries.Where(e => + e.SeriesEntityId == s.Id && e.Result == "won" && e.SerialNumber != null + ) + .Select(e => e.SerialNumber!.Value) + .ToListAsync(); + + return [.. Enumerable.Range(1, s.TotalQuantity).Except(usedSerials)]; + } + + private static List BuildDebits(CatalogOfferSnapshot offer) + { + var debits = new List(); + + if (offer.CostCredits > 0) + { + debits.Add( + new WalletDebitRequest + { + CurrencyKind = new CurrencyKind { CurrencyType = CurrencyType.Credits }, + Amount = offer.CostCredits, + } + ); + } + + if (offer.CostCurrency > 0) + { + debits.Add( + new WalletDebitRequest + { + CurrencyKind = new CurrencyKind + { + CurrencyType = CurrencyType.ActivityPoints, + ActivityPointType = offer.CurrencyTypeId, + }, + Amount = offer.CostCurrency, + } + ); + } + + return debits; + } + + private async Task CalculateWeightAsync(int playerId, CancellationToken ct) + { + var playerGrain = grainFactory.GetPlayerGrain(PlayerId.Parse(playerId)); + var summary = await playerGrain.GetSummaryAsync(ct); + var profile = await playerGrain.GetExtendedProfileSnapshotAsync(ct); + + var cfg = _config.LtdRaffle; + var weight = cfg.BaseWeight; + + // TODO: Replace DB queries with snapshot-based lookups once PlayerGrain exposes + // badge count, room count, and furniture count in PlayerSummarySnapshot or a dedicated snapshot. + var needsDbQuery = + cfg.BadgeCount.Enabled || cfg.RoomCount.Enabled || cfg.FurnitureCount.Enabled; + + if (needsDbQuery) + { + await using var db = await dbCtxFactory.CreateDbContextAsync(ct); + + if (cfg.BadgeCount.Enabled) + { + var badgeCount = await db.PlayerBadges.CountAsync( + b => b.PlayerEntityId == playerId, + ct + ); + weight += Math.Min( + badgeCount * cfg.BadgeCount.BonusPerUnit, + cfg.BadgeCount.MaxBonus + ); + } + + if (cfg.RoomCount.Enabled) + { + var roomCount = await db.Rooms.CountAsync(r => r.PlayerEntityId == playerId, ct); + weight += Math.Min(roomCount * cfg.RoomCount.BonusPerUnit, cfg.RoomCount.MaxBonus); + } + + if (cfg.FurnitureCount.Enabled) + { + var furniCount = await db.Furnitures.CountAsync( + f => f.PlayerEntityId == playerId, + ct + ); + weight += Math.Min( + furniCount * cfg.FurnitureCount.BonusPerUnit, + cfg.FurnitureCount.MaxBonus + ); + } + } + + // Snapshot-based weighting (no DB round-trip needed) + if (cfg.AccountAgeDays.Enabled) + weight += Math.Min( + (DateTime.UtcNow - summary.CreatedAt).Days * cfg.AccountAgeDays.BonusPerUnit, + cfg.AccountAgeDays.MaxBonus + ); + + if (cfg.AchievementScore.Enabled) + weight += Math.Min( + profile.AchievementScore * cfg.AchievementScore.BonusPerUnit, + cfg.AchievementScore.MaxBonus + ); + + if (cfg.FriendCount.Enabled) + weight += Math.Min( + profile.FriendCount * cfg.FriendCount.BonusPerUnit, + cfg.FriendCount.MaxBonus + ); + + if (cfg.RespectsReceived.Enabled) + weight += Math.Min( + profile.StarGemCount * cfg.RespectsReceived.BonusPerUnit, + cfg.RespectsReceived.MaxBonus + ); + + return weight; + } + + private static HashSet SelectWeighted(List> entries, int count) + { + var winners = new HashSet(); + var pool = entries.ToList(); + + for (var i = 0; i < count && pool.Count > 0; i++) + { + var total = pool.Sum(e => e.Value); + var roll = Random.Shared.NextDouble() * total; + var current = 0.0; + + foreach (var entry in pool) + { + current += entry.Value; + + if (roll <= current) + { + winners.Add(entry.Key); + pool.Remove(entry); + break; + } + } + } + + return winners; + } + + public async Task ReloadSeriesAsync(CancellationToken ct) + { + await using var db = await dbCtxFactory.CreateDbContextAsync(ct); + + var entity = await db + .LtdSeries.AsNoTracking() + .OrderBy(s => s.Id) + .FirstOrDefaultAsync(s => s.Id == (int)this.GetPrimaryKeyLong(), ct); + + if (entity != null) + { + _series = new LtdSeriesSnapshot + { + Id = entity.Id, + CatalogProductId = entity.CatalogProductEntityId, + TotalQuantity = entity.TotalQuantity, + RemainingQuantity = entity.RemainingQuantity, + RaffleWindowSeconds = entity.RaffleWindowSeconds, + IsActive = entity.IsActive, + IsRaffleFinished = entity.IsRaffleFinished, + StartsAt = entity.StartsAt, + EndsAt = entity.EndsAt, + }; + } + } + + private async Task PersistFinishedAsync() + { + await using var db = await dbCtxFactory.CreateDbContextAsync(); + + await db + .LtdSeries.Where(s => s.Id == (int)this.GetPrimaryKeyLong()) + .ExecuteUpdateAsync(u => u.SetProperty(s => s.IsRaffleFinished, true)); + } + + private async Task NotifyLoserAsync(int playerId, LtdRaffleResultCode resultCode) + { + var product = catalogService + .GetCatalogSnapshot(CatalogType.Normal) + .ProductsById.Values.FirstOrDefault(p => p.LtdSeriesId == _series?.Id); + + await grainFactory + .GetPlayerPresenceGrain(playerId) + .SendComposerAsync( + new LtdRaffleResultMessageComposer + { + ClassName = product?.ClassName ?? "LTD", + ResultCode = resultCode, + } + ); + } + + private async Task PersistEntryAsync(int playerId, string batchId, CancellationToken ct) + { + await using var db = await dbCtxFactory.CreateDbContextAsync(ct); + + db.LtdRaffleEntries.Add( + new LtdRaffleEntryEntity + { + SeriesEntityId = (int)this.GetPrimaryKeyLong(), + PlayerEntityId = playerId, + BatchId = batchId, + EnteredAt = DateTime.UtcNow, + Result = "pending", + } + ); + + await db.SaveChangesAsync(ct); + } + + public Task GetSeriesSnapshotAsync(CancellationToken ct) => + Task.FromResult(_series); + + public async Task ForceRunRaffleAsync(CancellationToken ct) + { + _raffleTimer?.Dispose(); + await ExecuteRaffleAsync(); + } +} diff --git a/Turbo.Catalog/Providers/CatalogSnapshotProvider.cs b/Turbo.Catalog/Providers/CatalogSnapshotProvider.cs index 963a7edb..2028a831 100644 --- a/Turbo.Catalog/Providers/CatalogSnapshotProvider.cs +++ b/Turbo.Catalog/Providers/CatalogSnapshotProvider.cs @@ -1,3 +1,4 @@ +using System.Collections.Generic; using System.Collections.Immutable; using System.Linq; using System.Threading; @@ -5,6 +6,7 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using Turbo.Database.Context; +using Turbo.Database.Entities.Catalog; using Turbo.Primitives.Catalog; using Turbo.Primitives.Catalog.Enums; using Turbo.Primitives.Catalog.Providers; @@ -55,6 +57,21 @@ public async Task ReloadAsync(CancellationToken ct) .CatalogProducts.AsNoTracking() .ToListAsync(ct) .ConfigureAwait(false); + var allSeries = await dbCtx + .LtdSeries.AsNoTracking() + .ToListAsync(ct) + .ConfigureAwait(false); + + // Group by product and pick the most relevant series (Active > Newest) + var series = allSeries + .GroupBy(s => s.CatalogProductEntityId) + .ToDictionary( + g => g.Key, + g => + g.OrderByDescending(s => s.IsActive) + .ThenByDescending(s => s.StartsAt ?? s.CreatedAt) + .First() + ); var pageChildrenIds = pages .GroupBy(p => p.ParentEntityId ?? -1) @@ -76,23 +93,34 @@ public async Task ReloadAsync(CancellationToken ct) .ToImmutableDictionary(g => g.Key, g => g.Select(x => x.Id).ToImmutableArray()); var productsById = products - .Select(x => new CatalogProductSnapshot + .Select(x => { - Id = x.Id, - OfferId = x.CatalogOfferEntityId, - ProductType = x.ProductType, - FurniDefinitionId = x.FurnitureDefinitionEntityId ?? -1, - SpriteId = - x.FurnitureDefinitionEntityId != null - ? _furnitureProvider - .TryGetDefinition(x.FurnitureDefinitionEntityId.Value) - ?.SpriteId - ?? -1 - : -1, - ExtraParam = x.ExtraParam, - Quantity = x.Quantity, - UniqueSize = x.UniqueSize, - UniqueRemaining = x.UniqueRemaining, + var productSeries = series.GetValueOrDefault(x.Id); + return new CatalogProductSnapshot + { + Id = x.Id, + OfferId = x.CatalogOfferEntityId, + ProductType = x.ProductType, + FurniDefinitionId = x.FurnitureDefinitionEntityId ?? -1, + SpriteId = + x.FurnitureDefinitionEntityId != null + ? _furnitureProvider + .TryGetDefinition(x.FurnitureDefinitionEntityId.Value) + ?.SpriteId + ?? -1 + : -1, + ExtraParam = x.ExtraParam, + Quantity = x.Quantity, + UniqueSize = productSeries?.TotalQuantity ?? 0, + UniqueRemaining = productSeries?.RemainingQuantity ?? 0, + LtdSeriesId = productSeries?.Id, + ClassName = + x.FurnitureDefinitionEntityId != null + ? _furnitureProvider + .TryGetDefinition(x.FurnitureDefinitionEntityId.Value) + ?.Name + : null, + }; }) .ToImmutableDictionary(x => x.Id); diff --git a/Turbo.Database/Context/TurboDbContext.cs b/Turbo.Database/Context/TurboDbContext.cs index 655b3829..6d776706 100644 --- a/Turbo.Database/Context/TurboDbContext.cs +++ b/Turbo.Database/Context/TurboDbContext.cs @@ -59,6 +59,10 @@ public class TurboDbContext(DbContextOptions options) public DbSet PlayerFavouriteRooms { get; init; } + public DbSet LtdSeries { get; init; } + + public DbSet LtdRaffleEntries { get; init; } + public DbSet MessengerFriends { get; init; } public DbSet MessengerRequests { get; init; } diff --git a/Turbo.Database/Entities/Catalog/CatalogProductEntity.cs b/Turbo.Database/Entities/Catalog/CatalogProductEntity.cs index 7d2f405f..9145639e 100644 --- a/Turbo.Database/Entities/Catalog/CatalogProductEntity.cs +++ b/Turbo.Database/Entities/Catalog/CatalogProductEntity.cs @@ -25,14 +25,6 @@ public class CatalogProductEntity : TurboEntity [DefaultValue(1)] public required int Quantity { get; set; } - [Column("unique_size")] - [DefaultValue(0)] - public required int UniqueSize { get; set; } - - [Column("unique_remaining")] - [DefaultValue(0)] - public required int UniqueRemaining { get; set; } - [ForeignKey(nameof(CatalogOfferEntityId))] public required CatalogOfferEntity Offer { get; set; } diff --git a/Turbo.Database/Entities/Catalog/LtdRaffleEntryEntity.cs b/Turbo.Database/Entities/Catalog/LtdRaffleEntryEntity.cs new file mode 100644 index 00000000..205b49bc --- /dev/null +++ b/Turbo.Database/Entities/Catalog/LtdRaffleEntryEntity.cs @@ -0,0 +1,65 @@ +using System; +using System.ComponentModel; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using Turbo.Database.Entities.Players; + +namespace Turbo.Database.Entities.Catalog; + +/// +/// An entry in an LTD raffle queue. +/// +[Table("ltd_raffle_entries")] +public class LtdRaffleEntryEntity : TurboEntity +{ + /// + /// The LTD series this entry is for. + /// + [Column("series_id")] + public required int SeriesEntityId { get; set; } + + /// + /// The player who entered the raffle. + /// + [Column("player_id")] + public required int PlayerEntityId { get; set; } + + /// + /// UUID for the raffle batch this entry belongs to. + /// + [Column("batch_id")] + [MaxLength(36)] + public required string BatchId { get; set; } + + /// + /// When the player entered the raffle. + /// + [Column("entered_at")] + public required DateTime EnteredAt { get; set; } + + /// + /// The result of the raffle: 'pending', 'won', 'lost'. + /// + [Column("result")] + [MaxLength(20)] + [DefaultValue("pending")] + public required string Result { get; set; } = "pending"; + + /// + /// The serial number assigned (if won). + /// + [Column("serial_number")] + public int? SerialNumber { get; set; } + + /// + /// When the raffle was processed. + /// + [Column("processed_at")] + public DateTime? ProcessedAt { get; set; } + + [ForeignKey(nameof(SeriesEntityId))] + public LtdSeriesEntity? Series { get; set; } + + [ForeignKey(nameof(PlayerEntityId))] + public PlayerEntity? Player { get; set; } +} diff --git a/Turbo.Database/Entities/Catalog/LtdSeriesEntity.cs b/Turbo.Database/Entities/Catalog/LtdSeriesEntity.cs new file mode 100644 index 00000000..8c2e7606 --- /dev/null +++ b/Turbo.Database/Entities/Catalog/LtdSeriesEntity.cs @@ -0,0 +1,67 @@ +using System; +using System.ComponentModel; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Turbo.Database.Entities.Catalog; + +/// +/// Configuration for an LTD (Limited Edition) series. +/// +[Table("ltd_series")] +public class LtdSeriesEntity : TurboEntity +{ + /// + /// Links to the catalog product this LTD series is for. + /// + [Column("catalog_product_id")] + public required int CatalogProductEntityId { get; set; } + + /// + /// Total items in this series (e.g., 500). + /// + [Column("total_quantity")] + public required int TotalQuantity { get; set; } + + /// + /// How many items are left to mint. + /// + [Column("remaining_quantity")] + public required int RemainingQuantity { get; set; } + + /// + /// How long the raffle queue is open (in seconds) before drawing. + /// + [Column("raffle_window_seconds")] + [DefaultValue(30)] + public required int RaffleWindowSeconds { get; set; } = 30; + + /// + /// Whether this series is currently active. + /// + [Column("is_active")] + [DefaultValue(true)] + public required bool IsActive { get; set; } = true; + + /// + /// Whether the initial 20s raffle draw has already occurred. + /// + [Column("has_raffle_finished")] + [DefaultValue(false)] + public bool IsRaffleFinished { get; set; } = false; + + /// + /// When this LTD becomes available (null = immediately). + /// + [Column("starts_at")] + public DateTime? StartsAt { get; set; } + + /// + /// When this LTD sale ends (null = never). + /// + [Column("ends_at")] + public DateTime? EndsAt { get; set; } + + [ForeignKey(nameof(CatalogProductEntityId))] + public CatalogProductEntity? CatalogProduct { get; set; } +} diff --git a/Turbo.Database/Migrations/20260208201115_AddLtdRaffleSystem.Designer.cs b/Turbo.Database/Migrations/20260208201115_AddLtdRaffleSystem.Designer.cs new file mode 100644 index 00000000..13943336 --- /dev/null +++ b/Turbo.Database/Migrations/20260208201115_AddLtdRaffleSystem.Designer.cs @@ -0,0 +1,2530 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Turbo.Database.Context; + +#nullable disable + +namespace Turbo.Database.Migrations +{ + [DbContext(typeof(TurboDbContext))] + [Migration("20260208201115_AddLtdRaffleSystem")] + partial class AddLtdRaffleSystem + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 64); + + MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder); + + modelBuilder.Entity("Turbo.Database.Entities.Catalog.CatalogOfferEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("CanBundle") + .ValueGeneratedOnAdd() + .HasColumnType("tinyint(1)") + .HasDefaultValue(true) + .HasColumnName("can_bundle"); + + b.Property("CanGift") + .ValueGeneratedOnAdd() + .HasColumnType("tinyint(1)") + .HasDefaultValue(true) + .HasColumnName("can_gift"); + + b.Property("CatalogPageEntityId") + .HasColumnType("int") + .HasColumnName("page_id"); + + b.Property("ClubLevel") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0) + .HasColumnName("club_level"); + + b.Property("CostCredits") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0) + .HasColumnName("cost_credits"); + + b.Property("CostCurrency") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0) + .HasColumnName("cost_currency"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime(6)") + .HasColumnName("created_at"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("CreatedAt")); + + b.Property("CurrencyTypeId") + .HasColumnType("int") + .HasColumnName("currency_type_id"); + + b.Property("DeletedAt") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("datetime(6)") + .HasColumnName("deleted_at"); + + MySqlPropertyBuilderExtensions.UseMySqlComputedColumn(b.Property("DeletedAt")); + + b.Property("LocalizationId") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("varchar(512)") + .HasColumnName("localization_id"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("datetime(6)") + .HasColumnName("updated_at"); + + MySqlPropertyBuilderExtensions.UseMySqlComputedColumn(b.Property("UpdatedAt")); + + b.Property("Visible") + .ValueGeneratedOnAdd() + .HasColumnType("tinyint(1)") + .HasDefaultValue(true) + .HasColumnName("visible"); + + b.HasKey("Id"); + + b.HasIndex("CatalogPageEntityId"); + + b.HasIndex("CurrencyTypeId"); + + b.ToTable("catalog_offers"); + }); + + modelBuilder.Entity("Turbo.Database.Entities.Catalog.CatalogPageEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime(6)") + .HasColumnName("created_at"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("CreatedAt")); + + b.Property("DeletedAt") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("datetime(6)") + .HasColumnName("deleted_at"); + + MySqlPropertyBuilderExtensions.UseMySqlComputedColumn(b.Property("DeletedAt")); + + b.Property("Icon") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0) + .HasColumnName("icon"); + + b.PrimitiveCollection("ImageData") + .HasColumnType("longtext") + .HasColumnName("image_data"); + + b.Property("Layout") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(50) + .HasColumnType("varchar(50)") + .HasDefaultValue("default_3x3") + .HasColumnName("layout"); + + b.Property("Localization") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)") + .HasColumnName("localization"); + + b.Property("Name") + .HasMaxLength(50) + .HasColumnType("varchar(50)") + .HasColumnName("name"); + + b.Property("ParentEntityId") + .HasColumnType("int") + .HasColumnName("parent_id"); + + b.Property("SortOrder") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0) + .HasColumnName("sort_order"); + + b.PrimitiveCollection("TextData") + .HasColumnType("longtext") + .HasColumnName("text_data"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("datetime(6)") + .HasColumnName("updated_at"); + + MySqlPropertyBuilderExtensions.UseMySqlComputedColumn(b.Property("UpdatedAt")); + + b.Property("Visible") + .ValueGeneratedOnAdd() + .HasColumnType("tinyint(1)") + .HasDefaultValue(true) + .HasColumnName("visible"); + + b.HasKey("Id"); + + b.HasIndex("ParentEntityId"); + + b.ToTable("catalog_pages"); + }); + + modelBuilder.Entity("Turbo.Database.Entities.Catalog.CatalogProductEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("CatalogOfferEntityId") + .HasColumnType("int") + .HasColumnName("offer_id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime(6)") + .HasColumnName("created_at"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("CreatedAt")); + + b.Property("DeletedAt") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("datetime(6)") + .HasColumnName("deleted_at"); + + MySqlPropertyBuilderExtensions.UseMySqlComputedColumn(b.Property("DeletedAt")); + + b.Property("ExtraParam") + .HasMaxLength(512) + .HasColumnType("varchar(512)") + .HasColumnName("extra_param"); + + b.Property("FurnitureDefinitionEntityId") + .HasColumnType("int") + .HasColumnName("definition_id"); + + b.Property("ProductType") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0) + .HasColumnName("product_type"); + + b.Property("Quantity") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(1) + .HasColumnName("quantity"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("datetime(6)") + .HasColumnName("updated_at"); + + MySqlPropertyBuilderExtensions.UseMySqlComputedColumn(b.Property("UpdatedAt")); + + b.HasKey("Id"); + + b.HasIndex("CatalogOfferEntityId"); + + b.HasIndex("FurnitureDefinitionEntityId"); + + b.ToTable("catalog_products"); + }); + + modelBuilder.Entity("Turbo.Database.Entities.Catalog.CurrencyTypeEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("ActivityPointType") + .HasColumnType("int") + .HasColumnName("activity_point_type"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime(6)") + .HasColumnName("created_at"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("CreatedAt")); + + b.Property("CurrencyType") + .HasColumnType("int") + .HasColumnName("type"); + + b.Property("DeletedAt") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("datetime(6)") + .HasColumnName("deleted_at"); + + MySqlPropertyBuilderExtensions.UseMySqlComputedColumn(b.Property("DeletedAt")); + + b.Property("Enabled") + .ValueGeneratedOnAdd() + .HasColumnType("tinyint(1)") + .HasDefaultValue(true) + .HasColumnName("enabled"); + + b.Property("Name") + .HasMaxLength(512) + .HasColumnType("varchar(512)") + .HasColumnName("name"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("datetime(6)") + .HasColumnName("updated_at"); + + MySqlPropertyBuilderExtensions.UseMySqlComputedColumn(b.Property("UpdatedAt")); + + b.HasKey("Id"); + + b.ToTable("currency_types"); + }); + + modelBuilder.Entity("Turbo.Database.Entities.Catalog.LtdRaffleEntryEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("BatchId") + .IsRequired() + .HasMaxLength(36) + .HasColumnType("varchar(36)") + .HasColumnName("batch_id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime(6)") + .HasColumnName("created_at"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("CreatedAt")); + + b.Property("DeletedAt") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("datetime(6)") + .HasColumnName("deleted_at"); + + MySqlPropertyBuilderExtensions.UseMySqlComputedColumn(b.Property("DeletedAt")); + + b.Property("EnteredAt") + .HasColumnType("datetime(6)") + .HasColumnName("entered_at"); + + b.Property("PlayerEntityId") + .HasColumnType("int") + .HasColumnName("player_id"); + + b.Property("ProcessedAt") + .HasColumnType("datetime(6)") + .HasColumnName("processed_at"); + + b.Property("Result") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("varchar(20)") + .HasDefaultValue("pending") + .HasColumnName("result"); + + b.Property("SerialNumber") + .HasColumnType("int") + .HasColumnName("serial_number"); + + b.Property("SeriesEntityId") + .HasColumnType("int") + .HasColumnName("series_id"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("datetime(6)") + .HasColumnName("updated_at"); + + MySqlPropertyBuilderExtensions.UseMySqlComputedColumn(b.Property("UpdatedAt")); + + b.HasKey("Id"); + + b.HasIndex("PlayerEntityId"); + + b.HasIndex("SeriesEntityId"); + + b.ToTable("ltd_raffle_entries"); + }); + + modelBuilder.Entity("Turbo.Database.Entities.Catalog.LtdSeriesEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("CatalogProductEntityId") + .HasColumnType("int") + .HasColumnName("catalog_product_id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime(6)") + .HasColumnName("created_at"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("CreatedAt")); + + b.Property("DeletedAt") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("datetime(6)") + .HasColumnName("deleted_at"); + + MySqlPropertyBuilderExtensions.UseMySqlComputedColumn(b.Property("DeletedAt")); + + b.Property("EndsAt") + .HasColumnType("datetime(6)") + .HasColumnName("ends_at"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("tinyint(1)") + .HasDefaultValue(true) + .HasColumnName("is_active"); + + b.Property("IsRaffleFinished") + .ValueGeneratedOnAdd() + .HasColumnType("tinyint(1)") + .HasDefaultValue(false) + .HasColumnName("has_raffle_finished"); + + b.Property("RaffleWindowSeconds") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(30) + .HasColumnName("raffle_window_seconds"); + + b.Property("RemainingQuantity") + .HasColumnType("int") + .HasColumnName("remaining_quantity"); + + b.Property("StartsAt") + .HasColumnType("datetime(6)") + .HasColumnName("starts_at"); + + b.Property("TotalQuantity") + .HasColumnType("int") + .HasColumnName("total_quantity"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("datetime(6)") + .HasColumnName("updated_at"); + + MySqlPropertyBuilderExtensions.UseMySqlComputedColumn(b.Property("UpdatedAt")); + + b.HasKey("Id"); + + b.HasIndex("CatalogProductEntityId"); + + b.ToTable("ltd_series"); + }); + + modelBuilder.Entity("Turbo.Database.Entities.Furniture.FurnitureDefinitionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("CanGroup") + .ValueGeneratedOnAdd() + .HasColumnType("tinyint(1)") + .HasDefaultValue(true) + .HasColumnName("can_group"); + + b.Property("CanLay") + .ValueGeneratedOnAdd() + .HasColumnType("tinyint(1)") + .HasDefaultValue(false) + .HasColumnName("can_lay"); + + b.Property("CanRecycle") + .ValueGeneratedOnAdd() + .HasColumnType("tinyint(1)") + .HasDefaultValue(false) + .HasColumnName("can_recycle"); + + b.Property("CanSell") + .ValueGeneratedOnAdd() + .HasColumnType("tinyint(1)") + .HasDefaultValue(true) + .HasColumnName("can_sell"); + + b.Property("CanSit") + .ValueGeneratedOnAdd() + .HasColumnType("tinyint(1)") + .HasDefaultValue(false) + .HasColumnName("can_sit"); + + b.Property("CanStack") + .ValueGeneratedOnAdd() + .HasColumnType("tinyint(1)") + .HasDefaultValue(true) + .HasColumnName("can_stack"); + + b.Property("CanTrade") + .ValueGeneratedOnAdd() + .HasColumnType("tinyint(1)") + .HasDefaultValue(true) + .HasColumnName("can_trade"); + + b.Property("CanWalk") + .ValueGeneratedOnAdd() + .HasColumnType("tinyint(1)") + .HasDefaultValue(false) + .HasColumnName("can_walk"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime(6)") + .HasColumnName("created_at"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("CreatedAt")); + + b.Property("DeletedAt") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("datetime(6)") + .HasColumnName("deleted_at"); + + MySqlPropertyBuilderExtensions.UseMySqlComputedColumn(b.Property("DeletedAt")); + + b.Property("ExtraData") + .HasMaxLength(512) + .HasColumnType("varchar(512)") + .HasColumnName("extra_data"); + + b.Property("FurniCategory") + .HasColumnType("int") + .HasDefaultValue(1) + .HasColumnName("category"); + + b.Property("Length") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(1) + .HasColumnName("length"); + + b.Property("Logic") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(50) + .HasColumnType("varchar(50)") + .HasDefaultValue("none") + .HasColumnName("logic"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("varchar(512)") + .HasColumnName("name"); + + b.Property("ProductType") + .HasColumnType("int") + .HasDefaultValue(0) + .HasColumnName("type"); + + b.Property("SpriteId") + .HasColumnType("int") + .HasColumnName("sprite_id"); + + b.Property("StackHeight") + .ValueGeneratedOnAdd() + .HasColumnType("double(10,3)") + .HasDefaultValue(0.0) + .HasColumnName("stack_height"); + + b.Property("TotalStates") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0) + .HasColumnName("total_states"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("datetime(6)") + .HasColumnName("updated_at"); + + MySqlPropertyBuilderExtensions.UseMySqlComputedColumn(b.Property("UpdatedAt")); + + b.Property("UsagePolicy") + .HasColumnType("int") + .HasDefaultValue(1) + .HasColumnName("usage_policy"); + + b.Property("Width") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0) + .HasColumnName("width"); + + b.HasKey("Id"); + + b.HasIndex("SpriteId", "ProductType", "FurniCategory") + .IsUnique(); + + b.ToTable("furniture_definitions"); + }); + + modelBuilder.Entity("Turbo.Database.Entities.Furniture.FurnitureEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime(6)") + .HasColumnName("created_at"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("CreatedAt")); + + b.Property("DeletedAt") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("datetime(6)") + .HasColumnName("deleted_at"); + + MySqlPropertyBuilderExtensions.UseMySqlComputedColumn(b.Property("DeletedAt")); + + b.Property("ExtraData") + .HasMaxLength(512) + .HasColumnType("varchar(512)") + .HasColumnName("extra_data"); + + b.Property("FurnitureDefinitionEntityId") + .HasColumnType("int") + .HasColumnName("definition_id"); + + b.Property("PlayerEntityId") + .HasColumnType("int") + .HasColumnName("player_id"); + + b.Property("RoomEntityId") + .HasColumnType("int") + .HasColumnName("room_id"); + + b.Property("Rotation") + .HasColumnType("int") + .HasDefaultValue(0) + .HasColumnName("direction"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("datetime(6)") + .HasColumnName("updated_at"); + + MySqlPropertyBuilderExtensions.UseMySqlComputedColumn(b.Property("UpdatedAt")); + + b.Property("WallOffset") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0) + .HasColumnName("wall_offset"); + + b.Property("X") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0) + .HasColumnName("x"); + + b.Property("Y") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0) + .HasColumnName("y"); + + b.Property("Z") + .ValueGeneratedOnAdd() + .HasColumnType("double(10,3)") + .HasDefaultValue(0.0) + .HasColumnName("z"); + + b.HasKey("Id"); + + b.HasIndex("FurnitureDefinitionEntityId"); + + b.HasIndex("PlayerEntityId"); + + b.HasIndex("RoomEntityId"); + + b.ToTable("furniture"); + }); + + modelBuilder.Entity("Turbo.Database.Entities.Furniture.FurnitureTeleportLinkEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime(6)") + .HasColumnName("created_at"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("CreatedAt")); + + b.Property("DeletedAt") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("datetime(6)") + .HasColumnName("deleted_at"); + + MySqlPropertyBuilderExtensions.UseMySqlComputedColumn(b.Property("DeletedAt")); + + b.Property("FurnitureEntityOneId") + .HasColumnType("int") + .HasColumnName("furniture_one_id"); + + b.Property("FurnitureEntityTwoId") + .HasColumnType("int") + .HasColumnName("furniture_two_id"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("datetime(6)") + .HasColumnName("updated_at"); + + MySqlPropertyBuilderExtensions.UseMySqlComputedColumn(b.Property("UpdatedAt")); + + b.HasKey("Id"); + + b.HasIndex("FurnitureEntityOneId") + .IsUnique(); + + b.HasIndex("FurnitureEntityTwoId") + .IsUnique(); + + b.ToTable("furniture_teleport_links"); + }); + + modelBuilder.Entity("Turbo.Database.Entities.Messenger.MessengerCategoryEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime(6)") + .HasColumnName("created_at"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("CreatedAt")); + + b.Property("DeletedAt") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("datetime(6)") + .HasColumnName("deleted_at"); + + MySqlPropertyBuilderExtensions.UseMySqlComputedColumn(b.Property("DeletedAt")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("varchar(512)") + .HasColumnName("name"); + + b.Property("PlayerEntityId") + .HasColumnType("int") + .HasColumnName("player_id"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("datetime(6)") + .HasColumnName("updated_at"); + + MySqlPropertyBuilderExtensions.UseMySqlComputedColumn(b.Property("UpdatedAt")); + + b.HasKey("Id"); + + b.HasIndex("PlayerEntityId", "Name") + .IsUnique(); + + b.ToTable("messenger_categories"); + }); + + modelBuilder.Entity("Turbo.Database.Entities.Messenger.MessengerFriendEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime(6)") + .HasColumnName("created_at"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("CreatedAt")); + + b.Property("DeletedAt") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("datetime(6)") + .HasColumnName("deleted_at"); + + MySqlPropertyBuilderExtensions.UseMySqlComputedColumn(b.Property("DeletedAt")); + + b.Property("FriendPlayerEntityId") + .HasColumnType("int") + .HasColumnName("requested_id"); + + b.Property("MessengerCategoryEntityId") + .HasColumnType("int") + .HasColumnName("category_id"); + + b.Property("PlayerEntityId") + .HasColumnType("int") + .HasColumnName("player_id"); + + b.Property("RelationType") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0) + .HasColumnName("relation"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("datetime(6)") + .HasColumnName("updated_at"); + + MySqlPropertyBuilderExtensions.UseMySqlComputedColumn(b.Property("UpdatedAt")); + + b.HasKey("Id"); + + b.HasIndex("FriendPlayerEntityId"); + + b.HasIndex("MessengerCategoryEntityId"); + + b.HasIndex("PlayerEntityId", "FriendPlayerEntityId") + .IsUnique(); + + b.ToTable("messenger_friends"); + }); + + modelBuilder.Entity("Turbo.Database.Entities.Messenger.MessengerRequestEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime(6)") + .HasColumnName("created_at"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("CreatedAt")); + + b.Property("DeletedAt") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("datetime(6)") + .HasColumnName("deleted_at"); + + MySqlPropertyBuilderExtensions.UseMySqlComputedColumn(b.Property("DeletedAt")); + + b.Property("PlayerEntityId") + .HasColumnType("int") + .HasColumnName("player_id"); + + b.Property("RequestedPlayerEntityId") + .HasColumnType("int") + .HasColumnName("requested_id"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("datetime(6)") + .HasColumnName("updated_at"); + + MySqlPropertyBuilderExtensions.UseMySqlComputedColumn(b.Property("UpdatedAt")); + + b.HasKey("Id"); + + b.HasIndex("RequestedPlayerEntityId"); + + b.HasIndex("PlayerEntityId", "RequestedPlayerEntityId") + .IsUnique(); + + b.ToTable("messenger_requests"); + }); + + modelBuilder.Entity("Turbo.Database.Entities.Navigator.NavigatorEventCategoryEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime(6)") + .HasColumnName("created_at"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("CreatedAt")); + + b.Property("DeletedAt") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("datetime(6)") + .HasColumnName("deleted_at"); + + MySqlPropertyBuilderExtensions.UseMySqlComputedColumn(b.Property("DeletedAt")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("varchar(512)") + .HasColumnName("name"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("datetime(6)") + .HasColumnName("updated_at"); + + MySqlPropertyBuilderExtensions.UseMySqlComputedColumn(b.Property("UpdatedAt")); + + b.Property("Visible") + .HasColumnType("tinyint(1)") + .HasColumnName("visible"); + + b.HasKey("Id"); + + b.ToTable("navigator_eventcats"); + }); + + modelBuilder.Entity("Turbo.Database.Entities.Navigator.NavigatorFlatCategoryEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("Automatic") + .ValueGeneratedOnAdd() + .HasColumnType("tinyint(1)") + .HasDefaultValue(true) + .HasColumnName("automatic"); + + b.Property("AutomaticCategory") + .HasMaxLength(512) + .HasColumnType("varchar(512)") + .HasColumnName("automatic_category"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime(6)") + .HasColumnName("created_at"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("CreatedAt")); + + b.Property("DeletedAt") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("datetime(6)") + .HasColumnName("deleted_at"); + + MySqlPropertyBuilderExtensions.UseMySqlComputedColumn(b.Property("DeletedAt")); + + b.Property("GlobalCategory") + .HasMaxLength(512) + .HasColumnType("varchar(512)") + .HasColumnName("global_category"); + + b.Property("MinRank") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(1) + .HasColumnName("min_rank"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("varchar(512)") + .HasColumnName("name"); + + b.Property("OrderNum") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0) + .HasColumnName("order_num"); + + b.Property("StaffOnly") + .ValueGeneratedOnAdd() + .HasColumnType("tinyint(1)") + .HasDefaultValue(false) + .HasColumnName("staff_only"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("datetime(6)") + .HasColumnName("updated_at"); + + MySqlPropertyBuilderExtensions.UseMySqlComputedColumn(b.Property("UpdatedAt")); + + b.Property("Visible") + .ValueGeneratedOnAdd() + .HasColumnType("tinyint(1)") + .HasDefaultValue(true) + .HasColumnName("visible"); + + b.HasKey("Id"); + + b.ToTable("navigator_flatcats"); + }); + + modelBuilder.Entity("Turbo.Database.Entities.Navigator.NavigatorTopLevelContextEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime(6)") + .HasColumnName("created_at"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("CreatedAt")); + + b.Property("DeletedAt") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("datetime(6)") + .HasColumnName("deleted_at"); + + MySqlPropertyBuilderExtensions.UseMySqlComputedColumn(b.Property("DeletedAt")); + + b.Property("OrderNum") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0) + .HasColumnName("order_num"); + + b.Property("SearchCode") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("varchar(512)") + .HasColumnName("search_code"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("datetime(6)") + .HasColumnName("updated_at"); + + MySqlPropertyBuilderExtensions.UseMySqlComputedColumn(b.Property("UpdatedAt")); + + b.Property("Visible") + .ValueGeneratedOnAdd() + .HasColumnType("tinyint(1)") + .HasDefaultValue(true) + .HasColumnName("visible"); + + b.HasKey("Id"); + + b.HasIndex("SearchCode") + .IsUnique(); + + b.ToTable("navigator_top_level_contexts"); + }); + + modelBuilder.Entity("Turbo.Database.Entities.Players.PlayerBadgeEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("BadgeCode") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("varchar(512)") + .HasColumnName("badge_code"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime(6)") + .HasColumnName("created_at"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("CreatedAt")); + + b.Property("DeletedAt") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("datetime(6)") + .HasColumnName("deleted_at"); + + MySqlPropertyBuilderExtensions.UseMySqlComputedColumn(b.Property("DeletedAt")); + + b.Property("PlayerEntityId") + .HasColumnType("int") + .HasColumnName("player_id"); + + b.Property("SlotId") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0) + .HasColumnName("slot_id"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("datetime(6)") + .HasColumnName("updated_at"); + + MySqlPropertyBuilderExtensions.UseMySqlComputedColumn(b.Property("UpdatedAt")); + + b.HasKey("Id"); + + b.HasIndex("PlayerEntityId", "BadgeCode") + .IsUnique(); + + b.ToTable("player_badges"); + }); + + modelBuilder.Entity("Turbo.Database.Entities.Players.PlayerChatStyleEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("ClientStyleId") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0) + .HasColumnName("client_style_id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime(6)") + .HasColumnName("created_at"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("CreatedAt")); + + b.Property("DeletedAt") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("datetime(6)") + .HasColumnName("deleted_at"); + + MySqlPropertyBuilderExtensions.UseMySqlComputedColumn(b.Property("DeletedAt")); + + b.Property("UpdatedAt") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("datetime(6)") + .HasColumnName("updated_at"); + + MySqlPropertyBuilderExtensions.UseMySqlComputedColumn(b.Property("UpdatedAt")); + + b.HasKey("Id"); + + b.HasIndex("ClientStyleId") + .IsUnique(); + + b.ToTable("player_chat_styles"); + }); + + modelBuilder.Entity("Turbo.Database.Entities.Players.PlayerChatStyleOwnedEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("ChatStyleId") + .HasColumnType("int") + .HasColumnName("chat_style_id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime(6)") + .HasColumnName("created_at"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("CreatedAt")); + + b.Property("DeletedAt") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("datetime(6)") + .HasColumnName("deleted_at"); + + MySqlPropertyBuilderExtensions.UseMySqlComputedColumn(b.Property("DeletedAt")); + + b.Property("PlayerEntityId") + .HasColumnType("int") + .HasColumnName("player_id"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("datetime(6)") + .HasColumnName("updated_at"); + + MySqlPropertyBuilderExtensions.UseMySqlComputedColumn(b.Property("UpdatedAt")); + + b.HasKey("Id"); + + b.HasIndex("ChatStyleId"); + + b.HasIndex("PlayerEntityId", "ChatStyleId") + .IsUnique(); + + b.ToTable("player_chat_styles_owned"); + }); + + modelBuilder.Entity("Turbo.Database.Entities.Players.PlayerCurrencyEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("Amount") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0) + .HasColumnName("amount"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime(6)") + .HasColumnName("created_at"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("CreatedAt")); + + b.Property("CurrencyTypeEntityId") + .HasColumnType("int") + .HasColumnName("currency_type_id"); + + b.Property("DeletedAt") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("datetime(6)") + .HasColumnName("deleted_at"); + + MySqlPropertyBuilderExtensions.UseMySqlComputedColumn(b.Property("DeletedAt")); + + b.Property("PlayerEntityId") + .HasColumnType("int") + .HasColumnName("player_id"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("datetime(6)") + .HasColumnName("updated_at"); + + MySqlPropertyBuilderExtensions.UseMySqlComputedColumn(b.Property("UpdatedAt")); + + b.HasKey("Id"); + + b.HasIndex("CurrencyTypeEntityId"); + + b.HasIndex("PlayerEntityId", "CurrencyTypeEntityId") + .IsUnique(); + + b.ToTable("player_currencies"); + }); + + modelBuilder.Entity("Turbo.Database.Entities.Players.PlayerEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime(6)") + .HasColumnName("created_at"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("CreatedAt")); + + b.Property("DeletedAt") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("datetime(6)") + .HasColumnName("deleted_at"); + + MySqlPropertyBuilderExtensions.UseMySqlComputedColumn(b.Property("DeletedAt")); + + b.Property("Figure") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(100) + .HasColumnType("varchar(100)") + .HasDefaultValue("hr-115-42.hd-195-19.ch-3030-82.lg-275-1408.fa-1201.ca-1804-64") + .HasColumnName("figure"); + + b.Property("Gender") + .HasColumnType("int") + .HasDefaultValue(0) + .HasColumnName("gender"); + + b.Property("Motto") + .HasMaxLength(512) + .HasColumnType("varchar(512)") + .HasColumnName("motto"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("varchar(512)") + .HasColumnName("name"); + + b.Property("PlayerPerks") + .HasColumnType("int") + .HasDefaultValue(0) + .HasColumnName("perk_flags"); + + b.Property("PlayerStatus") + .HasColumnType("int") + .HasDefaultValue(0) + .HasColumnName("status"); + + b.Property("RoomChatStyleId") + .HasColumnType("int") + .HasColumnName("room_chat_style_id"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("datetime(6)") + .HasColumnName("updated_at"); + + MySqlPropertyBuilderExtensions.UseMySqlComputedColumn(b.Property("UpdatedAt")); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("players"); + }); + + modelBuilder.Entity("Turbo.Database.Entities.Players.PlayerFavoriteRoomsEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime(6)") + .HasColumnName("created_at"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("CreatedAt")); + + b.Property("DeletedAt") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("datetime(6)") + .HasColumnName("deleted_at"); + + MySqlPropertyBuilderExtensions.UseMySqlComputedColumn(b.Property("DeletedAt")); + + b.Property("PlayerEntityId") + .HasColumnType("int") + .HasColumnName("player_id"); + + b.Property("RoomEntityId") + .HasColumnType("int") + .HasColumnName("room_id"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("datetime(6)") + .HasColumnName("updated_at"); + + MySqlPropertyBuilderExtensions.UseMySqlComputedColumn(b.Property("UpdatedAt")); + + b.HasKey("Id"); + + b.HasIndex("RoomEntityId"); + + b.HasIndex("PlayerEntityId", "RoomEntityId") + .IsUnique(); + + b.ToTable("player_favorite_rooms"); + }); + + modelBuilder.Entity("Turbo.Database.Entities.Room.RoomBanEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime(6)") + .HasColumnName("created_at"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("CreatedAt")); + + b.Property("DateExpires") + .HasColumnType("datetime(6)") + .HasColumnName("date_expires"); + + b.Property("DeletedAt") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("datetime(6)") + .HasColumnName("deleted_at"); + + MySqlPropertyBuilderExtensions.UseMySqlComputedColumn(b.Property("DeletedAt")); + + b.Property("PlayerEntityId") + .HasColumnType("int") + .HasColumnName("player_id"); + + b.Property("RoomEntityId") + .HasColumnType("int") + .HasColumnName("room_id"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("datetime(6)") + .HasColumnName("updated_at"); + + MySqlPropertyBuilderExtensions.UseMySqlComputedColumn(b.Property("UpdatedAt")); + + b.HasKey("Id"); + + b.HasIndex("PlayerEntityId"); + + b.HasIndex("RoomEntityId", "PlayerEntityId") + .IsUnique(); + + b.ToTable("room_bans"); + }); + + modelBuilder.Entity("Turbo.Database.Entities.Room.RoomChatlogEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime(6)") + .HasColumnName("created_at"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("CreatedAt")); + + b.Property("DeletedAt") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("datetime(6)") + .HasColumnName("deleted_at"); + + MySqlPropertyBuilderExtensions.UseMySqlComputedColumn(b.Property("DeletedAt")); + + b.Property("Message") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)") + .HasColumnName("message"); + + b.Property("PlayerEntityId") + .HasColumnType("int") + .HasColumnName("player_id"); + + b.Property("RoomEntityId") + .HasColumnType("int") + .HasColumnName("room_id"); + + b.Property("TargetPlayerEntityId") + .HasColumnType("int") + .HasColumnName("target_player_id"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("datetime(6)") + .HasColumnName("updated_at"); + + MySqlPropertyBuilderExtensions.UseMySqlComputedColumn(b.Property("UpdatedAt")); + + b.HasKey("Id"); + + b.HasIndex("PlayerEntityId"); + + b.HasIndex("RoomEntityId"); + + b.HasIndex("TargetPlayerEntityId"); + + b.ToTable("room_chatlogs"); + }); + + modelBuilder.Entity("Turbo.Database.Entities.Room.RoomEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("AllowBlocking") + .ValueGeneratedOnAdd() + .HasColumnType("tinyint(1)") + .HasDefaultValue(false) + .HasColumnName("allow_blocking"); + + b.Property("AllowPets") + .ValueGeneratedOnAdd() + .HasColumnType("tinyint(1)") + .HasDefaultValue(false) + .HasColumnName("allow_pets"); + + b.Property("AllowPetsEat") + .ValueGeneratedOnAdd() + .HasColumnType("tinyint(1)") + .HasDefaultValue(false) + .HasColumnName("allow_pets_eat"); + + b.Property("BanType") + .HasColumnType("int") + .HasDefaultValue(0) + .HasColumnName("ban_type"); + + b.Property("ChatBubbleType") + .HasColumnType("int") + .HasDefaultValue(1) + .HasColumnName("chat_bubble_type"); + + b.Property("ChatDistance") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(50) + .HasColumnName("chat_distance"); + + b.Property("ChatFloodType") + .HasColumnType("int") + .HasDefaultValue(2) + .HasColumnName("chat_flood_type"); + + b.Property("ChatModeType") + .HasColumnType("int") + .HasDefaultValue(0) + .HasColumnName("chat_mode_type"); + + b.Property("ChatSpeedType") + .HasColumnType("int") + .HasDefaultValue(1) + .HasColumnName("chat_speed_type"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime(6)") + .HasColumnName("created_at"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("CreatedAt")); + + b.Property("DeletedAt") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("datetime(6)") + .HasColumnName("deleted_at"); + + MySqlPropertyBuilderExtensions.UseMySqlComputedColumn(b.Property("DeletedAt")); + + b.Property("Description") + .HasMaxLength(512) + .HasColumnType("varchar(512)") + .HasColumnName("description"); + + b.Property("DoorMode") + .HasColumnType("int") + .HasDefaultValue(0) + .HasColumnName("door_mode"); + + b.Property("HideWalls") + .ValueGeneratedOnAdd() + .HasColumnType("tinyint(1)") + .HasDefaultValue(false) + .HasColumnName("hide_walls"); + + b.Property("KickType") + .HasColumnType("int") + .HasDefaultValue(0) + .HasColumnName("kick_type"); + + b.Property("LastActive") + .ValueGeneratedOnAdd() + .HasColumnType("datetime(6)") + .HasColumnName("last_active"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("LastActive")); + + b.Property("MuteType") + .HasColumnType("int") + .HasDefaultValue(0) + .HasColumnName("mute_type"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("varchar(512)") + .HasColumnName("name"); + + b.Property("NavigatorCategoryEntityId") + .HasColumnType("int") + .HasColumnName("category_id"); + + b.Property("PaintFloor") + .ValueGeneratedOnAdd() + .HasColumnType("double") + .HasDefaultValue(0.0) + .HasColumnName("paint_floor"); + + b.Property("PaintLandscape") + .ValueGeneratedOnAdd() + .HasColumnType("double") + .HasDefaultValue(0.0) + .HasColumnName("paint_landscape"); + + b.Property("PaintWall") + .ValueGeneratedOnAdd() + .HasColumnType("double") + .HasDefaultValue(0.0) + .HasColumnName("paint_wall"); + + b.Property("Password") + .HasMaxLength(512) + .HasColumnType("varchar(512)") + .HasColumnName("password"); + + b.Property("PlayerEntityId") + .HasColumnType("int") + .HasColumnName("player_id"); + + b.Property("PlayersMax") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(25) + .HasColumnName("players_max"); + + b.Property("RoomModelEntityId") + .HasColumnType("int") + .HasColumnName("model_id"); + + b.Property("ThicknessFloor") + .HasColumnType("int") + .HasDefaultValue(0) + .HasColumnName("thickness_floor"); + + b.Property("ThicknessWall") + .HasColumnType("int") + .HasDefaultValue(0) + .HasColumnName("thickness_wall"); + + b.Property("TradeType") + .HasColumnType("int") + .HasDefaultValue(0) + .HasColumnName("trade_type"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("datetime(6)") + .HasColumnName("updated_at"); + + MySqlPropertyBuilderExtensions.UseMySqlComputedColumn(b.Property("UpdatedAt")); + + b.Property("UsersNow") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0) + .HasColumnName("users_now"); + + b.Property("WallHeight") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(-1) + .HasColumnName("wall_height"); + + b.HasKey("Id"); + + b.HasIndex("NavigatorCategoryEntityId"); + + b.HasIndex("PlayerEntityId"); + + b.HasIndex("RoomModelEntityId"); + + b.ToTable("rooms"); + }); + + modelBuilder.Entity("Turbo.Database.Entities.Room.RoomEntryLogEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime(6)") + .HasColumnName("created_at"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("CreatedAt")); + + b.Property("DeletedAt") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("datetime(6)") + .HasColumnName("deleted_at"); + + MySqlPropertyBuilderExtensions.UseMySqlComputedColumn(b.Property("DeletedAt")); + + b.Property("PlayerEntityId") + .HasColumnType("int") + .HasColumnName("player_id"); + + b.Property("RoomEntityId") + .HasColumnType("int") + .HasColumnName("room_id"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("datetime(6)") + .HasColumnName("updated_at"); + + MySqlPropertyBuilderExtensions.UseMySqlComputedColumn(b.Property("UpdatedAt")); + + b.HasKey("Id"); + + b.HasIndex("PlayerEntityId"); + + b.HasIndex("RoomEntityId"); + + b.ToTable("room_entry_logs"); + }); + + modelBuilder.Entity("Turbo.Database.Entities.Room.RoomModelEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime(6)") + .HasColumnName("created_at"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("CreatedAt")); + + b.Property("Custom") + .ValueGeneratedOnAdd() + .HasColumnType("tinyint(1)") + .HasDefaultValue(false) + .HasColumnName("custom"); + + b.Property("DeletedAt") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("datetime(6)") + .HasColumnName("deleted_at"); + + MySqlPropertyBuilderExtensions.UseMySqlComputedColumn(b.Property("DeletedAt")); + + b.Property("DoorRotation") + .HasColumnType("int") + .HasDefaultValue(0) + .HasColumnName("door_rotation"); + + b.Property("DoorX") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0) + .HasColumnName("door_x"); + + b.Property("DoorY") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0) + .HasColumnName("door_y"); + + b.Property("Enabled") + .ValueGeneratedOnAdd() + .HasColumnType("tinyint(1)") + .HasDefaultValue(true) + .HasColumnName("enabled"); + + b.Property("Model") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("longtext") + .HasColumnName("model"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("varchar(512)") + .HasColumnName("name"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("datetime(6)") + .HasColumnName("updated_at"); + + MySqlPropertyBuilderExtensions.UseMySqlComputedColumn(b.Property("UpdatedAt")); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("room_models"); + }); + + modelBuilder.Entity("Turbo.Database.Entities.Room.RoomMuteEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime(6)") + .HasColumnName("created_at"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("CreatedAt")); + + b.Property("DateExpires") + .HasColumnType("datetime(6)") + .HasColumnName("date_expires"); + + b.Property("DeletedAt") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("datetime(6)") + .HasColumnName("deleted_at"); + + MySqlPropertyBuilderExtensions.UseMySqlComputedColumn(b.Property("DeletedAt")); + + b.Property("PlayerEntityId") + .HasColumnType("int") + .HasColumnName("player_id"); + + b.Property("RoomEntityId") + .HasColumnType("int") + .HasColumnName("room_id"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("datetime(6)") + .HasColumnName("updated_at"); + + MySqlPropertyBuilderExtensions.UseMySqlComputedColumn(b.Property("UpdatedAt")); + + b.HasKey("Id"); + + b.HasIndex("PlayerEntityId"); + + b.HasIndex("RoomEntityId", "PlayerEntityId") + .IsUnique(); + + b.ToTable("room_mutes"); + }); + + modelBuilder.Entity("Turbo.Database.Entities.Room.RoomRightEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime(6)") + .HasColumnName("created_at"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("CreatedAt")); + + b.Property("DeletedAt") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("datetime(6)") + .HasColumnName("deleted_at"); + + MySqlPropertyBuilderExtensions.UseMySqlComputedColumn(b.Property("DeletedAt")); + + b.Property("PlayerEntityId") + .HasColumnType("int") + .HasColumnName("player_id"); + + b.Property("RoomEntityId") + .HasColumnType("int") + .HasColumnName("room_id"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("datetime(6)") + .HasColumnName("updated_at"); + + MySqlPropertyBuilderExtensions.UseMySqlComputedColumn(b.Property("UpdatedAt")); + + b.HasKey("Id"); + + b.HasIndex("PlayerEntityId"); + + b.HasIndex("RoomEntityId", "PlayerEntityId") + .IsUnique(); + + b.ToTable("room_rights"); + }); + + modelBuilder.Entity("Turbo.Database.Entities.Security.SecurityTicketEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime(6)") + .HasColumnName("created_at"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("CreatedAt")); + + b.Property("DeletedAt") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("datetime(6)") + .HasColumnName("deleted_at"); + + MySqlPropertyBuilderExtensions.UseMySqlComputedColumn(b.Property("DeletedAt")); + + b.Property("IpAddress") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("varchar(512)") + .HasColumnName("ip_address"); + + b.Property("IsLocked") + .ValueGeneratedOnAdd() + .HasColumnType("tinyint(1)") + .HasDefaultValue(false) + .HasColumnName("is_locked"); + + b.Property("PlayerEntityId") + .HasColumnType("int") + .HasColumnName("player_id"); + + b.Property("Ticket") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("varchar(512)") + .HasColumnName("ticket"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("datetime(6)") + .HasColumnName("updated_at"); + + MySqlPropertyBuilderExtensions.UseMySqlComputedColumn(b.Property("UpdatedAt")); + + b.HasKey("Id"); + + b.HasIndex("PlayerEntityId") + .IsUnique(); + + b.HasIndex("Ticket") + .IsUnique(); + + b.ToTable("security_tickets"); + }); + + modelBuilder.Entity("Turbo.Database.Entities.Tracking.PerformanceLogEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("AverageFrameRate") + .HasColumnType("int") + .HasColumnName("average_frame_rate"); + + b.Property("Browser") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("varchar(512)") + .HasColumnName("browser"); + + b.Property("ElapsedTime") + .HasColumnType("int") + .HasColumnName("elapsed_time"); + + b.Property("FlashVersion") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("varchar(512)") + .HasColumnName("flash_version"); + + b.Property("GarbageCollections") + .HasColumnType("int") + .HasColumnName("garbage_collections"); + + b.Property("IPAddress") + .IsRequired() + .HasMaxLength(45) + .HasColumnType("varchar(45)") + .HasColumnName("ip_address"); + + b.Property("IsDebugger") + .HasColumnType("tinyint(1)") + .HasColumnName("is_debugger"); + + b.Property("MemoryUsage") + .HasColumnType("int") + .HasColumnName("memory_usage"); + + b.Property("OS") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("varchar(512)") + .HasColumnName("os"); + + b.Property("UserAgent") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("varchar(512)") + .HasColumnName("user_agent"); + + b.HasKey("Id"); + + b.HasIndex("ElapsedTime"); + + b.HasIndex("IPAddress"); + + b.ToTable("performance_logs"); + }); + + modelBuilder.Entity("Turbo.Database.Entities.Catalog.CatalogOfferEntity", b => + { + b.HasOne("Turbo.Database.Entities.Catalog.CatalogPageEntity", "Page") + .WithMany("Offers") + .HasForeignKey("CatalogPageEntityId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Turbo.Database.Entities.Catalog.CurrencyTypeEntity", "CurrencyTypeEntity") + .WithMany("CatalogOffers") + .HasForeignKey("CurrencyTypeId"); + + b.Navigation("CurrencyTypeEntity"); + + b.Navigation("Page"); + }); + + modelBuilder.Entity("Turbo.Database.Entities.Catalog.CatalogPageEntity", b => + { + b.HasOne("Turbo.Database.Entities.Catalog.CatalogPageEntity", "ParentEntity") + .WithMany("Children") + .HasForeignKey("ParentEntityId"); + + b.Navigation("ParentEntity"); + }); + + modelBuilder.Entity("Turbo.Database.Entities.Catalog.CatalogProductEntity", b => + { + b.HasOne("Turbo.Database.Entities.Catalog.CatalogOfferEntity", "Offer") + .WithMany("Products") + .HasForeignKey("CatalogOfferEntityId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Turbo.Database.Entities.Furniture.FurnitureDefinitionEntity", "FurnitureDefinition") + .WithMany() + .HasForeignKey("FurnitureDefinitionEntityId"); + + b.Navigation("FurnitureDefinition"); + + b.Navigation("Offer"); + }); + + modelBuilder.Entity("Turbo.Database.Entities.Catalog.LtdRaffleEntryEntity", b => + { + b.HasOne("Turbo.Database.Entities.Players.PlayerEntity", "Player") + .WithMany() + .HasForeignKey("PlayerEntityId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Turbo.Database.Entities.Catalog.LtdSeriesEntity", "Series") + .WithMany() + .HasForeignKey("SeriesEntityId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Player"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("Turbo.Database.Entities.Catalog.LtdSeriesEntity", b => + { + b.HasOne("Turbo.Database.Entities.Catalog.CatalogProductEntity", "CatalogProduct") + .WithMany() + .HasForeignKey("CatalogProductEntityId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CatalogProduct"); + }); + + modelBuilder.Entity("Turbo.Database.Entities.Furniture.FurnitureEntity", b => + { + b.HasOne("Turbo.Database.Entities.Furniture.FurnitureDefinitionEntity", "FurnitureDefinitionEntity") + .WithMany("Furnitures") + .HasForeignKey("FurnitureDefinitionEntityId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Turbo.Database.Entities.Players.PlayerEntity", "PlayerEntity") + .WithMany("Furniture") + .HasForeignKey("PlayerEntityId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Turbo.Database.Entities.Room.RoomEntity", "RoomEntity") + .WithMany() + .HasForeignKey("RoomEntityId"); + + b.Navigation("FurnitureDefinitionEntity"); + + b.Navigation("PlayerEntity"); + + b.Navigation("RoomEntity"); + }); + + modelBuilder.Entity("Turbo.Database.Entities.Furniture.FurnitureTeleportLinkEntity", b => + { + b.HasOne("Turbo.Database.Entities.Furniture.FurnitureEntity", "FurnitureEntityOne") + .WithMany() + .HasForeignKey("FurnitureEntityOneId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Turbo.Database.Entities.Furniture.FurnitureEntity", "FurnitureEntityTwo") + .WithMany() + .HasForeignKey("FurnitureEntityTwoId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("FurnitureEntityOne"); + + b.Navigation("FurnitureEntityTwo"); + }); + + modelBuilder.Entity("Turbo.Database.Entities.Messenger.MessengerCategoryEntity", b => + { + b.HasOne("Turbo.Database.Entities.Players.PlayerEntity", "PlayerEntity") + .WithMany("MessengerCategories") + .HasForeignKey("PlayerEntityId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("PlayerEntity"); + }); + + modelBuilder.Entity("Turbo.Database.Entities.Messenger.MessengerFriendEntity", b => + { + b.HasOne("Turbo.Database.Entities.Players.PlayerEntity", "FriendPlayerEntity") + .WithMany() + .HasForeignKey("FriendPlayerEntityId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Turbo.Database.Entities.Messenger.MessengerCategoryEntity", "MessengerCategoryEntity") + .WithMany() + .HasForeignKey("MessengerCategoryEntityId"); + + b.HasOne("Turbo.Database.Entities.Players.PlayerEntity", "PlayerEntity") + .WithMany("MessengerFriends") + .HasForeignKey("PlayerEntityId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("FriendPlayerEntity"); + + b.Navigation("MessengerCategoryEntity"); + + b.Navigation("PlayerEntity"); + }); + + modelBuilder.Entity("Turbo.Database.Entities.Messenger.MessengerRequestEntity", b => + { + b.HasOne("Turbo.Database.Entities.Players.PlayerEntity", "PlayerEntity") + .WithMany("MessengerRequestsSent") + .HasForeignKey("PlayerEntityId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Turbo.Database.Entities.Players.PlayerEntity", "RequestedPlayerEntity") + .WithMany("MessengerRequests") + .HasForeignKey("RequestedPlayerEntityId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("PlayerEntity"); + + b.Navigation("RequestedPlayerEntity"); + }); + + modelBuilder.Entity("Turbo.Database.Entities.Players.PlayerBadgeEntity", b => + { + b.HasOne("Turbo.Database.Entities.Players.PlayerEntity", "PlayerEntity") + .WithMany("PlayerBadges") + .HasForeignKey("PlayerEntityId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("PlayerEntity"); + }); + + modelBuilder.Entity("Turbo.Database.Entities.Players.PlayerChatStyleOwnedEntity", b => + { + b.HasOne("Turbo.Database.Entities.Players.PlayerChatStyleEntity", "ChatStyle") + .WithMany("OwnedChatStyles") + .HasForeignKey("ChatStyleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Turbo.Database.Entities.Players.PlayerEntity", "PlayerEntity") + .WithMany("PlayerOwnedChatStyles") + .HasForeignKey("PlayerEntityId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ChatStyle"); + + b.Navigation("PlayerEntity"); + }); + + modelBuilder.Entity("Turbo.Database.Entities.Players.PlayerCurrencyEntity", b => + { + b.HasOne("Turbo.Database.Entities.Catalog.CurrencyTypeEntity", "CurrencyTypeEntity") + .WithMany("PlayerCurrencies") + .HasForeignKey("CurrencyTypeEntityId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Turbo.Database.Entities.Players.PlayerEntity", "PlayerEntity") + .WithMany("PlayerCurrencies") + .HasForeignKey("PlayerEntityId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CurrencyTypeEntity"); + + b.Navigation("PlayerEntity"); + }); + + modelBuilder.Entity("Turbo.Database.Entities.Players.PlayerFavoriteRoomsEntity", b => + { + b.HasOne("Turbo.Database.Entities.Players.PlayerEntity", "PlayerEntity") + .WithMany() + .HasForeignKey("PlayerEntityId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Turbo.Database.Entities.Room.RoomEntity", "RoomEntity") + .WithMany() + .HasForeignKey("RoomEntityId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("PlayerEntity"); + + b.Navigation("RoomEntity"); + }); + + modelBuilder.Entity("Turbo.Database.Entities.Room.RoomBanEntity", b => + { + b.HasOne("Turbo.Database.Entities.Players.PlayerEntity", "PlayerEntity") + .WithMany("RoomBans") + .HasForeignKey("PlayerEntityId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Turbo.Database.Entities.Room.RoomEntity", "RoomEntity") + .WithMany("RoomBans") + .HasForeignKey("RoomEntityId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("PlayerEntity"); + + b.Navigation("RoomEntity"); + }); + + modelBuilder.Entity("Turbo.Database.Entities.Room.RoomChatlogEntity", b => + { + b.HasOne("Turbo.Database.Entities.Players.PlayerEntity", "PlayerEntity") + .WithMany("RoomChatlogs") + .HasForeignKey("PlayerEntityId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Turbo.Database.Entities.Room.RoomEntity", "RoomEntity") + .WithMany("RoomChats") + .HasForeignKey("RoomEntityId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Turbo.Database.Entities.Players.PlayerEntity", "TargetPlayerEntity") + .WithMany() + .HasForeignKey("TargetPlayerEntityId"); + + b.Navigation("PlayerEntity"); + + b.Navigation("RoomEntity"); + + b.Navigation("TargetPlayerEntity"); + }); + + modelBuilder.Entity("Turbo.Database.Entities.Room.RoomEntity", b => + { + b.HasOne("Turbo.Database.Entities.Navigator.NavigatorFlatCategoryEntity", "NavigatorFlatCategoryEntity") + .WithMany() + .HasForeignKey("NavigatorCategoryEntityId"); + + b.HasOne("Turbo.Database.Entities.Players.PlayerEntity", "PlayerEntity") + .WithMany("Rooms") + .HasForeignKey("PlayerEntityId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Turbo.Database.Entities.Room.RoomModelEntity", "RoomModelEntity") + .WithMany() + .HasForeignKey("RoomModelEntityId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("NavigatorFlatCategoryEntity"); + + b.Navigation("PlayerEntity"); + + b.Navigation("RoomModelEntity"); + }); + + modelBuilder.Entity("Turbo.Database.Entities.Room.RoomEntryLogEntity", b => + { + b.HasOne("Turbo.Database.Entities.Players.PlayerEntity", "PlayerEntity") + .WithMany() + .HasForeignKey("PlayerEntityId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Turbo.Database.Entities.Room.RoomEntity", "RoomEntity") + .WithMany() + .HasForeignKey("RoomEntityId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("PlayerEntity"); + + b.Navigation("RoomEntity"); + }); + + modelBuilder.Entity("Turbo.Database.Entities.Room.RoomMuteEntity", b => + { + b.HasOne("Turbo.Database.Entities.Players.PlayerEntity", "PlayerEntity") + .WithMany("RoomMutes") + .HasForeignKey("PlayerEntityId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Turbo.Database.Entities.Room.RoomEntity", "RoomEntity") + .WithMany("RoomMutes") + .HasForeignKey("RoomEntityId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("PlayerEntity"); + + b.Navigation("RoomEntity"); + }); + + modelBuilder.Entity("Turbo.Database.Entities.Room.RoomRightEntity", b => + { + b.HasOne("Turbo.Database.Entities.Players.PlayerEntity", "PlayerEntity") + .WithMany("RoomRights") + .HasForeignKey("PlayerEntityId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Turbo.Database.Entities.Room.RoomEntity", "RoomEntity") + .WithMany("RoomRights") + .HasForeignKey("RoomEntityId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("PlayerEntity"); + + b.Navigation("RoomEntity"); + }); + + modelBuilder.Entity("Turbo.Database.Entities.Security.SecurityTicketEntity", b => + { + b.HasOne("Turbo.Database.Entities.Players.PlayerEntity", "PlayerEntity") + .WithMany("SecurityTickets") + .HasForeignKey("PlayerEntityId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("PlayerEntity"); + }); + + modelBuilder.Entity("Turbo.Database.Entities.Catalog.CatalogOfferEntity", b => + { + b.Navigation("Products"); + }); + + modelBuilder.Entity("Turbo.Database.Entities.Catalog.CatalogPageEntity", b => + { + b.Navigation("Children"); + + b.Navigation("Offers"); + }); + + modelBuilder.Entity("Turbo.Database.Entities.Catalog.CurrencyTypeEntity", b => + { + b.Navigation("CatalogOffers"); + + b.Navigation("PlayerCurrencies"); + }); + + modelBuilder.Entity("Turbo.Database.Entities.Furniture.FurnitureDefinitionEntity", b => + { + b.Navigation("Furnitures"); + }); + + modelBuilder.Entity("Turbo.Database.Entities.Players.PlayerChatStyleEntity", b => + { + b.Navigation("OwnedChatStyles"); + }); + + modelBuilder.Entity("Turbo.Database.Entities.Players.PlayerEntity", b => + { + b.Navigation("Furniture"); + + b.Navigation("MessengerCategories"); + + b.Navigation("MessengerFriends"); + + b.Navigation("MessengerRequests"); + + b.Navigation("MessengerRequestsSent"); + + b.Navigation("PlayerBadges"); + + b.Navigation("PlayerCurrencies"); + + b.Navigation("PlayerOwnedChatStyles"); + + b.Navigation("RoomBans"); + + b.Navigation("RoomChatlogs"); + + b.Navigation("RoomMutes"); + + b.Navigation("RoomRights"); + + b.Navigation("Rooms"); + + b.Navigation("SecurityTickets"); + }); + + modelBuilder.Entity("Turbo.Database.Entities.Room.RoomEntity", b => + { + b.Navigation("RoomBans"); + + b.Navigation("RoomChats"); + + b.Navigation("RoomMutes"); + + b.Navigation("RoomRights"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Turbo.Database/Migrations/20260208201115_AddLtdRaffleSystem.cs b/Turbo.Database/Migrations/20260208201115_AddLtdRaffleSystem.cs new file mode 100644 index 00000000..5c94ca99 --- /dev/null +++ b/Turbo.Database/Migrations/20260208201115_AddLtdRaffleSystem.cs @@ -0,0 +1,193 @@ +using System; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Turbo.Database.Migrations +{ + /// + public partial class AddLtdRaffleSystem : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn(name: "unique_remaining", table: "catalog_products"); + + migrationBuilder.DropColumn(name: "unique_size", table: "catalog_products"); + + migrationBuilder + .CreateTable( + name: "ltd_series", + columns: table => new + { + id = table + .Column(type: "int", nullable: false) + .Annotation( + "MySql:ValueGenerationStrategy", + MySqlValueGenerationStrategy.IdentityColumn + ), + catalog_product_id = table.Column(type: "int", nullable: false), + total_quantity = table.Column(type: "int", nullable: false), + remaining_quantity = table.Column(type: "int", nullable: false), + raffle_window_seconds = table.Column( + type: "int", + nullable: false, + defaultValue: 30 + ), + is_active = table.Column( + type: "tinyint(1)", + nullable: false, + defaultValue: true + ), + has_raffle_finished = table.Column( + type: "tinyint(1)", + nullable: false, + defaultValue: false + ), + starts_at = table.Column(type: "datetime(6)", nullable: true), + ends_at = table.Column(type: "datetime(6)", nullable: true), + created_at = table + .Column(type: "datetime(6)", nullable: false) + .Annotation( + "MySql:ValueGenerationStrategy", + MySqlValueGenerationStrategy.IdentityColumn + ), + updated_at = table + .Column(type: "datetime(6)", nullable: false) + .Annotation( + "MySql:ValueGenerationStrategy", + MySqlValueGenerationStrategy.ComputedColumn + ), + deleted_at = table + .Column(type: "datetime(6)", nullable: true) + .Annotation( + "MySql:ValueGenerationStrategy", + MySqlValueGenerationStrategy.ComputedColumn + ), + }, + constraints: table => + { + table.PrimaryKey("PK_ltd_series", x => x.id); + table.ForeignKey( + name: "FK_ltd_series_catalog_products_catalog_product_id", + column: x => x.catalog_product_id, + principalTable: "catalog_products", + principalColumn: "id", + onDelete: ReferentialAction.Cascade + ); + } + ) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder + .CreateTable( + name: "ltd_raffle_entries", + columns: table => new + { + id = table + .Column(type: "int", nullable: false) + .Annotation( + "MySql:ValueGenerationStrategy", + MySqlValueGenerationStrategy.IdentityColumn + ), + series_id = table.Column(type: "int", nullable: false), + player_id = table.Column(type: "int", nullable: false), + batch_id = table + .Column(type: "varchar(36)", maxLength: 36, nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + entered_at = table.Column(type: "datetime(6)", nullable: false), + result = table + .Column( + type: "varchar(20)", + maxLength: 20, + nullable: false, + defaultValue: "pending" + ) + .Annotation("MySql:CharSet", "utf8mb4"), + serial_number = table.Column(type: "int", nullable: true), + processed_at = table.Column(type: "datetime(6)", nullable: true), + created_at = table + .Column(type: "datetime(6)", nullable: false) + .Annotation( + "MySql:ValueGenerationStrategy", + MySqlValueGenerationStrategy.IdentityColumn + ), + updated_at = table + .Column(type: "datetime(6)", nullable: false) + .Annotation( + "MySql:ValueGenerationStrategy", + MySqlValueGenerationStrategy.ComputedColumn + ), + deleted_at = table + .Column(type: "datetime(6)", nullable: true) + .Annotation( + "MySql:ValueGenerationStrategy", + MySqlValueGenerationStrategy.ComputedColumn + ), + }, + constraints: table => + { + table.PrimaryKey("PK_ltd_raffle_entries", x => x.id); + table.ForeignKey( + name: "FK_ltd_raffle_entries_ltd_series_series_id", + column: x => x.series_id, + principalTable: "ltd_series", + principalColumn: "id", + onDelete: ReferentialAction.Cascade + ); + table.ForeignKey( + name: "FK_ltd_raffle_entries_players_player_id", + column: x => x.player_id, + principalTable: "players", + principalColumn: "id", + onDelete: ReferentialAction.Cascade + ); + } + ) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateIndex( + name: "IX_ltd_raffle_entries_player_id", + table: "ltd_raffle_entries", + column: "player_id" + ); + + migrationBuilder.CreateIndex( + name: "IX_ltd_raffle_entries_series_id", + table: "ltd_raffle_entries", + column: "series_id" + ); + + migrationBuilder.CreateIndex( + name: "IX_ltd_series_catalog_product_id", + table: "ltd_series", + column: "catalog_product_id" + ); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable(name: "ltd_raffle_entries"); + + migrationBuilder.DropTable(name: "ltd_series"); + + migrationBuilder.AddColumn( + name: "unique_remaining", + table: "catalog_products", + type: "int", + nullable: false, + defaultValue: 0 + ); + + migrationBuilder.AddColumn( + name: "unique_size", + table: "catalog_products", + type: "int", + nullable: false, + defaultValue: 0 + ); + } + } +} diff --git a/Turbo.Database/Migrations/TurboDbContextModelSnapshot.cs b/Turbo.Database/Migrations/TurboDbContextModelSnapshot.cs index 2f57c564..8a00bf1d 100644 --- a/Turbo.Database/Migrations/TurboDbContextModelSnapshot.cs +++ b/Turbo.Database/Migrations/TurboDbContextModelSnapshot.cs @@ -245,18 +245,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasDefaultValue(1) .HasColumnName("quantity"); - b.Property("UniqueRemaining") - .ValueGeneratedOnAdd() - .HasColumnType("int") - .HasDefaultValue(0) - .HasColumnName("unique_remaining"); - - b.Property("UniqueSize") - .ValueGeneratedOnAdd() - .HasColumnType("int") - .HasDefaultValue(0) - .HasColumnName("unique_size"); - b.Property("UpdatedAt") .ValueGeneratedOnAddOrUpdate() .HasColumnType("datetime(6)") @@ -327,6 +315,154 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("currency_types"); }); + modelBuilder.Entity("Turbo.Database.Entities.Catalog.LtdRaffleEntryEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("BatchId") + .IsRequired() + .HasMaxLength(36) + .HasColumnType("varchar(36)") + .HasColumnName("batch_id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime(6)") + .HasColumnName("created_at"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("CreatedAt")); + + b.Property("DeletedAt") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("datetime(6)") + .HasColumnName("deleted_at"); + + MySqlPropertyBuilderExtensions.UseMySqlComputedColumn(b.Property("DeletedAt")); + + b.Property("EnteredAt") + .HasColumnType("datetime(6)") + .HasColumnName("entered_at"); + + b.Property("PlayerEntityId") + .HasColumnType("int") + .HasColumnName("player_id"); + + b.Property("ProcessedAt") + .HasColumnType("datetime(6)") + .HasColumnName("processed_at"); + + b.Property("Result") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("varchar(20)") + .HasDefaultValue("pending") + .HasColumnName("result"); + + b.Property("SerialNumber") + .HasColumnType("int") + .HasColumnName("serial_number"); + + b.Property("SeriesEntityId") + .HasColumnType("int") + .HasColumnName("series_id"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("datetime(6)") + .HasColumnName("updated_at"); + + MySqlPropertyBuilderExtensions.UseMySqlComputedColumn(b.Property("UpdatedAt")); + + b.HasKey("Id"); + + b.HasIndex("PlayerEntityId"); + + b.HasIndex("SeriesEntityId"); + + b.ToTable("ltd_raffle_entries"); + }); + + modelBuilder.Entity("Turbo.Database.Entities.Catalog.LtdSeriesEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("CatalogProductEntityId") + .HasColumnType("int") + .HasColumnName("catalog_product_id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime(6)") + .HasColumnName("created_at"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("CreatedAt")); + + b.Property("DeletedAt") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("datetime(6)") + .HasColumnName("deleted_at"); + + MySqlPropertyBuilderExtensions.UseMySqlComputedColumn(b.Property("DeletedAt")); + + b.Property("EndsAt") + .HasColumnType("datetime(6)") + .HasColumnName("ends_at"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("tinyint(1)") + .HasDefaultValue(true) + .HasColumnName("is_active"); + + b.Property("IsRaffleFinished") + .ValueGeneratedOnAdd() + .HasColumnType("tinyint(1)") + .HasDefaultValue(false) + .HasColumnName("has_raffle_finished"); + + b.Property("RaffleWindowSeconds") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(30) + .HasColumnName("raffle_window_seconds"); + + b.Property("RemainingQuantity") + .HasColumnType("int") + .HasColumnName("remaining_quantity"); + + b.Property("StartsAt") + .HasColumnType("datetime(6)") + .HasColumnName("starts_at"); + + b.Property("TotalQuantity") + .HasColumnType("int") + .HasColumnName("total_quantity"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("datetime(6)") + .HasColumnName("updated_at"); + + MySqlPropertyBuilderExtensions.UseMySqlComputedColumn(b.Property("UpdatedAt")); + + b.HasKey("Id"); + + b.HasIndex("CatalogProductEntityId"); + + b.ToTable("ltd_series"); + }); + modelBuilder.Entity("Turbo.Database.Entities.Furniture.FurnitureDefinitionEntity", b => { b.Property("Id") @@ -2138,6 +2274,36 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("Offer"); }); + modelBuilder.Entity("Turbo.Database.Entities.Catalog.LtdRaffleEntryEntity", b => + { + b.HasOne("Turbo.Database.Entities.Players.PlayerEntity", "Player") + .WithMany() + .HasForeignKey("PlayerEntityId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Turbo.Database.Entities.Catalog.LtdSeriesEntity", "Series") + .WithMany() + .HasForeignKey("SeriesEntityId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Player"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("Turbo.Database.Entities.Catalog.LtdSeriesEntity", b => + { + b.HasOne("Turbo.Database.Entities.Catalog.CatalogProductEntity", "CatalogProduct") + .WithMany() + .HasForeignKey("CatalogProductEntityId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CatalogProduct"); + }); + modelBuilder.Entity("Turbo.Database.Entities.Furniture.FurnitureEntity", b => { b.HasOne("Turbo.Database.Entities.Furniture.FurnitureDefinitionEntity", "FurnitureDefinitionEntity") diff --git a/Turbo.Inventory/Grains/InventoryGrain.Furni.cs b/Turbo.Inventory/Grains/InventoryGrain.Furni.cs index 44179458..87cc7a1d 100644 --- a/Turbo.Inventory/Grains/InventoryGrain.Furni.cs +++ b/Turbo.Inventory/Grains/InventoryGrain.Furni.cs @@ -1,6 +1,8 @@ using System; using System.Collections.Generic; using System.Collections.Immutable; +using System.Linq; +using System.Text.Json; using System.Threading; using System.Threading.Tasks; using Orleans; @@ -9,6 +11,7 @@ using Turbo.Inventory.Furniture; using Turbo.Logging; using Turbo.Primitives; +using Turbo.Primitives.Catalog.Enums; using Turbo.Primitives.Catalog.Snapshots; using Turbo.Primitives.Furniture.Enums; using Turbo.Primitives.Furniture.StuffData; @@ -143,4 +146,77 @@ CancellationToken ct public Task> GetAllItemSnapshotsAsync( CancellationToken ct ) => _furniModule.GetAllItemSnapshotsAsync(ct); + + public async Task GrantLtdFurnitureAsync( + int catalogProductId, + int serialNumber, + int seriesSize, + CancellationToken ct + ) + { + // Find the product in the catalog snapshot + var snapshot = _catalogService.GetCatalogSnapshot(CatalogType.Normal); + var product = snapshot.ProductsById.Values.FirstOrDefault(p => p.Id == catalogProductId); + + if (product == null) + throw new TurboException(TurboErrorCodeEnum.CatalogProductNotFound); + + var def = + _furnitureDefinitionProvider.TryGetDefinition(product.FurniDefinitionId) + ?? throw new TurboException(TurboErrorCodeEnum.FurnitureDefinitionNotFound); + + // Build ExtraData JSON with LTD serial info in the stuff section + var extraDataJson = JsonSerializer.Serialize( + new + { + stuff = new + { + UniqueNumber = serialNumber, + UniqueSeries = seriesSize, + Data = "0", + }, + } + ); + + var dbCtx = await _dbCtxFactory.CreateDbContextAsync(ct).ConfigureAwait(false); + + try + { + // Create furniture entity - LTD data is stored in ExtraData JSON + var entity = new FurnitureEntity + { + PlayerEntityId = (int)this.GetPrimaryKeyLong(), + FurnitureDefinitionEntityId = def.Id, + ExtraData = extraDataJson, + }; + + dbCtx.Add(entity); + await dbCtx.SaveChangesAsync(ct); + + // Create stuff data from the ExtraData - this properly loads the UniqueNumber/UniqueSeries + var extraData = new ExtraData(extraDataJson); + var stuffData = _stuffDataFactory.CreateStuffDataFromExtraData( + StuffDataType.LegacyKey, + extraData + ); + + // Add to inventory + await AddFurnitureAsync( + new FurnitureItem() + { + ItemId = entity.Id, + OwnerId = entity.PlayerEntityId, + OwnerName = string.Empty, + Definition = def, + ExtraData = extraData, + StuffData = stuffData, + }, + ct + ); + } + finally + { + await dbCtx.DisposeAsync().ConfigureAwait(false); + } + } } diff --git a/Turbo.Inventory/Grains/InventoryGrain.cs b/Turbo.Inventory/Grains/InventoryGrain.cs index 03a1fe8f..e382b4a2 100644 --- a/Turbo.Inventory/Grains/InventoryGrain.cs +++ b/Turbo.Inventory/Grains/InventoryGrain.cs @@ -6,6 +6,7 @@ using Turbo.Database.Context; using Turbo.Inventory.Configuration; using Turbo.Inventory.Grains.Modules; +using Turbo.Primitives.Catalog; using Turbo.Primitives.Furniture.Providers; using Turbo.Primitives.Inventory.Factories; using Turbo.Primitives.Inventory.Grains; @@ -20,6 +21,7 @@ public sealed partial class InventoryGrain : Grain, IInventoryGrain private readonly IFurnitureDefinitionProvider _furnitureDefinitionProvider; private readonly IInventoryFurnitureLoader _furnitureItemsLoader; private readonly IStuffDataFactory _stuffDataFactory; + private readonly ICatalogService _catalogService; private readonly InventoryLiveState _state; private readonly InventoryFurniModule _furniModule; @@ -30,7 +32,8 @@ public InventoryGrain( IGrainFactory grainFactory, IFurnitureDefinitionProvider furnitureDefinitionProvider, IInventoryFurnitureLoader furnitureItemsLoader, - IStuffDataFactory stuffDataFactory + IStuffDataFactory stuffDataFactory, + ICatalogService catalogService ) { _dbCtxFactory = dbContextFactory; @@ -39,6 +42,7 @@ IStuffDataFactory stuffDataFactory _furnitureDefinitionProvider = furnitureDefinitionProvider; _furnitureItemsLoader = furnitureItemsLoader; _stuffDataFactory = stuffDataFactory; + _catalogService = catalogService; _state = new(); _furniModule = new InventoryFurniModule(this, _state, _furnitureItemsLoader); diff --git a/Turbo.PacketHandlers/Catalog/GetLimitedOfferAppearingNextMessageHandler.cs b/Turbo.PacketHandlers/Catalog/GetLimitedOfferAppearingNextMessageHandler.cs index d6ac2d69..9f7070d8 100644 --- a/Turbo.PacketHandlers/Catalog/GetLimitedOfferAppearingNextMessageHandler.cs +++ b/Turbo.PacketHandlers/Catalog/GetLimitedOfferAppearingNextMessageHandler.cs @@ -1,19 +1,53 @@ using System.Threading; using System.Threading.Tasks; using Turbo.Messages.Registry; +using Turbo.Primitives.Catalog; using Turbo.Primitives.Messages.Incoming.Catalog; +using Turbo.Primitives.Messages.Outgoing.Catalog; namespace Turbo.PacketHandlers.Catalog; -public class GetLimitedOfferAppearingNextMessageHandler +public class GetLimitedOfferAppearingNextMessageHandler(ICatalogService catalogService) : IMessageHandler { + private readonly ICatalogService _catalogService = catalogService; + public async ValueTask HandleAsync( GetLimitedOfferAppearingNextMessage message, MessageContext ctx, CancellationToken ct ) { - await ValueTask.CompletedTask.ConfigureAwait(false); + var upcoming = await _catalogService.GetUpcomingLtdAsync(ct).ConfigureAwait(false); + + if (upcoming != null) + { + await ctx.SendComposerAsync( + new LimitedOfferAppearingNextMessageComposer + { + AppearsInSeconds = upcoming.SecondsUntil, + PageId = upcoming.PageId, + OfferId = upcoming.OfferId, + ProductClassName = upcoming.ClassName ?? "", + }, + ct + ) + .ConfigureAwait(false); + } + else + { + // Habbo standard: send -1 if no upcoming LTD + await ctx.SendComposerAsync( + new LimitedOfferAppearingNextMessageComposer + { + AppearsInSeconds = -1, + PageId = -1, + OfferId = -1, + ProductClassName = "", + }, + ct + ) + .ConfigureAwait(false); + } } } diff --git a/Turbo.PacketHandlers/Catalog/PurchaseFromCatalogMessageHandler.cs b/Turbo.PacketHandlers/Catalog/PurchaseFromCatalogMessageHandler.cs index 2eafcb08..27453494 100644 --- a/Turbo.PacketHandlers/Catalog/PurchaseFromCatalogMessageHandler.cs +++ b/Turbo.PacketHandlers/Catalog/PurchaseFromCatalogMessageHandler.cs @@ -1,19 +1,26 @@ +using System.Linq; using System.Threading; using System.Threading.Tasks; using Orleans; using Turbo.Catalog.Exceptions; using Turbo.Messages.Registry; +using Turbo.Primitives.Catalog; using Turbo.Primitives.Catalog.Enums; +using Turbo.Primitives.Catalog.Grains; +using Turbo.Primitives.Catalog.Snapshots; using Turbo.Primitives.Messages.Incoming.Catalog; using Turbo.Primitives.Messages.Outgoing.Catalog; using Turbo.Primitives.Orleans; namespace Turbo.PacketHandlers.Catalog; -public class PurchaseFromCatalogMessageHandler(IGrainFactory grainFactory) - : IMessageHandler +public class PurchaseFromCatalogMessageHandler( + IGrainFactory grainFactory, + ICatalogService catalogService +) : IMessageHandler { private readonly IGrainFactory _grainFactory = grainFactory; + private readonly ICatalogService _catalogService = catalogService; public async ValueTask HandleAsync( PurchaseFromCatalogMessage message, @@ -24,10 +31,25 @@ CancellationToken ct if (ctx.PlayerId <= 0) return; + // 1. Get current catalog snapshot to check for LTDs + var snapshot = _catalogService.GetCatalogSnapshot(CatalogType.Normal); + + if (snapshot.OffersById.TryGetValue(message.OfferId, out var offer)) + { + // Detect if any product in this offer is an LTD (UniqueSize > 0) + var ltdProduct = offer.Products.FirstOrDefault(p => p.UniqueSize > 0); + + if (ltdProduct != null) + { + await HandleLtdPurchaseAsync(ctx, ltdProduct, ct).ConfigureAwait(false); + return; + } + } + try { var purchaseGrain = _grainFactory.GetCatalogPurchaseGrain(ctx.PlayerId); - var offer = await purchaseGrain + var resultOffer = await purchaseGrain .PurchaseOfferFromCatalogAsync( CatalogType.Normal, message.OfferId, @@ -37,7 +59,7 @@ CancellationToken ct ) .ConfigureAwait(false); - await ctx.SendComposerAsync(new PurchaseOKMessageComposer { Offer = offer }, ct) + await ctx.SendComposerAsync(new PurchaseOKMessageComposer { Offer = resultOffer }, ct) .ConfigureAwait(false); } catch (CatalogPurchaseException ex) @@ -57,11 +79,141 @@ await ctx.SendComposerAsync( return; } + // If error code is < 100, it belongs to the dynamic "PurchaseError" (930) packet + if ((int)ex.ErrorType < 100) + { + await ctx.SendComposerAsync( + new PurchaseErrorMessageComposer { ErrorCode = (int)ex.ErrorType }, + ct + ) + .ConfigureAwait(false); + } + else + { + // Otherwise use the static "NotAllowed" (1872) packet + // Map internal RequiresHabboClub (101) to client code 1 + var errorCode = + ex.ErrorType == CatalogPurchaseErrorType.RequiresHabboClub + ? 1 + : (int)ex.ErrorType; + await ctx.SendComposerAsync( + new PurchaseNotAllowedMessageComposer + { + ErrorType = (CatalogPurchaseErrorType)errorCode, + }, + ct + ) + .ConfigureAwait(false); + } + } + } + + private async Task HandleLtdPurchaseAsync( + MessageContext ctx, + CatalogProductSnapshot ltdProduct, + CancellationToken ct + ) + { + // Use the series ID if available (Perfect Design), otherwise fallback to Product ID + var seriesId = ltdProduct.LtdSeriesId ?? ltdProduct.Id; + var ltdRaffleGrain = _grainFactory.GetLtdRaffleGrain(seriesId); + + var result = await ltdRaffleGrain.EnterRaffleAsync(ctx.PlayerId, ct).ConfigureAwait(false); + + if (result.Success) + return; + + // 1. Check for specialized balance alerts first + if (result.BalanceFailure != null) + { await ctx.SendComposerAsync( - new PurchaseNotAllowedMessageComposer { ErrorType = ex.ErrorType }, + new NotEnoughBalanceMessageComposer + { + NotEnoughCredits = result.BalanceFailure.NotEnoughCredits, + NotEnoughActivityPoints = result.BalanceFailure.NotEnoughActivityPoints, + ActivityPointType = result.BalanceFailure.ActivityPointType, + }, ct ) .ConfigureAwait(false); + return; + } + + // 2. Map other errors to correct packet types + switch (result.Error) + { + case LtdRaffleEntryError.AlreadyWon: + // Error 6: "LTD item purchases are limited..." needs PurchaseErrorMessage (930) + await ctx.SendComposerAsync( + new PurchaseErrorMessageComposer + { + ErrorCode = (int)CatalogPurchaseErrorType.LtdPurchasesLimited, + }, + ct + ) + .ConfigureAwait(false); + break; + + case LtdRaffleEntryError.RaffleProcessing: + // Error 12: "Frank is handling..." needs PurchaseErrorMessage (930) + await ctx.SendComposerAsync( + new PurchaseErrorMessageComposer + { + ErrorCode = (int)CatalogPurchaseErrorType.RaffleOngoing, + }, + ct + ) + .ConfigureAwait(false); + break; + + case LtdRaffleEntryError.AlreadyInQueue: + // Silent failure or generic error + await ctx.SendComposerAsync( + new PurchaseNotAllowedMessageComposer + { + ErrorType = CatalogPurchaseErrorType.PurchaseFailed, + }, + ct + ) + .ConfigureAwait(false); + break; + + case LtdRaffleEntryError.SoldOut: + // Map to static PurchaseNotAllowed (1872) using internal ID for OfferNotFound + await ctx.SendComposerAsync( + new PurchaseNotAllowedMessageComposer + { + ErrorType = (CatalogPurchaseErrorType) + (int)CatalogPurchaseErrorType.OfferNotFound, + }, + ct + ) + .ConfigureAwait(false); + break; + + case LtdRaffleEntryError.InsufficientFunds: + // Fallback: If 3883 failed or balancefailure null, send standard NotEnoughCredits via 1872 logic + // Map to client case 102 internally + await ctx.SendComposerAsync( + new PurchaseNotAllowedMessageComposer + { + ErrorType = CatalogPurchaseErrorType.NotEnoughCredits, + }, + ct + ) + .ConfigureAwait(false); + break; + + default: + await ctx.SendComposerAsync( + new PurchaseNotAllowedMessageComposer + { + ErrorType = CatalogPurchaseErrorType.PurchaseFailed, + }, + ct + ) + .ConfigureAwait(false); + break; } } } diff --git a/Turbo.Primitives/Catalog/Enums/CatalogPurchaseErrorType.cs b/Turbo.Primitives/Catalog/Enums/CatalogPurchaseErrorType.cs index 176f60af..d5214717 100644 --- a/Turbo.Primitives/Catalog/Enums/CatalogPurchaseErrorType.cs +++ b/Turbo.Primitives/Catalog/Enums/CatalogPurchaseErrorType.cs @@ -3,10 +3,26 @@ namespace Turbo.Primitives.Catalog.Enums; public enum CatalogPurchaseErrorType { None = 0, - RequiresHabboClub = 1, - OfferNotFound = 2, - NotEnoughCredits = 3, - OfferMisconfigured = 4, - PurchaseFailed = 5, - NotEnoughActivityPoints = 6, + + // Mapped to catalog.alert.purchaseerror.description.{ID} (Packet 930) + BadgeOwned = 1, + NotEnoughCredits = 2, + TradeLocked = 3, + NotEnoughActivityPoints = 4, + EffectOwned = 5, + LtdPurchasesLimited = 6, + GroupRequired = 7, + SafetyLocked = 8, + InvalidGiftMessage = 9, + InventoryFull = 10, + ChatbubbleOwned = 11, + RaffleOngoing = 12, + BlockedByReceiver = 13, + ReceiverNotFound = 14, + + // Static Description Mapping (1872 Packet) + RequiresHabboClub = 101, // Handled as case 1 in client + OfferNotFound = 102, + OfferMisconfigured = 103, + PurchaseFailed = 104, } diff --git a/Turbo.Primitives/Catalog/Enums/LtdRaffleEntryError.cs b/Turbo.Primitives/Catalog/Enums/LtdRaffleEntryError.cs new file mode 100644 index 00000000..2691edb5 --- /dev/null +++ b/Turbo.Primitives/Catalog/Enums/LtdRaffleEntryError.cs @@ -0,0 +1,16 @@ +namespace Turbo.Primitives.Catalog.Enums; + +/// +/// Error codes for LTD raffle entry failures. +/// +public enum LtdRaffleEntryError +{ + None = 0, + SeriesNotFound = 1, + SeriesNotActive = 2, + SoldOut = 3, + AlreadyInQueue = 4, + InsufficientFunds = 5, + RaffleProcessing = 6, + AlreadyWon = 7, +} diff --git a/Turbo.Primitives/Catalog/Enums/LtdRaffleResultCode.cs b/Turbo.Primitives/Catalog/Enums/LtdRaffleResultCode.cs new file mode 100644 index 00000000..574bfc43 --- /dev/null +++ b/Turbo.Primitives/Catalog/Enums/LtdRaffleResultCode.cs @@ -0,0 +1,29 @@ +namespace Turbo.Primitives.Catalog.Enums; + +/// +/// Result codes for LTD raffle outcomes. +/// +public enum LtdRaffleResultCode : byte +{ + /// + /// Player won the raffle and received the item. + /// Triggers notification.raffle.won in client. + /// + Won = 0, + + /// + /// Player lost the raffle. + /// Triggers notification.raffle.lost in client. + /// + Lost = 1, + + /// + /// Player lost because stock ran out during raffle. + /// + LostNoStock = 2, + + /// + /// Player lost due to an error. + /// + LostError = 3, +} diff --git a/Turbo.Primitives/Catalog/Grains/ILtdRaffleGrain.cs b/Turbo.Primitives/Catalog/Grains/ILtdRaffleGrain.cs new file mode 100644 index 00000000..f9fa7069 --- /dev/null +++ b/Turbo.Primitives/Catalog/Grains/ILtdRaffleGrain.cs @@ -0,0 +1,36 @@ +using System.Threading; +using System.Threading.Tasks; +using Orleans; +using Turbo.Primitives.Catalog.Snapshots; + +namespace Turbo.Primitives.Catalog.Grains; + +/// +/// Grain that manages the LTD raffle system for a specific series. +/// Keyed by LTD Series ID. +/// +public interface ILtdRaffleGrain : IGrainWithIntegerKey +{ + /// + /// Player enters the raffle for this LTD series. + /// + /// The player attempting to enter. + /// Cancellation token. + /// Result indicating success or failure reason. + Task EnterRaffleAsync(int playerId, CancellationToken ct); + + /// + /// Get the current series snapshot. + /// + Task GetSeriesSnapshotAsync(CancellationToken ct); + + /// + /// Admin: Force run the raffle for the current batch immediately. + /// + Task ForceRunRaffleAsync(CancellationToken ct); + + /// + /// Admin: Reload series configuration from database. + /// + Task ReloadSeriesAsync(CancellationToken ct); +} diff --git a/Turbo.Primitives/Catalog/ICatalogService.cs b/Turbo.Primitives/Catalog/ICatalogService.cs index a26766f5..0107015a 100644 --- a/Turbo.Primitives/Catalog/ICatalogService.cs +++ b/Turbo.Primitives/Catalog/ICatalogService.cs @@ -1,3 +1,5 @@ +using System.Threading; +using System.Threading.Tasks; using Turbo.Primitives.Catalog.Enums; using Turbo.Primitives.Catalog.Snapshots; @@ -6,4 +8,6 @@ namespace Turbo.Primitives.Catalog; public interface ICatalogService { public CatalogSnapshot GetCatalogSnapshot(CatalogType catalogType); + + public Task GetUpcomingLtdAsync(CancellationToken ct); } diff --git a/Turbo.Primitives/Catalog/Snapshots/CatalogProductSnapshot.cs b/Turbo.Primitives/Catalog/Snapshots/CatalogProductSnapshot.cs index 5ea56b16..2117c0f9 100644 --- a/Turbo.Primitives/Catalog/Snapshots/CatalogProductSnapshot.cs +++ b/Turbo.Primitives/Catalog/Snapshots/CatalogProductSnapshot.cs @@ -32,4 +32,10 @@ public sealed record CatalogProductSnapshot [Id(8)] public required int UniqueRemaining { get; init; } + + [Id(9)] + public int? LtdSeriesId { get; init; } + + [Id(10)] + public required string? ClassName { get; init; } } diff --git a/Turbo.Primitives/Catalog/Snapshots/LtdRaffleEntryResult.cs b/Turbo.Primitives/Catalog/Snapshots/LtdRaffleEntryResult.cs new file mode 100644 index 00000000..fa1db042 --- /dev/null +++ b/Turbo.Primitives/Catalog/Snapshots/LtdRaffleEntryResult.cs @@ -0,0 +1,44 @@ +using Orleans; +using Turbo.Primitives.Catalog.Enums; + +namespace Turbo.Primitives.Catalog.Snapshots; + +/// +/// Result of attempting to enter an LTD raffle. +/// +[GenerateSerializer, Immutable] +public sealed record LtdRaffleEntryResult +{ + [Id(0)] + public required bool Success { get; init; } + + [Id(1)] + public string? BatchId { get; init; } + + [Id(2)] + public LtdRaffleEntryError? Error { get; init; } + + [Id(3)] + public CatalogBalanceFailure? BalanceFailure { get; init; } + + public static LtdRaffleEntryResult Succeeded(string batchId) => + new() + { + Success = true, + BatchId = batchId, + Error = null, + BalanceFailure = null, + }; + + public static LtdRaffleEntryResult Failed( + LtdRaffleEntryError error, + CatalogBalanceFailure? balanceFailure = null + ) => + new() + { + Success = false, + BatchId = null, + Error = error, + BalanceFailure = balanceFailure, + }; +} diff --git a/Turbo.Primitives/Catalog/Snapshots/LtdSeriesSnapshot.cs b/Turbo.Primitives/Catalog/Snapshots/LtdSeriesSnapshot.cs new file mode 100644 index 00000000..2cd797fd --- /dev/null +++ b/Turbo.Primitives/Catalog/Snapshots/LtdSeriesSnapshot.cs @@ -0,0 +1,47 @@ +using System; +using Orleans; + +namespace Turbo.Primitives.Catalog.Snapshots; + +/// +/// Snapshot of an LTD (Limited Edition) series configuration. +/// +[GenerateSerializer, Immutable] +public sealed record LtdSeriesSnapshot +{ + [Id(0)] + public required int Id { get; init; } + + [Id(1)] + public required int CatalogProductId { get; init; } + + [Id(3)] + public required int TotalQuantity { get; init; } + + [Id(4)] + public required int RemainingQuantity { get; init; } + + [Id(6)] + public required int RaffleWindowSeconds { get; init; } + + [Id(7)] + public required bool IsActive { get; init; } + + [Id(8)] + public required bool IsRaffleFinished { get; init; } + + [Id(9)] + public DateTime? StartsAt { get; init; } + + [Id(10)] + public DateTime? EndsAt { get; init; } + + /// + /// Whether this LTD series is currently available for purchase. + /// + public bool IsAvailable => + IsActive + && RemainingQuantity > 0 + && (StartsAt == null || StartsAt <= DateTime.UtcNow) + && (EndsAt == null || EndsAt >= DateTime.UtcNow); +} diff --git a/Turbo.Primitives/Catalog/Snapshots/UpcomingLtdSnapshot.cs b/Turbo.Primitives/Catalog/Snapshots/UpcomingLtdSnapshot.cs new file mode 100644 index 00000000..384e793a --- /dev/null +++ b/Turbo.Primitives/Catalog/Snapshots/UpcomingLtdSnapshot.cs @@ -0,0 +1,22 @@ +using Orleans; + +namespace Turbo.Primitives.Catalog.Snapshots; + +/// +/// Data required for the Landing View LTD countdown. +/// +[GenerateSerializer, Immutable] +public sealed record UpcomingLtdSnapshot +{ + [Id(0)] + public required int SecondsUntil { get; init; } + + [Id(1)] + public required int PageId { get; init; } + + [Id(2)] + public required int OfferId { get; init; } + + [Id(3)] + public required string? ClassName { get; init; } +} diff --git a/Turbo.Primitives/Inventory/Grains/IInventoryGrain.Furni.cs b/Turbo.Primitives/Inventory/Grains/IInventoryGrain.Furni.cs index 2366dd47..eddf9ef2 100644 --- a/Turbo.Primitives/Inventory/Grains/IInventoryGrain.Furni.cs +++ b/Turbo.Primitives/Inventory/Grains/IInventoryGrain.Furni.cs @@ -23,6 +23,21 @@ public Task GrantCatalogOfferAsync( int quantity, CancellationToken ct ); + + /// + /// Grant an LTD furniture item with serial number to the player's inventory. + /// + /// The catalog product ID. + /// The unique serial number (e.g., 123). + /// The total series size (e.g., 500). + /// Cancellation token. + public Task GrantLtdFurnitureAsync( + int catalogProductId, + int serialNumber, + int seriesSize, + CancellationToken ct + ); + public Task GetItemSnapshotAsync( RoomObjectId itemId, CancellationToken ct diff --git a/Turbo.Primitives/Messages/Outgoing/Catalog/LimitedOfferAppearingNextMessageComposer.cs b/Turbo.Primitives/Messages/Outgoing/Catalog/LimitedOfferAppearingNextMessageComposer.cs index 1cfa8e4f..9063ae76 100644 --- a/Turbo.Primitives/Messages/Outgoing/Catalog/LimitedOfferAppearingNextMessageComposer.cs +++ b/Turbo.Primitives/Messages/Outgoing/Catalog/LimitedOfferAppearingNextMessageComposer.cs @@ -6,5 +6,15 @@ namespace Turbo.Primitives.Messages.Outgoing.Catalog; [GenerateSerializer, Immutable] public sealed record LimitedOfferAppearingNextMessageComposer : IComposer { - // TODO: add properties if/when identified + [Id(0)] + public required int AppearsInSeconds { get; init; } + + [Id(1)] + public required int PageId { get; init; } + + [Id(2)] + public required int OfferId { get; init; } + + [Id(3)] + public required string ProductClassName { get; init; } } diff --git a/Turbo.Primitives/Messages/Outgoing/Catalog/PurchaseErrorMessageComposer.cs b/Turbo.Primitives/Messages/Outgoing/Catalog/PurchaseErrorMessageComposer.cs index 999ceabd..bc10da6d 100644 --- a/Turbo.Primitives/Messages/Outgoing/Catalog/PurchaseErrorMessageComposer.cs +++ b/Turbo.Primitives/Messages/Outgoing/Catalog/PurchaseErrorMessageComposer.cs @@ -6,5 +6,6 @@ namespace Turbo.Primitives.Messages.Outgoing.Catalog; [GenerateSerializer, Immutable] public sealed record PurchaseErrorMessageComposer : IComposer { - // TODO: add properties if/when identified + [Id(0)] + public required int ErrorCode { get; init; } } diff --git a/Turbo.Primitives/Messages/Outgoing/Collectibles/LtdRaffleEnteredMessageComposer.cs b/Turbo.Primitives/Messages/Outgoing/Collectibles/LtdRaffleEnteredMessageComposer.cs new file mode 100644 index 00000000..08e69c59 --- /dev/null +++ b/Turbo.Primitives/Messages/Outgoing/Collectibles/LtdRaffleEnteredMessageComposer.cs @@ -0,0 +1,19 @@ +using Orleans; +using Turbo.Primitives.Networking; + +namespace Turbo.Primitives.Messages.Outgoing.Collectibles; + +/// +/// Sent when a player successfully enters the LTD raffle queue. +/// Shows "entering raffle" UI with dots animation in purchase confirmation. +/// Header ID: 1221 +/// +[GenerateSerializer, Immutable] +public sealed record LtdRaffleEnteredMessageComposer : IComposer +{ + /// + /// The product name/type for which the user entered the raffle. + /// + [Id(0)] + public required string ClassName { get; init; } +} diff --git a/Turbo.Primitives/Messages/Outgoing/Collectibles/LtdRaffleResultMessageComposer.cs b/Turbo.Primitives/Messages/Outgoing/Collectibles/LtdRaffleResultMessageComposer.cs new file mode 100644 index 00000000..9a641cd0 --- /dev/null +++ b/Turbo.Primitives/Messages/Outgoing/Collectibles/LtdRaffleResultMessageComposer.cs @@ -0,0 +1,28 @@ +using Orleans; +using Turbo.Primitives.Catalog.Enums; +using Turbo.Primitives.Networking; + +namespace Turbo.Primitives.Messages.Outgoing.Collectibles; + +/// +/// Sent when the LTD raffle completes with the player's result. +/// Header ID: 785 +/// ResultCode 0 = Won (triggers notification.raffle.won) +/// ResultCode 1-3 = Lost (triggers notification.raffle.lost) +/// +[GenerateSerializer, Immutable] +public sealed record LtdRaffleResultMessageComposer : IComposer +{ + /// + /// The product name. + /// + [Id(0)] + public required string ClassName { get; init; } + + /// + /// The raffle result code. + /// 0 = Won, 1+ = Lost + /// + [Id(1)] + public required LtdRaffleResultCode ResultCode { get; init; } +} diff --git a/Turbo.Primitives/Orleans/GrainFactoryExtensions.cs b/Turbo.Primitives/Orleans/GrainFactoryExtensions.cs index 8f258436..32b1dba6 100644 --- a/Turbo.Primitives/Orleans/GrainFactoryExtensions.cs +++ b/Turbo.Primitives/Orleans/GrainFactoryExtensions.cs @@ -67,6 +67,9 @@ public static ICatalogPurchaseGrain GetCatalogPurchaseGrain( long playerId ) => factory.GetGrain(playerId); + public static ILtdRaffleGrain GetLtdRaffleGrain(this IGrainFactory factory, int ltdSeriesId) => + factory.GetGrain(ltdSeriesId); + public static IMessengerGrain GetMessengerGrain( this IGrainFactory factory, PlayerId playerId diff --git a/Turbo.Primitives/TurboErrorCodeEnum.cs b/Turbo.Primitives/TurboErrorCodeEnum.cs index d9cc1d71..790ac806 100644 --- a/Turbo.Primitives/TurboErrorCodeEnum.cs +++ b/Turbo.Primitives/TurboErrorCodeEnum.cs @@ -12,6 +12,7 @@ public enum TurboErrorCodeEnum FloorItemNotFound, WallItemNotFound, FurnitureDefinitionNotFound, + CatalogProductNotFound, InvalidLogic, InvalidWired, InvalidFurnitureProductType, @@ -36,6 +37,8 @@ public static string ToDefaultMessage(this TurboErrorCodeEnum code) => TurboErrorCodeEnum.WallItemNotFound => "The specified wall item could not be found.", TurboErrorCodeEnum.FurnitureDefinitionNotFound => "The specified furniture definition could not be found.", + TurboErrorCodeEnum.CatalogProductNotFound => + "The specified catalog product could not be found.", TurboErrorCodeEnum.InvalidLogic => "The logic is not valid.", TurboErrorCodeEnum.InvalidWired => "The wired definition is not valid.", TurboErrorCodeEnum.InvalidFurnitureProductType => diff --git a/Turbo.Revisions/Revision20260112/Revision20260112.cs b/Turbo.Revisions/Revision20260112/Revision20260112.cs index c465e9a5..2df2e12e 100644 --- a/Turbo.Revisions/Revision20260112/Revision20260112.cs +++ b/Turbo.Revisions/Revision20260112/Revision20260112.cs @@ -1801,6 +1801,18 @@ public class Revision20260112 : IRevision MessageComposer.UserNftChatStylesMessageComposer ) }, + { + typeof(LtdRaffleEnteredMessageComposer), + new LtdRaffleEnteredMessageComposerSerializer( + MessageComposer.LtdRaffleEnteredMessageComposer + ) + }, + { + typeof(LtdRaffleResultMessageComposer), + new LtdRaffleResultMessageComposerSerializer( + MessageComposer.LtdRaffleResultMessageComposer + ) + }, #endregion #region FriendList diff --git a/Turbo.Revisions/Revision20260112/Serializers/Catalog/LimitedOfferAppearingNextMessageComposerSerializer.cs b/Turbo.Revisions/Revision20260112/Serializers/Catalog/LimitedOfferAppearingNextMessageComposerSerializer.cs index 8a3c63b6..2ba72cbc 100644 --- a/Turbo.Revisions/Revision20260112/Serializers/Catalog/LimitedOfferAppearingNextMessageComposerSerializer.cs +++ b/Turbo.Revisions/Revision20260112/Serializers/Catalog/LimitedOfferAppearingNextMessageComposerSerializer.cs @@ -11,6 +11,9 @@ protected override void Serialize( LimitedOfferAppearingNextMessageComposer message ) { - // + packet.WriteInteger(message.AppearsInSeconds); + packet.WriteInteger(message.PageId); + packet.WriteInteger(message.OfferId); + packet.WriteString(message.ProductClassName); } } diff --git a/Turbo.Revisions/Revision20260112/Serializers/Catalog/PurchaseErrorMessageComposerSerializer.cs b/Turbo.Revisions/Revision20260112/Serializers/Catalog/PurchaseErrorMessageComposerSerializer.cs index a73509be..be3930e7 100644 --- a/Turbo.Revisions/Revision20260112/Serializers/Catalog/PurchaseErrorMessageComposerSerializer.cs +++ b/Turbo.Revisions/Revision20260112/Serializers/Catalog/PurchaseErrorMessageComposerSerializer.cs @@ -8,6 +8,6 @@ internal class PurchaseErrorMessageComposerSerializer(int header) { protected override void Serialize(IServerPacket packet, PurchaseErrorMessageComposer message) { - // + packet.WriteInteger(message.ErrorCode); } } diff --git a/Turbo.Revisions/Revision20260112/Serializers/Collectibles/LtdRaffleEnteredMessageComposerSerializer.cs b/Turbo.Revisions/Revision20260112/Serializers/Collectibles/LtdRaffleEnteredMessageComposerSerializer.cs new file mode 100644 index 00000000..9e8ab861 --- /dev/null +++ b/Turbo.Revisions/Revision20260112/Serializers/Collectibles/LtdRaffleEnteredMessageComposerSerializer.cs @@ -0,0 +1,13 @@ +using Turbo.Primitives.Messages.Outgoing.Collectibles; +using Turbo.Primitives.Packets; + +namespace Turbo.Revisions.Revision20260112.Serializers.Collectibles; + +internal class LtdRaffleEnteredMessageComposerSerializer(int header) + : AbstractSerializer(header) +{ + protected override void Serialize(IServerPacket packet, LtdRaffleEnteredMessageComposer message) + { + packet.WriteString(message.ClassName); + } +} diff --git a/Turbo.Revisions/Revision20260112/Serializers/Collectibles/LtdRaffleResultMessageComposerSerializer.cs b/Turbo.Revisions/Revision20260112/Serializers/Collectibles/LtdRaffleResultMessageComposerSerializer.cs new file mode 100644 index 00000000..18c4f266 --- /dev/null +++ b/Turbo.Revisions/Revision20260112/Serializers/Collectibles/LtdRaffleResultMessageComposerSerializer.cs @@ -0,0 +1,14 @@ +using Turbo.Primitives.Messages.Outgoing.Collectibles; +using Turbo.Primitives.Packets; + +namespace Turbo.Revisions.Revision20260112.Serializers.Collectibles; + +internal class LtdRaffleResultMessageComposerSerializer(int header) + : AbstractSerializer(header) +{ + protected override void Serialize(IServerPacket packet, LtdRaffleResultMessageComposer message) + { + packet.WriteString(message.ClassName); + packet.WriteByte((byte)message.ResultCode); + } +} diff --git a/appsettings.json b/appsettings.json index 8eed5f3e..5d8786dc 100644 --- a/appsettings.json +++ b/appsettings.json @@ -50,6 +50,54 @@ }, "Game": {}, "Networking": {}, + "Catalog": { + "LtdRaffle": { + "BaseWeight": 1.0, + "DefaultBufferSeconds": 20, + "UsePureRandom": false, + "RandomizeSerials": true, + "LimitOnePerCustomer": false, + "BadgeCount": { + "Enabled": true, + "BonusPerUnit": 0.02, + "MaxBonus": 1.0 + }, + "AccountAgeDays": { + "Enabled": true, + "BonusPerUnit": 0.00137, + "MaxBonus": 0.5 + }, + "OnlineTimeMinutes": { + "Enabled": false, + "BonusPerUnit": 0.00005, + "MaxBonus": 0.5 + }, + "RoomCount": { + "Enabled": false, + "BonusPerUnit": 0.05, + "MaxBonus": 0.5 + }, + "FurnitureCount": { + "Enabled": false, + "BonusPerUnit": 0.001, + "MaxBonus": 0.5 + }, + "FriendCount": { + "Enabled": false, + "BonusPerUnit": 0.01, + "MaxBonus": 0.5 + }, + "RespectsReceived": { + "Enabled": false, + "BonusPerUnit": 0.005, + "MaxBonus": 0.5 + }, + "AchievementScore": { + "Enabled": false, + "BonusPerUnit": 0.0001, + "MaxBonus": 0.5 + } + }, "FriendList": { "UserFriendLimit": 100, "NormalFriendLimit": 100, @@ -62,4 +110,4 @@ "EnableServerToClientEncryption": true } } -} \ No newline at end of file +}