Pack opening

This commit is contained in:
gamer147
2026-05-24 02:03:13 -04:00
parent bdff142d16
commit 79209bd70b
41 changed files with 37320 additions and 0 deletions

View File

@@ -0,0 +1,17 @@
namespace SVSim.Database.Enums;
/// <summary>
/// Mirrors <c>Wizard.PackCategory</c> in the decompiled client
/// (<c>Shadowverse_Code/Wizard/PackCategory.cs</c>). Wire value = (int)enum.
/// </summary>
public enum PackCategory
{
None = 0,
LegendCardPack = 1,
SpecialCardPack = 2,
LimitedSpecialCardPack = 3,
FreePackLeaderSkin = 4,
RotationStarterCardPack = 5,
LeaderSkinPack = 6,
LegendAndLeaderSkinSinglePack = 7,
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,135 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace SVSim.Database.Migrations
{
/// <inheritdoc />
public partial class AddPackCatalog : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Packs",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false),
BasePackId = table.Column<int>(type: "integer", nullable: false),
GachaType = table.Column<int>(type: "integer", nullable: false),
PackCategory = table.Column<int>(type: "integer", nullable: false),
PosterType = table.Column<int>(type: "integer", nullable: false),
CommenceDate = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
CompleteDate = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
SalesPeriodTime = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
SleeveId = table.Column<int>(type: "integer", nullable: false),
SpecialSleeveId = table.Column<int>(type: "integer", nullable: false),
OverrideDrawEffectPackId = table.Column<int>(type: "integer", nullable: false),
OverrideUiEffectPackId = table.Column<int>(type: "integer", nullable: false),
GachaDetail = table.Column<string>(type: "text", nullable: false),
IsHide = table.Column<bool>(type: "boolean", nullable: false),
IsNew = table.Column<bool>(type: "boolean", nullable: false),
IsPreRelease = table.Column<bool>(type: "boolean", nullable: false),
OpenCountLimit = table.Column<int>(type: "integer", nullable: false),
GachaPointConfig_ExchangeablePoint = table.Column<int>(type: "integer", nullable: true),
GachaPointConfig_IncreaseGachaPoint = table.Column<int>(type: "integer", nullable: true),
DateCreated = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
DateUpdated = table.Column<DateTime>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_Packs", x => x.Id);
});
migrationBuilder.CreateTable(
name: "ViewerPackOpenCount",
columns: table => new
{
ViewerId = table.Column<long>(type: "bigint", nullable: false),
Id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
PackId = table.Column<int>(type: "integer", nullable: false),
OpenCount = table.Column<int>(type: "integer", nullable: false),
LastDailyFreeAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_ViewerPackOpenCount", x => new { x.ViewerId, x.Id });
table.ForeignKey(
name: "FK_ViewerPackOpenCount_Viewers_ViewerId",
column: x => x.ViewerId,
principalTable: "Viewers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "PackBannerEntry",
columns: table => new
{
PackConfigEntryId = table.Column<int>(type: "integer", nullable: false),
Id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
BannerName = table.Column<string>(type: "text", nullable: false),
DialogTitle = table.Column<string>(type: "text", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_PackBannerEntry", x => new { x.PackConfigEntryId, x.Id });
table.ForeignKey(
name: "FK_PackBannerEntry_Packs_PackConfigEntryId",
column: x => x.PackConfigEntryId,
principalTable: "Packs",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "PackChildGachaEntry",
columns: table => new
{
PackConfigEntryId = table.Column<int>(type: "integer", nullable: false),
Id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
GachaId = table.Column<int>(type: "integer", nullable: false),
TypeDetail = table.Column<int>(type: "integer", nullable: false),
Cost = table.Column<int>(type: "integer", nullable: false),
CardCount = table.Column<int>(type: "integer", nullable: false),
ItemId = table.Column<long>(type: "bigint", nullable: true),
IsDailySingle = table.Column<bool>(type: "boolean", nullable: false),
OverrideIncreaseGachaPoint = table.Column<int>(type: "integer", nullable: false),
PurchaseLimitCount = table.Column<int>(type: "integer", nullable: false),
FreeGachaCampaignId = table.Column<int>(type: "integer", nullable: true),
CampaignName = table.Column<string>(type: "text", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_PackChildGachaEntry", x => new { x.PackConfigEntryId, x.Id });
table.ForeignKey(
name: "FK_PackChildGachaEntry_Packs_PackConfigEntryId",
column: x => x.PackConfigEntryId,
principalTable: "Packs",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "PackBannerEntry");
migrationBuilder.DropTable(
name: "PackChildGachaEntry");
migrationBuilder.DropTable(
name: "ViewerPackOpenCount");
migrationBuilder.DropTable(
name: "Packs");
}
}
}

View File

@@ -25422,6 +25422,71 @@ namespace SVSim.Database.Migrations
b.ToTable("MyRotationSettings");
});
modelBuilder.Entity("SVSim.Database.Models.PackConfigEntry", b =>
{
b.Property<int>("Id")
.HasColumnType("integer");
b.Property<int>("BasePackId")
.HasColumnType("integer");
b.Property<DateTime>("CommenceDate")
.HasColumnType("timestamp with time zone");
b.Property<DateTime>("CompleteDate")
.HasColumnType("timestamp with time zone");
b.Property<DateTime>("DateCreated")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("DateUpdated")
.HasColumnType("timestamp with time zone");
b.Property<string>("GachaDetail")
.IsRequired()
.HasColumnType("text");
b.Property<int>("GachaType")
.HasColumnType("integer");
b.Property<bool>("IsHide")
.HasColumnType("boolean");
b.Property<bool>("IsNew")
.HasColumnType("boolean");
b.Property<bool>("IsPreRelease")
.HasColumnType("boolean");
b.Property<int>("OpenCountLimit")
.HasColumnType("integer");
b.Property<int>("OverrideDrawEffectPackId")
.HasColumnType("integer");
b.Property<int>("OverrideUiEffectPackId")
.HasColumnType("integer");
b.Property<int>("PackCategory")
.HasColumnType("integer");
b.Property<int>("PosterType")
.HasColumnType("integer");
b.Property<DateTime?>("SalesPeriodTime")
.HasColumnType("timestamp with time zone");
b.Property<int>("SleeveId")
.HasColumnType("integer");
b.Property<int>("SpecialSleeveId")
.HasColumnType("integer");
b.HasKey("Id");
b.ToTable("Packs");
});
modelBuilder.Entity("SVSim.Database.Models.PaymentItemEntry", b =>
{
b.Property<int>("Id")
@@ -34209,6 +34274,110 @@ namespace SVSim.Database.Migrations
b.Navigation("Class");
});
modelBuilder.Entity("SVSim.Database.Models.PackConfigEntry", b =>
{
b.OwnsMany("SVSim.Database.Models.PackBannerEntry", "Banners", b1 =>
{
b1.Property<int>("PackConfigEntryId")
.HasColumnType("integer");
b1.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b1.Property<int>("Id"));
b1.Property<string>("BannerName")
.IsRequired()
.HasColumnType("text");
b1.Property<string>("DialogTitle")
.IsRequired()
.HasColumnType("text");
b1.HasKey("PackConfigEntryId", "Id");
b1.ToTable("PackBannerEntry");
b1.WithOwner()
.HasForeignKey("PackConfigEntryId");
});
b.OwnsMany("SVSim.Database.Models.PackChildGachaEntry", "ChildGachas", b1 =>
{
b1.Property<int>("PackConfigEntryId")
.HasColumnType("integer");
b1.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b1.Property<int>("Id"));
b1.Property<string>("CampaignName")
.HasColumnType("text");
b1.Property<int>("CardCount")
.HasColumnType("integer");
b1.Property<int>("Cost")
.HasColumnType("integer");
b1.Property<int?>("FreeGachaCampaignId")
.HasColumnType("integer");
b1.Property<int>("GachaId")
.HasColumnType("integer");
b1.Property<bool>("IsDailySingle")
.HasColumnType("boolean");
b1.Property<long?>("ItemId")
.HasColumnType("bigint");
b1.Property<int>("OverrideIncreaseGachaPoint")
.HasColumnType("integer");
b1.Property<int>("PurchaseLimitCount")
.HasColumnType("integer");
b1.Property<int>("TypeDetail")
.HasColumnType("integer");
b1.HasKey("PackConfigEntryId", "Id");
b1.ToTable("PackChildGachaEntry");
b1.WithOwner()
.HasForeignKey("PackConfigEntryId");
});
b.OwnsOne("SVSim.Database.Models.PackGachaPointConfig", "GachaPointConfig", b1 =>
{
b1.Property<int>("PackConfigEntryId")
.HasColumnType("integer");
b1.Property<int>("ExchangeablePoint")
.HasColumnType("integer");
b1.Property<int>("IncreaseGachaPoint")
.HasColumnType("integer");
b1.HasKey("PackConfigEntryId");
b1.ToTable("Packs");
b1.WithOwner()
.HasForeignKey("PackConfigEntryId");
});
b.Navigation("Banners");
b.Navigation("ChildGachas");
b.Navigation("GachaPointConfig");
});
modelBuilder.Entity("SVSim.Database.Models.ShadowverseCardEntry", b =>
{
b.HasOne("SVSim.Database.Models.ClassEntry", "Class")
@@ -34588,6 +34757,34 @@ namespace SVSim.Database.Migrations
.HasForeignKey("ViewerId");
});
b.OwnsMany("SVSim.Database.Models.ViewerPackOpenCount", "PackOpenCounts", b1 =>
{
b1.Property<long>("ViewerId")
.HasColumnType("bigint");
b1.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b1.Property<int>("Id"));
b1.Property<DateTime?>("LastDailyFreeAt")
.HasColumnType("timestamp with time zone");
b1.Property<int>("OpenCount")
.HasColumnType("integer");
b1.Property<int>("PackId")
.HasColumnType("integer");
b1.HasKey("ViewerId", "Id");
b1.ToTable("ViewerPackOpenCount");
b1.WithOwner()
.HasForeignKey("ViewerId");
});
b.Navigation("Cards");
b.Navigation("Classes");
@@ -34603,6 +34800,8 @@ namespace SVSim.Database.Migrations
b.Navigation("MissionData")
.IsRequired();
b.Navigation("PackOpenCounts");
b.Navigation("SocialAccountConnections");
});

View File

@@ -0,0 +1,11 @@
using Microsoft.EntityFrameworkCore;
namespace SVSim.Database.Models;
/// <summary>One entry of <c>cardpack_banner_list</c> in /pack/info. Owned by PackConfigEntry.</summary>
[Owned]
public class PackBannerEntry
{
public string BannerName { get; set; } = string.Empty;
public string DialogTitle { get; set; } = string.Empty;
}

View File

@@ -0,0 +1,26 @@
using Microsoft.EntityFrameworkCore;
namespace SVSim.Database.Models;
/// <summary>
/// One sub-option inside a pack (single-open / 10-open / ticket / daily-free).
/// Wire shape: one entry of <c>child_gacha_info</c> in /pack/info. Owned by PackConfigEntry.
/// <c>TypeDetail</c> corresponds to <c>GachaUI.CardPackType</c>:
/// 1=CRYSTAL, 2=CRYSTAL_MULTI, 3=DAILY, 4=TICKET, 5=TICKET_MULTI, 6=RUPY, 7=RUPY_MULTI,
/// 8=CRYSTAL_SPECIAL, 9=CRYSTAL_SELECT_SKIN, 10=FREE_PACKS, 11=FREE_PACK_WITH_SKIN,
/// 12=ROTATION_STARTER_PACK, 13=CRYSTAL_ACQUIRE_SKIN_CARD_PACK.
/// </summary>
[Owned]
public class PackChildGachaEntry
{
public int GachaId { get; set; }
public int TypeDetail { get; set; }
public int Cost { get; set; }
public int CardCount { get; set; }
public long? ItemId { get; set; }
public bool IsDailySingle { get; set; }
public int OverrideIncreaseGachaPoint { get; set; }
public int PurchaseLimitCount { get; set; }
public int? FreeGachaCampaignId { get; set; }
public string? CampaignName { get; set; }
}

View File

@@ -0,0 +1,40 @@
using SVSim.Database.Common;
using SVSim.Database.Enums;
namespace SVSim.Database.Models;
/// <summary>
/// One row of /pack/info's <c>pack_config_list</c>. PK = <c>parent_gacha_id</c> (the wire id the
/// client treats as "this pack"). Child gachas and banners are owned collections — replaced
/// wholesale on importer re-runs.
/// </summary>
public class PackConfigEntry : BaseEntity<int>
{
public int BasePackId { get; set; }
public int GachaType { get; set; }
public PackCategory PackCategory { get; set; }
public int PosterType { get; set; }
public DateTime CommenceDate { get; set; }
public DateTime CompleteDate { get; set; }
public DateTime? SalesPeriodTime { get; set; }
public int SleeveId { get; set; }
public int SpecialSleeveId { get; set; }
public int OverrideDrawEffectPackId { get; set; }
public int OverrideUiEffectPackId { get; set; }
public string GachaDetail { get; set; } = string.Empty;
public bool IsHide { get; set; }
public bool IsNew { get; set; }
public bool IsPreRelease { get; set; }
public int OpenCountLimit { get; set; }
public PackGachaPointConfig? GachaPointConfig { get; set; }
public List<PackBannerEntry> Banners { get; set; } = new();
public List<PackChildGachaEntry> ChildGachas { get; set; } = new();
}

View File

@@ -0,0 +1,16 @@
using Microsoft.EntityFrameworkCore;
namespace SVSim.Database.Models;
/// <summary>
/// Per-pack gacha-point exchange config. Owned by <see cref="PackConfigEntry"/>; null when the
/// pack does not participate in gacha-point exchange. Wire shape (from /pack/info):
/// <c>{"pack_id":"10001","gacha_point":0,"increase_gacha_point":"1","exchangeable_gacha_point":400,"is_exchangeable_gacha_point":false}</c>.
/// v1 only persists the static catalog values; per-viewer accrual is deferred.
/// </summary>
[Owned]
public class PackGachaPointConfig
{
public int ExchangeablePoint { get; set; }
public int IncreaseGachaPoint { get; set; }
}

View File

@@ -55,6 +55,8 @@ public class Viewer : BaseEntity<long>
public List<MyPageBackgroundEntry> MyPageBackgrounds { get; set; } = new List<MyPageBackgroundEntry>();
public List<ViewerPackOpenCount> PackOpenCounts { get; set; } = new List<ViewerPackOpenCount>();
#endregion
#region Navigation Properties

View File

@@ -0,0 +1,17 @@
using Microsoft.EntityFrameworkCore;
namespace SVSim.Database.Models;
/// <summary>
/// Per-viewer, per-pack open counter. Owned collection on <see cref="Viewer"/>.
/// <c>PackId</c> = parent_gacha_id. <c>LastDailyFreeAt</c> is null until the viewer first opens
/// a DAILY (type_detail=3) child gacha; thereafter the controller compares it against the daily
/// reset boundary to decide whether the free open is available again.
/// </summary>
[Owned]
public class ViewerPackOpenCount
{
public int PackId { get; set; }
public int OpenCount { get; set; }
public DateTime? LastDailyFreeAt { get; set; }
}

View File

@@ -0,0 +1,13 @@
using SVSim.Database.Models;
namespace SVSim.Database.Repositories.Pack;
public interface IPackRepository
{
Task<List<PackConfigEntry>> GetActivePacks(DateTime now);
Task<PackConfigEntry?> GetPack(int parentGachaId);
Task<Dictionary<int, ViewerPackOpenCount>> GetOpenCountsForViewer(long viewerId);
Task IncrementOpenCount(long viewerId, int parentGachaId, int by);
Task MarkDailyFreeUsed(long viewerId, int parentGachaId, DateTime when);
Task GrantCardsToViewer(long viewerId, IEnumerable<long> cardIds);
}

View File

@@ -0,0 +1,90 @@
using Microsoft.EntityFrameworkCore;
using SVSim.Database.Models;
namespace SVSim.Database.Repositories.Pack;
public class PackRepository : IPackRepository
{
private readonly SVSimDbContext _db;
public PackRepository(SVSimDbContext db) { _db = db; }
public async Task<List<PackConfigEntry>> GetActivePacks(DateTime now) =>
await _db.Packs
.Include(p => p.ChildGachas)
.Include(p => p.Banners)
.Where(p => p.CommenceDate <= now && p.CompleteDate >= now)
.ToListAsync();
public async Task<PackConfigEntry?> GetPack(int parentGachaId) =>
await _db.Packs
.Include(p => p.ChildGachas)
.Include(p => p.Banners)
.FirstOrDefaultAsync(p => p.Id == parentGachaId);
public async Task<Dictionary<int, ViewerPackOpenCount>> GetOpenCountsForViewer(long viewerId)
{
var viewer = await _db.Viewers
.Include(v => v.PackOpenCounts)
.FirstOrDefaultAsync(v => v.Id == viewerId);
return viewer?.PackOpenCounts.ToDictionary(p => p.PackId) ?? new();
}
public async Task IncrementOpenCount(long viewerId, int parentGachaId, int by)
{
var viewer = await _db.Viewers
.Include(v => v.PackOpenCounts)
.FirstAsync(v => v.Id == viewerId);
var row = viewer.PackOpenCounts.FirstOrDefault(p => p.PackId == parentGachaId);
if (row is null)
{
viewer.PackOpenCounts.Add(new ViewerPackOpenCount { PackId = parentGachaId, OpenCount = by });
}
else
{
row.OpenCount += by;
}
await _db.SaveChangesAsync();
}
public async Task MarkDailyFreeUsed(long viewerId, int parentGachaId, DateTime when)
{
var viewer = await _db.Viewers
.Include(v => v.PackOpenCounts)
.FirstAsync(v => v.Id == viewerId);
var row = viewer.PackOpenCounts.FirstOrDefault(p => p.PackId == parentGachaId);
if (row is null)
{
viewer.PackOpenCounts.Add(new ViewerPackOpenCount { PackId = parentGachaId, OpenCount = 0, LastDailyFreeAt = when });
}
else
{
row.LastDailyFreeAt = when;
}
await _db.SaveChangesAsync();
}
public async Task GrantCardsToViewer(long viewerId, IEnumerable<long> cardIds)
{
var viewer = await _db.Viewers
.Include(v => v.Cards).ThenInclude(c => c.Card)
.FirstAsync(v => v.Id == viewerId);
var byId = viewer.Cards.ToDictionary(c => c.Card.Id);
foreach (var grp in cardIds.GroupBy(id => id))
{
if (byId.TryGetValue(grp.Key, out var existing))
{
existing.Count += grp.Count();
}
else
{
// Look up the card by id and attach it so we don't insert a phantom Card row.
var card = await _db.Cards.FirstAsync(c => c.Id == grp.Key);
var owned = new OwnedCardEntry { Card = card, Count = grp.Count(), IsProtected = false };
viewer.Cards.Add(owned);
byId[grp.Key] = owned;
}
}
await _db.SaveChangesAsync();
}
}

View File

@@ -56,6 +56,7 @@ public class SVSimDbContext : DbContext
public DbSet<MasterPointRankingPeriodEntry> MasterPointRankingPeriods => Set<MasterPointRankingPeriodEntry>();
public DbSet<SpecialDeckFormatEntry> SpecialDeckFormats => Set<SpecialDeckFormatEntry>();
public DbSet<PaymentItemEntry> PaymentItems => Set<PaymentItemEntry>();
public DbSet<PackConfigEntry> Packs => Set<PackConfigEntry>();
public DbSet<MaintenanceCardEntry> MaintenanceCards => Set<MaintenanceCardEntry>();
public DbSet<FeatureMaintenanceEntry> FeatureMaintenances => Set<FeatureMaintenanceEntry>();
public DbSet<PreReleaseInfo> PreReleaseInfos => Set<PreReleaseInfo>();
@@ -107,6 +108,10 @@ public class SVSimDbContext : DbContext
.Property(v => v.ShortUdid)
.UseSequence("ShortUdidSequence");
modelBuilder.Entity<PackConfigEntry>().OwnsMany(p => p.ChildGachas);
modelBuilder.Entity<PackConfigEntry>().OwnsMany(p => p.Banners);
modelBuilder.Entity<Viewer>().OwnsMany(v => v.PackOpenCounts);
new BaseDataSeeder().Seed(modelBuilder);
new DefaultSettingsSeeder().Seed(modelBuilder);