More features

This commit is contained in:
gamer147
2026-05-23 14:18:01 -04:00
parent b2024af852
commit 6b70850b7b
59 changed files with 862 additions and 42033 deletions

View File

@@ -1,30 +0,0 @@
using System.Reflection;
using DCGEngine.Database.Models;
using Microsoft.EntityFrameworkCore;
namespace DCGEngine.Database.Configuration;
public class DCGEDatabaseConfiguration
{
/// <summary>
/// The default name of the appsettings section where these are configured.
/// </summary>
public const string DefaultSectionName = "DCGEDatabaseConfiguration";
#region Appsettings
// TODO
#endregion
#region Manual Configuration
/// <summary>
/// Assemblies to be searched for classes implementing <see cref="BaseEntity{TKey}"/> to be added as <see cref="DbSet{TEntity}"/>s. Should be set in code, not in appsettings.
/// </summary>
public List<Assembly> DbSetSearchAssemblies { get; set; } = new List<Assembly>();
#endregion
}

View File

@@ -1,81 +0,0 @@
using DCGEngine.Database.Configuration;
using DCGEngine.Database.Interfaces;
using DCGEngine.Database.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.ChangeTracking;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace DCGEngine.Database;
public class DCGEDbContext : DbContext
{
private readonly DCGEDatabaseConfiguration _configuration;
private readonly ILogger _logger;
public DCGEDbContext(IOptions<DCGEDatabaseConfiguration> configuration, ILogger<DCGEDbContext> logger, DbContextOptions options) : base(options)
{
_logger = logger;
_configuration = configuration.Value;
}
/// <inheritdoc/>
public override async Task<int> SaveChangesAsync(CancellationToken cancellationToken = new CancellationToken())
{
foreach (var entityEntry in ChangeTracker.Entries())
{
if (entityEntry.Entity is ITimeTrackedEntity timeTrackedEntity)
{
if (entityEntry.State is EntityState.Added && timeTrackedEntity.DateCreated == DateTime.MinValue)
{
timeTrackedEntity.DateCreated = DateTime.UtcNow;
}
if (entityEntry.State is EntityState.Modified or EntityState.Added)
{
timeTrackedEntity.DateUpdated = DateTime.UtcNow;
}
}
}
return await base.SaveChangesAsync(cancellationToken);
}
/// <inheritdoc/>
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
_configuration.DbSetSearchAssemblies.ForEach(assembly =>
{
foreach (var typeInfo in assembly.DefinedTypes.Where(type => type.IsAssignableTo(typeof(IDbTrackedEntity))))
{
modelBuilder.Entity(typeInfo.AsType());
}
foreach (var typeInfo in assembly.DefinedTypes.Where(type => type.IsAssignableTo(typeof(IDataSeeder))))
{
((IDataSeeder?)Activator.CreateInstance(typeInfo.AsType()))?.Seed(modelBuilder);
}
});
modelBuilder.Entity<DeckEntry>()
.OwnsMany<DeckCard>(de => de.Cards);
base.OnModelCreating(modelBuilder);
}
public void UpdateDatabase()
{
IEnumerable<string> pendingMigrations = Database.GetPendingMigrations();
if (!pendingMigrations.Any())
{
_logger.LogDebug("No pending migrations found, continuing.");
return;
}
foreach (string migration in pendingMigrations)
{
_logger.LogInformation("Found pending migration with name {migrationName}.", migration);
}
_logger.LogInformation("Attempting to apply pending migrations...");
Database.Migrate();
_logger.LogInformation("Migrations applied.");
}
}

View File

@@ -1,20 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<Folder Include="Attributes\" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.8" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="8.0.8" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.8" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.4" />
</ItemGroup>
</Project>

View File

@@ -1,8 +0,0 @@
using Microsoft.EntityFrameworkCore;
namespace DCGEngine.Database.Interfaces;
public interface IDataSeeder
{
void Seed(ModelBuilder builder);
}

View File

@@ -1,11 +0,0 @@
using Microsoft.EntityFrameworkCore;
namespace DCGEngine.Database.Interfaces;
/// <summary>
/// Indicates a class should have a <see cref="DbSet{TEntity}"/> created and be tracked by the <see cref="DbContext"/>.
/// </summary>
public interface IDbTrackedEntity
{
}

View File

@@ -1,14 +0,0 @@
namespace DCGEngine.Database.Interfaces;
public interface ITimeTrackedEntity
{
/// <summary>
/// The <see cref="DateTime"/> this entity was first added to the database.
/// </summary>
public DateTime DateCreated { get; set; }
/// <summary>
/// The <see cref="DateTime"/> this entity was last updated.
/// </summary>
public DateTime? DateUpdated { get; set; }
}

View File

@@ -1,16 +0,0 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using DCGEngine.Database.Interfaces;
namespace DCGEngine.Database.Models;
public class BaseEntity<TKey> : ITimeTrackedEntity, IDbTrackedEntity
{
[Key]
[DatabaseGenerated(DatabaseGeneratedOption.None)]
public virtual TKey Id { get; set; }
public DateTime DateCreated { get; set; } = DateTime.MinValue;
public DateTime? DateUpdated { get; set; }
}

View File

@@ -1,30 +0,0 @@
using System.ComponentModel.DataAnnotations;
namespace DCGEngine.Database.Models;
/// <summary>
/// A card within the system.
/// </summary>
public abstract class CardEntry : BaseEntity<long>
{
/// <summary>
/// The name of this card used internally, to separate it from any localization key stored.
/// </summary>
[Required(AllowEmptyStrings = false)]
public virtual string Name { get; set; } = string.Empty;
/// <summary>
/// The offensive power of this card.
/// </summary>
public virtual int? Attack { get; set; }
/// <summary>
/// The defensive power of this card.
/// </summary>
public virtual int? Defense { get; set; }
/// <summary>
/// How much of the primary resource (ie mana, energy) does this card cost to play in a match.
/// </summary>
public virtual int? PrimaryResourceCost { get; set; }
}

View File

@@ -1,17 +0,0 @@
namespace DCGEngine.Database.Models;
/// <summary>
/// A set containing 0 or more <see cref="CardEntry"/>s. All cards must belong to a set.
/// </summary>
public abstract class CardSetEntry : BaseEntity<int>
{
/// <summary>
/// The internal name of the set.
/// </summary>
public virtual string Name { get; set; } = string.Empty;
/// <summary>
/// The cards in the set.
/// </summary>
public virtual List<CardEntry> Cards { get; set; } = new List<CardEntry>();
}

View File

@@ -1,25 +0,0 @@
using Microsoft.EntityFrameworkCore;
namespace DCGEngine.Database.Models;
/// <summary>
/// A <see cref="CardEntry"/> that is in a <see cref="DeckEntry"/> X times.
/// </summary>
[Owned]
public class DeckCard
{
/// <summary>
/// The card in the deck.
/// </summary>
public virtual CardEntry Card { get; set; }
/// <summary>
/// The number of the card that is in the deck.
/// </summary>
public virtual int Count { get; set; }
/// <summary>
/// The deck this belongs to.
/// </summary>
public virtual DeckEntry Deck { get; set; }
}

View File

@@ -1,22 +0,0 @@
using System.ComponentModel.DataAnnotations;
using DCGEngine.Database.Interfaces;
using Microsoft.EntityFrameworkCore;
namespace DCGEngine.Database.Models;
/// <summary>
/// A deck consisting of multiple <see cref="CardEntry"/> stored in the DB.
/// </summary>
public abstract class DeckEntry : BaseEntity<Guid>
{
/// <summary>
/// How this deck is referred to internally.
/// </summary>
[Required(AllowEmptyStrings = false)]
public virtual string Name { get; set; } = string.Empty;
/// <summary>
/// The cards present in this deck.
/// </summary>
public virtual List<DeckCard> Cards { get; set; } = new List<DeckCard>();
}

View File

@@ -1,33 +1,24 @@

Microsoft Visual Studio Solution File, Format Version 12.00
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SVSim.Content", "SVSim.Content\SVSim.Content.csproj", "{7D990EA3-0A15-4A71-A992-7E0FC8F4F677}"
EndProject
#
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SVSim.EmulatedEntrypoint", "SVSim.EmulatedEntrypoint\SVSim.EmulatedEntrypoint.csproj", "{B345A858-043F-404E-9D98-B5A6CA2EACA9}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DCGEngine.Database", "DCGEngine.Database\DCGEngine.Database.csproj", "{8D5DF264-F1A7-4455-837A-EC64849E0AE3}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SVSim.Database", "SVSim.Database\SVSim.Database.csproj", "{9CE5D4F0-0D98-4E1C-942F-D692978F6103}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SVSim.UnitTests", "SVSim.UnitTests\SVSim.UnitTests.csproj", "{00E87101-F286-46F3-858E-83AB1CEBF8D1}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SVSim.CardImport", "SVSim.CardImport\SVSim.CardImport.csproj", "{666786D9-9A4D-49EA-A759-39055C57F9AA}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{7D990EA3-0A15-4A71-A992-7E0FC8F4F677}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{7D990EA3-0A15-4A71-A992-7E0FC8F4F677}.Debug|Any CPU.Build.0 = Debug|Any CPU
{7D990EA3-0A15-4A71-A992-7E0FC8F4F677}.Release|Any CPU.ActiveCfg = Release|Any CPU
{7D990EA3-0A15-4A71-A992-7E0FC8F4F677}.Release|Any CPU.Build.0 = Release|Any CPU
{B345A858-043F-404E-9D98-B5A6CA2EACA9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{B345A858-043F-404E-9D98-B5A6CA2EACA9}.Debug|Any CPU.Build.0 = Debug|Any CPU
{B345A858-043F-404E-9D98-B5A6CA2EACA9}.Release|Any CPU.ActiveCfg = Release|Any CPU
{B345A858-043F-404E-9D98-B5A6CA2EACA9}.Release|Any CPU.Build.0 = Release|Any CPU
{8D5DF264-F1A7-4455-837A-EC64849E0AE3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{8D5DF264-F1A7-4455-837A-EC64849E0AE3}.Debug|Any CPU.Build.0 = Debug|Any CPU
{8D5DF264-F1A7-4455-837A-EC64849E0AE3}.Release|Any CPU.ActiveCfg = Release|Any CPU
{8D5DF264-F1A7-4455-837A-EC64849E0AE3}.Release|Any CPU.Build.0 = Release|Any CPU
{9CE5D4F0-0D98-4E1C-942F-D692978F6103}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{9CE5D4F0-0D98-4E1C-942F-D692978F6103}.Debug|Any CPU.Build.0 = Debug|Any CPU
{9CE5D4F0-0D98-4E1C-942F-D692978F6103}.Release|Any CPU.ActiveCfg = Release|Any CPU
@@ -36,5 +27,9 @@ Global
{00E87101-F286-46F3-858E-83AB1CEBF8D1}.Debug|Any CPU.Build.0 = Debug|Any CPU
{00E87101-F286-46F3-858E-83AB1CEBF8D1}.Release|Any CPU.ActiveCfg = Release|Any CPU
{00E87101-F286-46F3-858E-83AB1CEBF8D1}.Release|Any CPU.Build.0 = Release|Any CPU
{666786D9-9A4D-49EA-A759-39055C57F9AA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{666786D9-9A4D-49EA-A759-39055C57F9AA}.Debug|Any CPU.Build.0 = Debug|Any CPU
{666786D9-9A4D-49EA-A759-39055C57F9AA}.Release|Any CPU.ActiveCfg = Release|Any CPU
{666786D9-9A4D-49EA-A759-39055C57F9AA}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
EndGlobal

View File

@@ -1,5 +0,0 @@
namespace SVSim.Content;
public class Class1
{
}

View File

@@ -1,9 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>

View File

@@ -1,7 +1,7 @@
using System.Globalization;
using CsvHelper;
using CsvHelper.Configuration;
using DCGEngine.Database.Interfaces;
using SVSim.Database.Common;
using Microsoft.EntityFrameworkCore;
using SVSim.Database.Models;
@@ -12,6 +12,17 @@ namespace SVSim.Database.DataSeeders;
/// </summary>
public class BaseDataSeeder : IDataSeeder
{
private static string DataPath(string fileName) =>
Path.Combine(AppContext.BaseDirectory, "Data", fileName);
private static List<T> ReadCsv<T, TMap>(string fileName) where TMap : ClassMap<T>, new()
{
using StreamReader reader = new(DataPath(fileName));
using CsvReader csv = new(reader, CultureInfo.InvariantCulture);
csv.Context.RegisterClassMap<TMap>();
return csv.GetRecords<T>().ToList();
}
private class ClassEntryMap : ClassMap<ClassEntry>
{
public ClassEntryMap()
@@ -108,99 +119,33 @@ public class BaseDataSeeder : IDataSeeder
public void Seed(ModelBuilder builder)
{
List<ClassEntry> classes = new();
List<LeaderSkinEntry> leaderSkins = new();
List<EmblemEntry> emblems = new();
List<DegreeEntry> degrees = new();
List<SleeveEntry> sleeves = new();
List<BattlefieldEntry> battlefields = new();
List<MyPageBackgroundEntry> myPageBackgrounds = new();
List<ClassExpEntry> classexp = new();
List<RankInfoEntry> rankinfos = new();
using (StreamReader reader = new("data/classes.csv"))
// Migrations bake the HasData rows into InsertData calls — once the migration is
// generated, runtime model-creation no longer needs the CSVs. Tools that only query
// an already-migrated DB (e.g. SVSim.CardImport) don't ship the Data folder; skip
// gracefully so DbContext construction succeeds for them.
if (!File.Exists(DataPath("classes.csv")))
{
using CsvReader csv = new(reader, CultureInfo.InvariantCulture);
csv.Context.RegisterClassMap<ClassEntryMap>();
IEnumerable<ClassEntry> records = csv.GetRecords<ClassEntry>();
classes.AddRange(records);
}
using (StreamReader reader = new("data/leaderskins.csv"))
{
using CsvReader csv = new(reader, CultureInfo.InvariantCulture);
csv.Context.RegisterClassMap<LeaderSkinEntryMap>();
IEnumerable<LeaderSkinEntry> records = csv.GetRecords<LeaderSkinEntry>();
leaderSkins.AddRange(records);
leaderSkins.ForEach(skin =>
{
if (skin.ClassId == 0)
{
skin.ClassId = null;
}
});
Console.Error.WriteLine($"[BaseDataSeeder] Skipping seed: Data folder not found at {DataPath("")}");
return;
}
// Load rest of default data
using (StreamReader reader = new("data/emblems.csv"))
List<ClassEntry> classes = ReadCsv<ClassEntry, ClassEntryMap>("classes.csv");
List<LeaderSkinEntry> leaderSkins = ReadCsv<LeaderSkinEntry, LeaderSkinEntryMap>("leaderskins.csv");
leaderSkins.ForEach(skin =>
{
using CsvReader csv = new(reader, CultureInfo.InvariantCulture);
csv.Context.RegisterClassMap<EmblemEntryMap>();
IEnumerable<EmblemEntry> records = csv.GetRecords<EmblemEntry>();
emblems.AddRange(records);
}
using (StreamReader reader = new("data/degrees.csv"))
{
using CsvReader csv = new(reader, CultureInfo.InvariantCulture);
csv.Context.RegisterClassMap<DegreeEntryMap>();
IEnumerable<DegreeEntry> records = csv.GetRecords<DegreeEntry>();
degrees.AddRange(records);
}
using (StreamReader reader = new("data/sleeves.csv"))
{
using CsvReader csv = new(reader, CultureInfo.InvariantCulture);
csv.Context.RegisterClassMap<SleeveEntryMap>();
IEnumerable<SleeveEntry> records = csv.GetRecords<SleeveEntry>();
sleeves.AddRange(records);
}
using (StreamReader reader = new("data/battlefields.csv"))
{
using CsvReader csv = new(reader, CultureInfo.InvariantCulture);
csv.Context.RegisterClassMap<BattlefieldEntryMap>();
IEnumerable<BattlefieldEntry> records = csv.GetRecords<BattlefieldEntry>();
battlefields.AddRange(records);
}
using (StreamReader reader = new("data/mypagebackgrounds.csv"))
{
using CsvReader csv = new(reader, CultureInfo.InvariantCulture);
csv.Context.RegisterClassMap<MyPageBackgroundEntryMap>();
IEnumerable<MyPageBackgroundEntry> records = csv.GetRecords<MyPageBackgroundEntry>();
myPageBackgrounds.AddRange(records);
}
using (StreamReader reader = new("data/ranks.csv"))
{
using CsvReader csv = new(reader, CultureInfo.InvariantCulture);
csv.Context.RegisterClassMap<RankInfoEntryMap>();
IEnumerable<RankInfoEntry> records = csv.GetRecords<RankInfoEntry>();
rankinfos.AddRange(records);
}
using (StreamReader reader = new("data/classexp.csv"))
{
using CsvReader csv = new(reader, CultureInfo.InvariantCulture);
csv.Context.RegisterClassMap<ClassExpEntryMap>();
IEnumerable<ClassExpEntry> records = csv.GetRecords<ClassExpEntry>();
classexp.AddRange(records);
}
if (skin.ClassId == 0)
{
skin.ClassId = null;
}
});
List<EmblemEntry> emblems = ReadCsv<EmblemEntry, EmblemEntryMap>("emblems.csv");
List<DegreeEntry> degrees = ReadCsv<DegreeEntry, DegreeEntryMap>("degrees.csv");
List<SleeveEntry> sleeves = ReadCsv<SleeveEntry, SleeveEntryMap>("sleeves.csv");
List<BattlefieldEntry> battlefields = ReadCsv<BattlefieldEntry, BattlefieldEntryMap>("battlefields.csv");
List<MyPageBackgroundEntry> myPageBackgrounds = ReadCsv<MyPageBackgroundEntry, MyPageBackgroundEntryMap>("mypagebackgrounds.csv");
List<RankInfoEntry> rankinfos = ReadCsv<RankInfoEntry, RankInfoEntryMap>("ranks.csv");
List<ClassExpEntry> classexp = ReadCsv<ClassExpEntry, ClassExpEntryMap>("classexp.csv");
builder.Entity<ClassEntry>().HasData(classes);
builder.Entity<LeaderSkinEntry>().HasData(leaderSkins);
@@ -212,4 +157,4 @@ public class BaseDataSeeder : IDataSeeder
builder.Entity<RankInfoEntry>().HasData(rankinfos);
builder.Entity<ClassExpEntry>().HasData(classexp);
}
}
}

View File

@@ -1,4 +1,4 @@
using DCGEngine.Database.Interfaces;
using SVSim.Database.Common;
using Microsoft.EntityFrameworkCore;
using SVSim.Database.Models;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -25,78 +25,6 @@ namespace SVSim.Database.Migrations
modelBuilder.HasSequence("ShortUdidSequence")
.StartsAt(400000000L);
modelBuilder.Entity("DCGEngine.Database.Models.CardEntry", b =>
{
b.Property<long>("Id")
.HasColumnType("bigint");
b.Property<int?>("Attack")
.HasColumnType("integer");
b.Property<DateTime>("DateCreated")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("DateUpdated")
.HasColumnType("timestamp with time zone");
b.Property<int?>("Defense")
.HasColumnType("integer");
b.Property<string>("Discriminator")
.IsRequired()
.HasMaxLength(21)
.HasColumnType("character varying(21)");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
b.Property<int?>("PrimaryResourceCost")
.HasColumnType("integer");
b.Property<int?>("ShadowverseCardSetEntryId")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("ShadowverseCardSetEntryId");
b.ToTable("CardEntry");
b.HasDiscriminator().HasValue("CardEntry");
b.UseTphMappingStrategy();
});
modelBuilder.Entity("DCGEngine.Database.Models.DeckEntry", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<DateTime>("DateCreated")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("DateUpdated")
.HasColumnType("timestamp with time zone");
b.Property<string>("Discriminator")
.IsRequired()
.HasMaxLength(21)
.HasColumnType("character varying(21)");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.ToTable("DeckEntry");
b.HasDiscriminator().HasValue("DeckEntry");
b.UseTphMappingStrategy();
});
modelBuilder.Entity("DegreeEntryViewer", b =>
{
b.Property<int>("DegreesId")
@@ -177,7 +105,7 @@ namespace SVSim.Database.Migrations
b.HasKey("Id");
b.ToTable("BattlefieldEntry");
b.ToTable("Battlefields");
b.HasData(
new
@@ -311,7 +239,7 @@ namespace SVSim.Database.Migrations
b.HasKey("Id");
b.ToTable("ClassEntry");
b.ToTable("Classes");
b.HasData(
new
@@ -380,7 +308,7 @@ namespace SVSim.Database.Migrations
b.HasKey("Id");
b.ToTable("ClassExpEntry");
b.ToTable("ClassExpCurve");
b.HasData(
new
@@ -1298,7 +1226,7 @@ namespace SVSim.Database.Migrations
b.HasKey("Id");
b.ToTable("DegreeEntry");
b.ToTable("Degrees");
b.HasData(
new
@@ -10396,7 +10324,7 @@ namespace SVSim.Database.Migrations
b.HasKey("Id");
b.ToTable("EmblemEntry");
b.ToTable("Emblems");
b.HasData(
new
@@ -21516,7 +21444,7 @@ namespace SVSim.Database.Migrations
b.HasIndex("DefaultSleeveId");
b.ToTable("GameConfiguration");
b.ToTable("GameConfigurations");
b.HasData(
new
@@ -21551,7 +21479,7 @@ namespace SVSim.Database.Migrations
b.HasKey("Id");
b.ToTable("ItemEntry");
b.ToTable("Items");
});
modelBuilder.Entity("SVSim.Database.Models.LeaderSkinEntry", b =>
@@ -21579,7 +21507,7 @@ namespace SVSim.Database.Migrations
b.HasIndex("ClassId");
b.ToTable("LeaderSkinEntry");
b.ToTable("LeaderSkins");
b.HasData(
new
@@ -24929,7 +24857,7 @@ namespace SVSim.Database.Migrations
b.HasKey("Id");
b.ToTable("MyPageBackgroundEntry");
b.ToTable("MyPageBackgrounds");
b.HasData(
new
@@ -25081,7 +25009,7 @@ namespace SVSim.Database.Migrations
b.HasKey("Id");
b.ToTable("RankInfoEntry");
b.ToTable("RankInfo");
b.HasData(
new
@@ -25695,6 +25623,48 @@ namespace SVSim.Database.Migrations
});
});
modelBuilder.Entity("SVSim.Database.Models.ShadowverseCardEntry", b =>
{
b.Property<long>("Id")
.HasColumnType("bigint");
b.Property<int?>("Attack")
.HasColumnType("integer");
b.Property<int?>("ClassId")
.HasColumnType("integer");
b.Property<DateTime>("DateCreated")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("DateUpdated")
.HasColumnType("timestamp with time zone");
b.Property<int?>("Defense")
.HasColumnType("integer");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
b.Property<int?>("PrimaryResourceCost")
.HasColumnType("integer");
b.Property<int>("Rarity")
.HasColumnType("integer");
b.Property<int?>("ShadowverseCardSetEntryId")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("ClassId");
b.HasIndex("ShadowverseCardSetEntryId");
b.ToTable("Cards");
});
modelBuilder.Entity("SVSim.Database.Models.ShadowverseCardSetEntry", b =>
{
b.Property<int>("Id")
@@ -25718,7 +25688,56 @@ namespace SVSim.Database.Migrations
b.HasKey("Id");
b.ToTable("ShadowverseCardSetEntry");
b.ToTable("CardSets");
});
modelBuilder.Entity("SVSim.Database.Models.ShadowverseDeckEntry", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<int>("ClassId")
.HasColumnType("integer");
b.Property<DateTime>("DateCreated")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("DateUpdated")
.HasColumnType("timestamp with time zone");
b.Property<int>("Format")
.HasColumnType("integer");
b.Property<int>("LeaderSkinId")
.HasColumnType("integer");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
b.Property<int>("Number")
.HasColumnType("integer");
b.Property<bool>("RandomLeaderSkin")
.HasColumnType("boolean");
b.Property<int>("SleeveId")
.HasColumnType("integer");
b.Property<long?>("ViewerId")
.HasColumnType("bigint");
b.HasKey("Id");
b.HasIndex("ClassId");
b.HasIndex("LeaderSkinId");
b.HasIndex("SleeveId");
b.HasIndex("ViewerId");
b.ToTable("Decks");
});
modelBuilder.Entity("SVSim.Database.Models.SleeveEntry", b =>
@@ -25734,7 +25753,7 @@ namespace SVSim.Database.Migrations
b.HasKey("Id");
b.ToTable("SleeveEntry");
b.ToTable("Sleeves");
b.HasData(
new
@@ -33281,7 +33300,7 @@ namespace SVSim.Database.Migrations
b.HasIndex("ShortUdid");
b.ToTable("Viewer");
b.ToTable("Viewers");
});
modelBuilder.Entity("SleeveEntryViewer", b =>
@@ -33299,106 +33318,6 @@ namespace SVSim.Database.Migrations
b.ToTable("SleeveEntryViewer");
});
modelBuilder.Entity("SVSim.Database.Models.ShadowverseCardEntry", b =>
{
b.HasBaseType("DCGEngine.Database.Models.CardEntry");
b.Property<int?>("ClassId")
.HasColumnType("integer");
b.Property<int>("Rarity")
.HasColumnType("integer");
b.HasIndex("ClassId");
b.HasDiscriminator().HasValue("ShadowverseCardEntry");
});
modelBuilder.Entity("SVSim.Database.Models.ShadowverseDeckEntry", b =>
{
b.HasBaseType("DCGEngine.Database.Models.DeckEntry");
b.Property<int>("ClassId")
.HasColumnType("integer");
b.Property<int>("Format")
.HasColumnType("integer");
b.Property<int>("LeaderSkinId")
.HasColumnType("integer");
b.Property<int>("Number")
.HasColumnType("integer");
b.Property<bool>("RandomLeaderSkin")
.HasColumnType("boolean");
b.Property<int>("SleeveId")
.HasColumnType("integer");
b.Property<long?>("ViewerId")
.HasColumnType("bigint");
b.HasIndex("ClassId");
b.HasIndex("LeaderSkinId");
b.HasIndex("SleeveId");
b.HasIndex("ViewerId");
b.HasDiscriminator().HasValue("ShadowverseDeckEntry");
});
modelBuilder.Entity("DCGEngine.Database.Models.CardEntry", b =>
{
b.HasOne("SVSim.Database.Models.ShadowverseCardSetEntry", null)
.WithMany("Cards")
.HasForeignKey("ShadowverseCardSetEntryId");
});
modelBuilder.Entity("DCGEngine.Database.Models.DeckEntry", b =>
{
b.OwnsMany("DCGEngine.Database.Models.DeckCard", "Cards", b1 =>
{
b1.Property<Guid>("DeckId")
.HasColumnType("uuid");
b1.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b1.Property<int>("Id"));
b1.Property<long>("CardId")
.HasColumnType("bigint");
b1.Property<int>("Count")
.HasColumnType("integer");
b1.HasKey("DeckId", "Id");
b1.HasIndex("CardId");
b1.ToTable("DeckEntry_Cards");
b1.HasOne("DCGEngine.Database.Models.CardEntry", "Card")
.WithMany()
.HasForeignKey("CardId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b1.WithOwner("Deck")
.HasForeignKey("DeckId");
b1.Navigation("Card");
b1.Navigation("Deck");
});
b.Navigation("Cards");
});
modelBuilder.Entity("DegreeEntryViewer", b =>
{
b.HasOne("SVSim.Database.Models.DegreeEntry", null)
@@ -33503,6 +33422,108 @@ namespace SVSim.Database.Migrations
b.Navigation("Class");
});
modelBuilder.Entity("SVSim.Database.Models.ShadowverseCardEntry", b =>
{
b.HasOne("SVSim.Database.Models.ClassEntry", "Class")
.WithMany()
.HasForeignKey("ClassId");
b.HasOne("SVSim.Database.Models.ShadowverseCardSetEntry", null)
.WithMany("Cards")
.HasForeignKey("ShadowverseCardSetEntryId");
b.OwnsOne("SVSim.Database.Models.CardCollectionInfo", "CollectionInfo", b1 =>
{
b1.Property<long>("ShadowverseCardEntryId")
.HasColumnType("bigint");
b1.Property<int>("CraftCost")
.HasColumnType("integer");
b1.Property<int>("DustReward")
.HasColumnType("integer");
b1.HasKey("ShadowverseCardEntryId");
b1.ToTable("Cards");
b1.WithOwner()
.HasForeignKey("ShadowverseCardEntryId");
});
b.Navigation("Class");
b.Navigation("CollectionInfo");
});
modelBuilder.Entity("SVSim.Database.Models.ShadowverseDeckEntry", b =>
{
b.HasOne("SVSim.Database.Models.ClassEntry", "Class")
.WithMany()
.HasForeignKey("ClassId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("SVSim.Database.Models.LeaderSkinEntry", "LeaderSkin")
.WithMany()
.HasForeignKey("LeaderSkinId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("SVSim.Database.Models.SleeveEntry", "Sleeve")
.WithMany()
.HasForeignKey("SleeveId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("SVSim.Database.Models.Viewer", null)
.WithMany("Decks")
.HasForeignKey("ViewerId");
b.OwnsMany("SVSim.Database.Models.DeckCard", "Cards", b1 =>
{
b1.Property<Guid>("ShadowverseDeckEntryId")
.HasColumnType("uuid");
b1.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b1.Property<int>("Id"));
b1.Property<long>("CardId")
.HasColumnType("bigint");
b1.Property<int>("Count")
.HasColumnType("integer");
b1.HasKey("ShadowverseDeckEntryId", "Id");
b1.HasIndex("CardId");
b1.ToTable("DeckCard");
b1.HasOne("SVSim.Database.Models.ShadowverseCardEntry", "Card")
.WithMany()
.HasForeignKey("CardId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b1.WithOwner()
.HasForeignKey("ShadowverseDeckEntryId");
b1.Navigation("Card");
});
b.Navigation("Cards");
b.Navigation("Class");
b.Navigation("LeaderSkin");
b.Navigation("Sleeve");
});
modelBuilder.Entity("SVSim.Database.Models.Viewer", b =>
{
b.OwnsMany("SVSim.Database.Models.OwnedCardEntry", "Cards", b1 =>
@@ -33531,7 +33552,7 @@ namespace SVSim.Database.Migrations
b1.ToTable("OwnedCardEntry");
b1.HasOne("DCGEngine.Database.Models.CardEntry", "Card")
b1.HasOne("SVSim.Database.Models.ShadowverseCardEntry", "Card")
.WithMany()
.HasForeignKey("CardId")
.OnDelete(DeleteBehavior.Cascade)
@@ -33694,7 +33715,7 @@ namespace SVSim.Database.Migrations
b1.HasKey("ViewerId");
b1.ToTable("Viewer");
b1.ToTable("Viewers");
b1.WithOwner()
.HasForeignKey("ViewerId");
@@ -33733,7 +33754,7 @@ namespace SVSim.Database.Migrations
b1.HasIndex("SelectedEmblemId");
b1.ToTable("Viewer");
b1.ToTable("Viewers");
b1.HasOne("SVSim.Database.Models.DegreeEntry", "SelectedDegree")
.WithMany()
@@ -33774,7 +33795,7 @@ namespace SVSim.Database.Migrations
b1.HasKey("ViewerId");
b1.ToTable("Viewer");
b1.ToTable("Viewers");
b1.WithOwner()
.HasForeignKey("ViewerId");
@@ -33813,67 +33834,6 @@ namespace SVSim.Database.Migrations
.IsRequired();
});
modelBuilder.Entity("SVSim.Database.Models.ShadowverseCardEntry", b =>
{
b.HasOne("SVSim.Database.Models.ClassEntry", "Class")
.WithMany()
.HasForeignKey("ClassId");
b.OwnsOne("SVSim.Database.Models.CardCollectionInfo", "CollectionInfo", b1 =>
{
b1.Property<long>("ShadowverseCardEntryId")
.HasColumnType("bigint");
b1.Property<int>("CraftCost")
.HasColumnType("integer");
b1.Property<int>("DustReward")
.HasColumnType("integer");
b1.HasKey("ShadowverseCardEntryId");
b1.ToTable("CardEntry");
b1.WithOwner()
.HasForeignKey("ShadowverseCardEntryId");
});
b.Navigation("Class");
b.Navigation("CollectionInfo");
});
modelBuilder.Entity("SVSim.Database.Models.ShadowverseDeckEntry", b =>
{
b.HasOne("SVSim.Database.Models.ClassEntry", "Class")
.WithMany()
.HasForeignKey("ClassId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("SVSim.Database.Models.LeaderSkinEntry", "LeaderSkin")
.WithMany()
.HasForeignKey("LeaderSkinId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("SVSim.Database.Models.SleeveEntry", "Sleeve")
.WithMany()
.HasForeignKey("SleeveId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("SVSim.Database.Models.Viewer", null)
.WithMany("Decks")
.HasForeignKey("ViewerId");
b.Navigation("Class");
b.Navigation("LeaderSkin");
b.Navigation("Sleeve");
});
modelBuilder.Entity("SVSim.Database.Models.ClassEntry", b =>
{
b.Navigation("LeaderSkins");

View File

@@ -1,4 +1,4 @@
using DCGEngine.Database.Models;
using SVSim.Database.Common;
namespace SVSim.Database.Models;

View File

@@ -1,5 +1,5 @@
using System.ComponentModel.DataAnnotations.Schema;
using DCGEngine.Database.Models;
using SVSim.Database.Common;
namespace SVSim.Database.Models;

View File

@@ -1,4 +1,4 @@
using DCGEngine.Database.Models;
using SVSim.Database.Common;
namespace SVSim.Database.Models;

View File

@@ -1,4 +1,4 @@
using DCGEngine.Database.Models;
using SVSim.Database.Common;
namespace SVSim.Database.Models;

View File

@@ -1,4 +1,4 @@
using DCGEngine.Database.Models;
using SVSim.Database.Common;
namespace SVSim.Database.Models;

View File

@@ -1,4 +1,4 @@
using DCGEngine.Database.Models;
using SVSim.Database.Common;
namespace SVSim.Database.Models;

View File

@@ -1,4 +1,4 @@
using DCGEngine.Database.Models;
using SVSim.Database.Common;
namespace SVSim.Database.Models;

View File

@@ -1,4 +1,4 @@
using DCGEngine.Database.Models;
using SVSim.Database.Common;
namespace SVSim.Database.Models;

View File

@@ -1,4 +1,4 @@
using DCGEngine.Database.Models;
using SVSim.Database.Common;
namespace SVSim.Database.Models;

View File

@@ -1,4 +1,3 @@
using DCGEngine.Database.Models;
using Microsoft.EntityFrameworkCore;
namespace SVSim.Database.Models;
@@ -9,7 +8,7 @@ namespace SVSim.Database.Models;
[Owned]
public class OwnedCardEntry
{
public CardEntry Card { get; set; } = new ShadowverseCardEntry();
public ShadowverseCardEntry Card { get; set; } = new ShadowverseCardEntry();
public int Count { get; set; }
public bool IsProtected { get; set; }
}
}

View File

@@ -1,4 +1,4 @@
using DCGEngine.Database.Models;
using SVSim.Database.Common;
namespace SVSim.Database.Models;

View File

@@ -1,12 +1,32 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using DCGEngine.Database.Models;
using SVSim.Database.Common;
using SVSim.Database.Enums;
namespace SVSim.Database.Models;
public class ShadowverseCardEntry : CardEntry
public class ShadowverseCardEntry : BaseEntity<long>
{
/// <summary>
/// The internal name of this card (not the localized display name).
/// </summary>
[Required(AllowEmptyStrings = false)]
public string Name { get; set; } = string.Empty;
/// <summary>
/// Attack stat (atk on the wire).
/// </summary>
public int? Attack { get; set; }
/// <summary>
/// Life / defense stat (life on the wire).
/// </summary>
public int? Defense { get; set; }
/// <summary>
/// Play cost (cost on the wire).
/// </summary>
public int? PrimaryResourceCost { get; set; }
/// <summary>
/// The rarity of this card.
/// </summary>
@@ -24,9 +44,9 @@ public class ShadowverseCardEntry : CardEntry
#region Navigation Properties
/// <summary>
/// The class this card belongs to, or optionally none for neutral cards.
/// The class this card belongs to, or null for neutral cards.
/// </summary>
public ClassEntry? Class { get; set; }
#endregion
}
}

View File

@@ -1,10 +1,19 @@
using System.ComponentModel.DataAnnotations.Schema;
using DCGEngine.Database.Models;
using SVSim.Database.Common;
namespace SVSim.Database.Models;
public class ShadowverseCardSetEntry : CardSetEntry
public class ShadowverseCardSetEntry : BaseEntity<int>
{
/// <summary>
/// The internal name of the set.
/// </summary>
public string Name { get; set; } = string.Empty;
/// <summary>
/// The cards in the set.
/// </summary>
public List<ShadowverseCardEntry> Cards { get; set; } = new List<ShadowverseCardEntry>();
public bool IsInRotation { get; set; }
public bool IsBasic { get; set; }
}
}

View File

@@ -1,11 +1,22 @@
using DCGEngine.Database.Models;
using Microsoft.EntityFrameworkCore;
using System.ComponentModel.DataAnnotations;
using SVSim.Database.Common;
using SVSim.Database.Enums;
namespace SVSim.Database.Models;
public class ShadowverseDeckEntry : DeckEntry
public class ShadowverseDeckEntry : BaseEntity<Guid>
{
/// <summary>
/// Internal deck name.
/// </summary>
[Required(AllowEmptyStrings = false)]
public string Name { get; set; } = string.Empty;
/// <summary>
/// Cards in this deck.
/// </summary>
public List<DeckCard> Cards { get; set; } = new List<DeckCard>();
public int Number { get; set; }
public Format Format { get; set; }
public bool RandomLeaderSkin { get; set; }
@@ -19,4 +30,4 @@ public class ShadowverseDeckEntry : DeckEntry
public LeaderSkinEntry LeaderSkin { get; set; } = new LeaderSkinEntry();
#endregion
}
}

View File

@@ -1,4 +1,4 @@
using DCGEngine.Database.Models;
using SVSim.Database.Common;
namespace SVSim.Database.Models;

View File

@@ -1,5 +1,3 @@
using System.ComponentModel.DataAnnotations.Schema;
using DCGEngine.Database.Models;
using Microsoft.EntityFrameworkCore;
using SVSim.Database.Enums;

View File

@@ -1,5 +1,5 @@
using System.ComponentModel.DataAnnotations.Schema;
using DCGEngine.Database.Models;
using SVSim.Database.Common;
using Microsoft.EntityFrameworkCore;
namespace SVSim.Database.Models;

View File

@@ -1,4 +1,3 @@
using DCGEngine.Database.Models;
using Microsoft.EntityFrameworkCore;
using SVSim.Database.Models;
@@ -19,7 +18,7 @@ public class CardRepository : BaseRepository<ShadowverseCardEntry>, ICardReposit
public async Task<List<ShadowverseCardEntry>> GetAllBasic()
{
return await DbContext.Set<ShadowverseCardSetEntry>().Where(set => set.IsBasic).SelectMany(set => set.Cards)
.Cast<ShadowverseCardEntry>().ToListAsync();
.ToListAsync();
}
public async Task<List<ShadowverseCardSetEntry>> GetCardSets(bool onlyInRotation)

View File

@@ -19,11 +19,12 @@ public class ViewerRepository : IViewerRepository
public async Task<Models.Viewer?> GetViewerBySocialConnection(SocialAccountType accountType, ulong socialId)
{
return (await _dbContext.Set<SocialAccountConnection>()
.AsNoTracking()
.Include(sac => sac.Viewer)
.FirstOrDefaultAsync(sac => sac.AccountType == accountType && sac.AccountId == socialId))
?.Viewer;
// SocialAccountConnection is [Owned]-by-Viewer — can't be queried as a top-level Set<T>.
// Look up the Viewer that has a matching owned connection instead.
return await _dbContext.Set<Models.Viewer>()
.AsNoTracking()
.FirstOrDefaultAsync(v => v.SocialAccountConnections.Any(sac =>
sac.AccountType == accountType && sac.AccountId == socialId));
}
public async Task<Models.Viewer?> GetViewerWithSocials(long id)
@@ -32,10 +33,31 @@ public class ViewerRepository : IViewerRepository
.FirstOrDefaultAsync(viewer => viewer.Id == id);
}
/// <summary>
/// Loads a viewer with every navigation property needed to render the home-screen
/// (/load/index). Heavy query — only call from LoadController.Index.
/// </summary>
public async Task<Models.Viewer?> GetViewerByShortUdid(long shortUdid)
{
return await _dbContext.Set<Models.Viewer>().AsNoTracking().Include(viewer => viewer.MissionData)
.Include(viewer => viewer.Info).FirstOrDefaultAsync(viewer => viewer.ShortUdid == shortUdid);
return await _dbContext.Set<Models.Viewer>()
.AsNoTracking()
.Include(v => v.MissionData)
.Include(v => v.Info).ThenInclude(i => i.SelectedEmblem)
.Include(v => v.Info).ThenInclude(i => i.SelectedDegree)
.Include(v => v.Currency)
.Include(v => v.Classes).ThenInclude(c => c.Class).ThenInclude(c => c.LeaderSkins)
.Include(v => v.Classes).ThenInclude(c => c.LeaderSkin)
.Include(v => v.Decks).ThenInclude(d => d.Class)
.Include(v => v.Decks).ThenInclude(d => d.Sleeve)
.Include(v => v.Decks).ThenInclude(d => d.LeaderSkin)
.Include(v => v.Cards).ThenInclude(c => c.Card)
.Include(v => v.Items).ThenInclude(i => i.Item)
.Include(v => v.Sleeves)
.Include(v => v.Emblems)
.Include(v => v.Degrees)
.Include(v => v.LeaderSkins).ThenInclude(ls => ls.Class)
.Include(v => v.MyPageBackgrounds)
.FirstOrDefaultAsync(viewer => viewer.ShortUdid == shortUdid);
}
public async Task<Models.Viewer> RegisterViewer(string displayName, SocialAccountType socialType,
@@ -46,7 +68,7 @@ public class ViewerRepository : IViewerRepository
DisplayName = displayName
};
GameConfiguration gameConfig = await new GlobalsRepository(_dbContext).GetGameConfiguration("default");
viewer.SocialAccountConnections.Add(new SocialAccountConnection
{
AccountId = socialAccountIdentifier,
@@ -61,23 +83,40 @@ public class ViewerRepository : IViewerRepository
viewer.Currency.RedEther = gameConfig.DefaultEther;
viewer.MissionData.TutorialState = 100; // finishes tutorial for now
List<ClassEntry> classes = await _dbContext.Set<ClassEntry>().ToListAsync();
viewer.Classes = classes.Select(ce => new ViewerClassData
// Load classes WITH their LeaderSkins — DefaultLeaderSkin iterates the nav collection
// and would otherwise be null (audit §6 #3 latent NRE — this is the one).
List<ClassEntry> classes = await _dbContext.Set<ClassEntry>()
.Include(c => c.LeaderSkins)
.ToListAsync();
viewer.Classes = classes.Select(ce =>
{
Class = ce,
Exp = 0,
Level = 0,
LeaderSkin = ce.DefaultLeaderSkin
var skin = ce.DefaultLeaderSkin ?? ce.LeaderSkins.FirstOrDefault();
return new ViewerClassData
{
Class = ce,
Exp = 0,
Level = 0,
LeaderSkin = skin ?? new LeaderSkinEntry { Id = 0, Name = "<missing>", ClassId = ce.Id }
};
}).ToList();
viewer.Sleeves.Add(gameConfig.DefaultSleeve);
viewer.Degrees.Add(gameConfig.DefaultDegree);
viewer.Emblems.Add(gameConfig.DefaultEmblem);
viewer.MyPageBackgrounds.Add(gameConfig.DefaultMyPageBackground);
viewer.LeaderSkins.AddRange(viewer.Classes.Select(vcd => vcd.LeaderSkin));
if (gameConfig.DefaultSleeve is not null) viewer.Sleeves.Add(gameConfig.DefaultSleeve);
if (gameConfig.DefaultDegree is not null) viewer.Degrees.Add(gameConfig.DefaultDegree);
if (gameConfig.DefaultEmblem is not null) viewer.Emblems.Add(gameConfig.DefaultEmblem);
if (gameConfig.DefaultMyPageBackground is not null) viewer.MyPageBackgrounds.Add(gameConfig.DefaultMyPageBackground);
// Grant one of each class's default leader skin. Filter out the synthetic placeholders
// (Id=0) and dedupe — skins are many-to-many via SleeveEntryViewer-style join.
var grantedSkins = viewer.Classes
.Select(vcd => vcd.LeaderSkin)
.Where(s => s.Id != 0)
.DistinctBy(s => s.Id)
.ToList();
viewer.LeaderSkins.AddRange(grantedSkins);
_dbContext.Set<Models.Viewer>().Add(viewer);
await _dbContext.SaveChangesAsync();
return viewer;
}
}
}

View File

@@ -6,16 +6,11 @@
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\DCGEngine.Database\DCGEngine.Database.csproj" />
</ItemGroup>
<ItemGroup>
<Folder Include="Data\" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="CsvHelper" Version="33.0.1" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.8" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="8.0.8" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.4" />
</ItemGroup>
</Project>

View File

@@ -1,30 +1,108 @@
using DCGEngine.Database;
using DCGEngine.Database.Configuration;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using SVSim.Database.Common;
using SVSim.Database.DataSeeders;
using SVSim.Database.Models;
namespace SVSim.Database;
public class SVSimDbContext : DCGEDbContext
public class SVSimDbContext : DbContext
{
public SVSimDbContext(IOptions<DCGEDatabaseConfiguration> configuration, ILogger<DCGEDbContext> logger, DbContextOptions options) : base(configuration, logger, options)
private readonly ILogger<SVSimDbContext> _logger;
public SVSimDbContext(ILogger<SVSimDbContext> logger, DbContextOptions<SVSimDbContext> options) : base(options)
{
_logger = logger;
}
#region DbSets
public DbSet<Viewer> Viewers => Set<Viewer>();
public DbSet<ShadowverseCardEntry> Cards => Set<ShadowverseCardEntry>();
public DbSet<ShadowverseCardSetEntry> CardSets => Set<ShadowverseCardSetEntry>();
public DbSet<ShadowverseDeckEntry> Decks => Set<ShadowverseDeckEntry>();
public DbSet<ClassEntry> Classes => Set<ClassEntry>();
public DbSet<ClassExpEntry> ClassExpCurve => Set<ClassExpEntry>();
public DbSet<LeaderSkinEntry> LeaderSkins => Set<LeaderSkinEntry>();
public DbSet<SleeveEntry> Sleeves => Set<SleeveEntry>();
public DbSet<EmblemEntry> Emblems => Set<EmblemEntry>();
public DbSet<DegreeEntry> Degrees => Set<DegreeEntry>();
public DbSet<MyPageBackgroundEntry> MyPageBackgrounds => Set<MyPageBackgroundEntry>();
public DbSet<BattlefieldEntry> Battlefields => Set<BattlefieldEntry>();
public DbSet<RankInfoEntry> RankInfo => Set<RankInfoEntry>();
public DbSet<ItemEntry> Items => Set<ItemEntry>();
public DbSet<GameConfiguration> GameConfigurations => Set<GameConfiguration>();
#endregion
public override async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
{
foreach (var entityEntry in ChangeTracker.Entries())
{
if (entityEntry.Entity is ITimeTrackedEntity timeTrackedEntity)
{
if (entityEntry.State is EntityState.Added && timeTrackedEntity.DateCreated == DateTime.MinValue)
{
timeTrackedEntity.DateCreated = DateTime.UtcNow;
}
if (entityEntry.State is EntityState.Modified or EntityState.Added)
{
timeTrackedEntity.DateUpdated = DateTime.UtcNow;
}
}
}
return await base.SaveChangesAsync(cancellationToken);
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
modelBuilder.Entity<ShadowverseDeckEntry>()
.OwnsMany(de => de.Cards);
// For whatever reason it cannot figure out this relationship on it's own
// BaseEntity<TKey> annotates Id with [DatabaseGenerated(None)] for the integer-PK
// entities seeded via HasData. ShadowverseDeckEntry uses Guid and is created at
// runtime — without client-side generation every new deck gets Guid.Empty and the
// second deck insert collides on PK. (DDL has no column default; this only works
// because EF generates a sequential Guid before INSERT.)
modelBuilder.Entity<ShadowverseDeckEntry>()
.Property(d => d.Id)
.ValueGeneratedOnAdd();
// EF can't figure this many-to-many out on its own
modelBuilder.Entity<SleeveEntry>()
.HasMany<Viewer>(se => se.Viewers)
.HasMany(se => se.Viewers)
.WithMany(v => v.Sleeves);
modelBuilder.HasSequence<long>("ShortUdidSequence").StartsAt(400000000);
modelBuilder.Entity<Viewer>()
.Property(v => v.ShortUdid)
.UseSequence("ShortUdidSequence");
new BaseDataSeeder().Seed(modelBuilder);
new DefaultSettingsSeeder().Seed(modelBuilder);
base.OnModelCreating(modelBuilder);
}
}
public void UpdateDatabase()
{
IEnumerable<string> pendingMigrations = Database.GetPendingMigrations();
if (!pendingMigrations.Any())
{
_logger.LogDebug("No pending migrations found, continuing.");
return;
}
foreach (string migration in pendingMigrations)
{
_logger.LogInformation("Found pending migration with name {migrationName}.", migration);
}
_logger.LogInformation("Attempting to apply pending migrations...");
Database.Migrate();
_logger.LogInformation("Migrations applied.");
}
}

View File

@@ -1,11 +1,5 @@
using System.Buffers.Text;
using System.Text;
using MessagePack;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Newtonsoft.Json;
using Newtonsoft.Json.Serialization;
using SVSim.Database.Models;
using SVSim.Database.Repositories.Viewer;
using SVSim.EmulatedEntrypoint.Extensions;
@@ -13,64 +7,68 @@ using SVSim.EmulatedEntrypoint.Models.Dtos;
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests;
using SVSim.EmulatedEntrypoint.Models.Dtos.Responses;
namespace SVSim.EmulatedEntrypoint.Controllers
namespace SVSim.EmulatedEntrypoint.Controllers;
public class CheckController : SVSimController
{
[Route("api/[controller]")]
[ApiController]
public class CheckController : SVSimController
private readonly ILogger _logger;
private readonly IViewerRepository _viewerRepository;
public CheckController(ILogger<CheckController> logger, IViewerRepository viewerRepository)
{
private readonly ILogger _logger;
private readonly IViewerRepository _viewerRepository;
public CheckController(ILogger<CheckController> logger, IViewerRepository viewerRepository)
{
_logger = logger;
_viewerRepository = viewerRepository;
}
[AllowAnonymous]
[HttpPost("special_title")]
public async Task<SpecialTitleCheckResponse> SpecialTitleCheck(SpecialTitleCheckRequest request)
{
int titleId = Random.Shared.Next(8, 33);
var res = new SpecialTitleCheckResponse
{
TitleImageId = titleId,
TitleSoundId = titleId
};
return res;
}
[HttpPost("game_start")]
public async Task<GameStartResponse> GameStart(GameStartRequest request)
{
Viewer? viewer = await _viewerRepository.GetViewerWithSocials(HttpContext.GetViewer().Id);
return new GameStartResponse()
{
IsSetTransitionPassword = true,
KorAuthorityId = default,
KorAuthorityState = default,
NowRank = new Dictionary<string, string>()
{
{ "1", "RankName_010" },
{ "2", "RankName_010" },
{ "4", "RankName_017" }
},
NowName = viewer.DisplayName,
PolicyState = default,
PolicyId = default,
NowTutorialStep = "100",
NowViewerId = viewer.Id,
TosId = default,
TosState = default,
TransitionAccountData = viewer.SocialAccountConnections.Select(sac => new TransitionAccountData
{
ConnectedViewerId = viewer.Id.ToString(),
SocialAccountId = sac.AccountId.ToString(),
SocialAccountType = ((int)sac.AccountType).ToString()
}).ToList()
};
}
_logger = logger;
_viewerRepository = viewerRepository;
}
}
[AllowAnonymous]
[HttpPost("special_title")]
public Task<SpecialTitleCheckResponse> SpecialTitleCheck(SpecialTitleCheckRequest request)
{
return Task.FromResult(new SpecialTitleCheckResponse
{
TitleImageId = "0"
});
}
// TODO: spec lists this as anonymous (identity from SHORT_UDID), but the base controller's
// [Authorize] still applies. For now requires a Steam-linked viewer; new-user bootstrap (where
// the server creates a viewer + returns rewrite_viewer_id) is deferred until the boot flow is
// exercised end-to-end with a real client.
[HttpPost("game_start")]
public async Task<GameStartResponse> GameStart(GameStartRequest request)
{
Viewer viewer = HttpContext.GetViewer()
?? throw new InvalidOperationException("Auth handler must set viewer in context.");
Viewer fullViewer = await _viewerRepository.GetViewerWithSocials(viewer.Id) ?? viewer;
return new GameStartResponse
{
NowViewerId = fullViewer.Id,
NowName = fullViewer.DisplayName,
NowTutorialStep = fullViewer.MissionData.TutorialState.ToString(),
IsSetTransitionPassword = true,
// Stub rank map until per-format ranks are persisted (prod observed: "1"/"2"/"4"
// keys mapping to RankName_010 / RankName_017). Empty dict here may be safe but
// we don't yet know which client paths read this — match prod stub.
NowRank = new Dictionary<string, string>
{
{ "1", "RankName_010" },
{ "2", "RankName_010" },
{ "4", "RankName_017" }
},
TransitionAccountData = fullViewer.SocialAccountConnections
.Select(sac => new TransitionAccountData
{
SocialAccountId = sac.AccountId.ToString(),
SocialAccountType = ((int)sac.AccountType).ToString(),
ConnectedViewerId = fullViewer.Id.ToString()
}).ToList(),
TosState = 1,
PolicyState = 1,
KorAuthorityState = 0,
TosId = 1,
PolicyId = 1,
KorAuthorityId = 0
};
}
}

View File

@@ -14,12 +14,29 @@ namespace SVSim.EmulatedEntrypoint.Controllers;
public class LoadController : SVSimController
{
// Per-format rank entries the wire expects (5 entries, in deck_format discriminator order).
// Hard-coded until viewer rank-state is persisted (see audit §6 #1).
private static readonly Format[] RankFormats =
{
Format.Rotation, Format.Unlimited, Format.MyRotation, Format.Avatar, Format.Crossover
};
// Until ShadowverseCardSetEntry is seeded by CardImport, hard-code a stub so the client
// doesn't crash on RotationCardSetList[1] / [Count-1] (LoadDetail.cs:184).
private static readonly List<CardSetIdentifier> StubRotationSets = new()
{
new CardSetIdentifier { SetId = 10000 },
new CardSetIdentifier { SetId = 10005 },
new CardSetIdentifier { SetId = 10010 }
};
private readonly IViewerRepository _viewerRepository;
private readonly ICardRepository _cardRepository;
private readonly ICollectionRepository _collectionRepository;
private readonly IGlobalsRepository _globalsRepository;
public LoadController(IViewerRepository viewerRepository, ICardRepository cardRepository, ICollectionRepository collectionRepository, IGlobalsRepository globalsRepository)
public LoadController(IViewerRepository viewerRepository, ICardRepository cardRepository,
ICollectionRepository collectionRepository, IGlobalsRepository globalsRepository)
{
_viewerRepository = viewerRepository;
_cardRepository = cardRepository;
@@ -30,29 +47,30 @@ public class LoadController : SVSimController
[HttpPost("index")]
public async Task<ActionResult<IndexResponse>> Index(IndexRequest request)
{
Viewer? viewer = await _viewerRepository.GetViewerByShortUdid(long.Parse(User.Claims
.FirstOrDefault(claim => claim.Type == ShadowverseClaimTypes.ShortUdidClaim).Value));
if (viewer == null)
var shortUdidClaim = User.Claims.FirstOrDefault(c => c.Type == ShadowverseClaimTypes.ShortUdidClaim)?.Value;
if (shortUdidClaim is null || !long.TryParse(shortUdidClaim, out long shortUdid))
{
return Unauthorized();
}
Viewer? viewer = await _viewerRepository.GetViewerByShortUdid(shortUdid);
if (viewer is null)
{
return NotFound();
}
// Cards. Empty until CardImport lands (audit §3 — user_card_list is blocked).
List<ShadowverseCardEntry> allCollectibleCards = await _cardRepository.GetAll(true);
List<ShadowverseCardEntry> allBasicCards = await _cardRepository.GetAllBasic();
List<OwnedCardEntry> ownedCards = viewer.Cards;
List<OwnedCardEntry> allCardsAsOwned = allCollectibleCards.GroupJoin(ownedCards, card => card.Id,
List<OwnedCardEntry> allCardsAsOwned = allCollectibleCards.GroupJoin(ownedCards,
card => card.Id,
ownedCard => ownedCard.Card.Id,
(card, foundOwnedCards) =>
(card, foundOwnedCards) => foundOwnedCards.DefaultIfEmpty().FirstOrDefault() ?? new OwnedCardEntry
{
OwnedCardEntry ownedCard = foundOwnedCards.DefaultIfEmpty().FirstOrDefault() ?? new OwnedCardEntry
{
Card = card,
Count = 0,
IsProtected = false
};
return ownedCard;
Card = card,
Count = 0,
IsProtected = false
}).ToList();
allCardsAsOwned = allCardsAsOwned.Union(allBasicCards.Select(bc => new OwnedCardEntry
{
@@ -60,14 +78,11 @@ public class LoadController : SVSimController
Count = 3,
IsProtected = true
})).ToList();
List<LeaderSkinEntry> allLeaderSkins = await _collectionRepository.GetLeaderSkins();
Dictionary<string, ClassExp> classExp = new Dictionary<string, ClassExp>();
List<LeaderSkinEntry> allLeaderSkins = await _collectionRepository.GetLeaderSkins();
var classExpCurve = await _globalsRepository.GetClassExpCurve();
List<ClassExp> classExps = new List<ClassExp>();
List<ClassExp> classExps = new();
int accumulateExp = 0;
int? prevNecessaryExp = null;
foreach (var entry in classExpCurve)
@@ -75,7 +90,7 @@ public class LoadController : SVSimController
accumulateExp += entry.NecessaryExp;
classExps.Add(new ClassExp
{
Level = entry.Id, // You need to specify the level value based on your logic
Level = entry.Id,
NecessaryExp = entry.NecessaryExp,
DiffExp = prevNecessaryExp.HasValue ? entry.NecessaryExp - prevNecessaryExp.Value : entry.NecessaryExp,
AccumulateExp = accumulateExp
@@ -83,37 +98,53 @@ public class LoadController : SVSimController
prevNecessaryExp = entry.NecessaryExp;
}
List<CardSetIdentifier> rotationSets = (await _cardRepository.GetCardSets(true))
.Select(set => new CardSetIdentifier { SetId = set.Id })
.ToList();
if (rotationSets.Count < 2)
{
rotationSets = StubRotationSets;
}
var deviceHeader = Request.Headers["DEVICE"].FirstOrDefault();
int deviceType = int.TryParse(deviceHeader, out int parsed) ? parsed : 0;
return new IndexResponse
{
UserTutorial = new UserTutorial
{
TutorialStep = viewer.MissionData.TutorialState
},
UserInfo = new UserInfo(int.Parse(Request.Headers["DEVICE"].FirstOrDefault()), viewer),
UserInfo = new UserInfo(deviceType, viewer),
UserCurrency = new UserCurrency(viewer),
UserItems = viewer.Items.Select(item => new UserItem(item)).ToList(),
UserRotationDecks = new UserFormatDeckInfo
{
UserDecks = viewer.Decks.Where(deck => deck.Format == Format.Rotation).Select(deck => new UserDeck(deck)).ToList()
UserDecks = viewer.Decks.Where(d => d.Format == Format.Rotation)
.Select(d => new UserDeck(d)).ToList()
},
UserUnlimitedDecks = new UserFormatDeckInfo
{
UserDecks = viewer.Decks.Where(deck => deck.Format == Format.Unlimited).Select(deck => new UserDeck(deck)).ToList()
UserDecks = viewer.Decks.Where(d => d.Format == Format.Unlimited)
.Select(d => new UserDeck(d)).ToList()
},
UserMyRotationDecks = new UserFormatDeckInfo
{
UserDecks = viewer.Decks.Where(deck => deck.Format == Format.MyRotation).Select(deck => new UserDeck(deck)).ToList()
UserDecks = viewer.Decks.Where(d => d.Format == Format.MyRotation)
.Select(d => new UserDeck(d)).ToList()
},
UserCards = allCardsAsOwned.Select(card => new UserCard(card)).ToList(),
UserClasses = viewer.Classes.Select(viewerClass => new UserClass(viewerClass)).ToList(),
Sleeves = viewer.Sleeves.ToDictionary(sleeve => sleeve.Id.ToString(), sleeve => new SleeveIdentifier { SleeveId = sleeve.Id }),
UserEmblems = viewer.Emblems.Select(emblem => new EmblemIdentifier { EmblemId = emblem.Id }).ToList(),
UserDegrees = viewer.Degrees.Select(degree => new DegreeIdentifier { DegreeId = degree.Id }).ToList(),
LeaderSkins = allLeaderSkins.ToDictionary(skin => skin.Id.ToString(), skin => new UserLeaderSkin(skin, viewer.LeaderSkins.Any(vs => vs.Id == skin.Id))),
MyPageBackgrounds = viewer.MyPageBackgrounds.Select(mpbg => mpbg.Id).ToList(),
UserClasses = viewer.Classes.Select(vc => new UserClass(vc)).ToList(),
Sleeves = viewer.Sleeves.Select(s => new SleeveIdentifier { SleeveId = s.Id }).ToList(),
UserEmblems = viewer.Emblems.Select(e => new EmblemIdentifier { EmblemId = e.Id }).ToList(),
UserDegrees = viewer.Degrees.Select(d => new DegreeIdentifier { DegreeId = d.Id }).ToList(),
LeaderSkins = allLeaderSkins
.Select(skin => new UserLeaderSkin(skin, viewer.LeaderSkins.Any(vs => vs.Id == skin.Id)))
.ToList(),
MyPageBackgrounds = viewer.MyPageBackgrounds.Select(mpbg => mpbg.Id.ToString()).ToList(),
LootBoxRegulations = new LootBoxRegulations(),
GatheringInfo = new GatheringInfo(),
IsBattlePassPeriod = false,
IsBattlePassPeriod = 0,
BattlePassLevelInfo = null,
SpecialCrystalInfos = new List<SpecialCrystalInfo>(),
AvatarRotationInfo = null,
@@ -121,22 +152,35 @@ public class LoadController : SVSimController
FeatureMaintenances = new List<FeatureMaintenance>(),
PreReleaseInfo = null,
SpotCards = new Dictionary<string, int>(),
ReprintedCards = new Dictionary<string, long>(),
ReprintedCards = new List<long>(),
UnlimitedBanList = new Dictionary<string, int>(),
LoadingTipCardExclusions = new List<long>(),
MaintenanceCards = new List<CardIdentifier>(),
MaintenanceCards = new List<long>(),
RedEtherOverrides = new List<RedEtherOverride>(),
DailyLoginBonus = new DailyLoginBonus(),
UserRankedMatches = new List<UserRankedMatches>(),
UserRankInfo = new Dictionary<string, UserRankInfo>(),
UserRankInfo = RankFormats.Select(f => new UserRankInfo
{
DeckFormat = (int)f,
Rank = 1,
BattlePoints = 0,
WinStreak = 0,
IsPromotion = 0,
IsMasterRank = 0,
IsGrandMasterRank = 0,
MasterPoints = 0
}).ToList(),
ArenaConfig = new ArenaConfig(),
ArenaInfos = new List<ArenaInfo>(),
RotationSets = (await _cardRepository.GetCardSets(true)).Select(set => new CardSetIdentifier { SetId = set.Id }).ToList(),
RotationSets = rotationSets,
UserConfig = new UserConfig(),
OpenBattlefieldIds = (await _globalsRepository.GetBattlefields(true)).ToDictionary(bf => bf.Id.ToString(), bf => bf.Id),
OpenBattlefieldIds = (await _globalsRepository.GetBattlefields(true))
.Select(bf => bf.Id.ToString()).ToList(),
DefaultSettings = new DefaultSettings(await _globalsRepository.GetGameConfiguration("default")),
ClassExp = classExps.ToDictionary(kv => kv.Level.ToString(), kv => kv),
RankInfo = (await _globalsRepository.GetRankInfo()).Select(ri => new RankInfo(ri)).ToDictionary(ri => ri.RankId.ToString(), ri => ri)
ClassExp = classExps,
RankInfo = (await _globalsRepository.GetRankInfo()).Select(ri => new RankInfo(ri)).ToList(),
DeckFormat = 1,
CardSetIdForResourceDlView = rotationSets.Last().SetId
};
}
}
}

View File

@@ -9,7 +9,7 @@ namespace SVSim.EmulatedEntrypoint.Controllers
/// <summary>
/// A base controller for SVSim with helpers for getting some values.
/// </summary>
[Route("api/[controller]")]
[Route("[controller]")]
[ApiController]
[Authorize(AuthenticationSchemes = SteamAuthenticationConstants.SchemeName)]
public abstract class SVSimController : ControllerBase

View File

@@ -1,26 +0,0 @@
using Microsoft.AspNetCore.Mvc.ApplicationModels;
namespace SVSim.EmulatedEntrypoint.Conventions;
/// <summary>
/// Ensures controllers go to the correct swagger definition.
/// </summary>
public class SwaggerDefinitionConvention : IControllerModelConvention
{
/// <inheritdoc/>
public void Apply(ControllerModel controller)
{
var controllerNamespace = controller.ControllerType.Namespace; // eg. Controllers.V1
var swaggerDefinition = controllerNamespace.Split('.').Last().ToLower();
const string defaultRoute = "api/[controller]";
controller.ApiExplorer.GroupName = swaggerDefinition;
foreach (SelectorModel selector in controller.Selectors)
{
if (selector.AttributeRouteModel != null && selector.AttributeRouteModel.Template == defaultRoute)
{
selector.AttributeRouteModel.Template = $"api/{swaggerDefinition}/[controller]";
}
}
}
}

View File

@@ -6,5 +6,5 @@ namespace SVSim.EmulatedEntrypoint.Models.Dtos;
public class EmblemIdentifier
{
[Key("emblem_id")]
public int EmblemId { get; set; }
public long EmblemId { get; set; }
}

View File

@@ -1,7 +1,13 @@
using MessagePack;
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Requests;
[MessagePackObject]
public class IndexRequest : BaseRequest
{
[Key("carrier")]
public string Carrier { get; set; }
[Key("card_master_hash")]
public string CardMasterHash { get; set; }
}
}

View File

@@ -2,31 +2,83 @@ using MessagePack;
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Responses;
/// <summary>
/// Wire-shape mirrors production's <c>/check/game_start</c> response. Several fields here are
/// NOT read by <c>Cute/GameStartCheckTask.Parse</c> (<c>now_viewer_id</c>, <c>now_name</c>,
/// <c>now_rank</c> — those are consumed by sibling tasks); they're included because prod sends
/// them and the boot worked when we matched prod exactly. Removing them is a regression risk
/// even though the parse-time decompile says they're unused.
/// </summary>
[MessagePackObject]
public class GameStartResponse
{
/// <summary>The signed-in viewer's internal id. Prod always sends.</summary>
[Key("now_viewer_id")]
public long NowViewerId { get; set; }
/// <summary>
/// Whether the user has set a data-transfer password. Prod sends a non-null bool;
/// <c>GameStartCheckTask.Parse</c> gates the read with <c>Keys.Contains</c>.
/// </summary>
[Key("is_set_transition_password")]
public bool IsSetTransitionPassword { get; set; }
/// <summary>Viewer display name. Not read by GameStartCheckTask but sent by prod.</summary>
[Key("now_name")]
public string NowName { get; set; }
public string NowName { get; set; } = string.Empty;
/// <summary>
/// Per-format rank-name map keyed by deck-format id ("1", "2", "4" observed in prod).
/// Stub for now until rank state is persisted; pinned to RankName_010 / RankName_017
/// (matches prod's shape).
/// </summary>
[Key("now_rank")]
public Dictionary<string, string> NowRank { get; set; }
public Dictionary<string, string> NowRank { get; set; } = new();
/// <summary>
/// Tutorial progress — **sent as a string on the wire** ("100" = tutorial complete).
/// <c>GameStartCheckTask.Parse</c> calls <c>.ToInt()</c> so LitJson coerces.
/// </summary>
[Key("now_tutorial_step")]
public string NowTutorialStep { get; set; }
public string NowTutorialStep { get; set; } = "100";
/// <summary>
/// Linked social accounts. Per-entry shape in <see cref="TransitionAccountData"/>.
/// </summary>
[Key("transition_account_data")]
public List<TransitionAccountData> TransitionAccountData { get; set; }
public List<TransitionAccountData> TransitionAccountData { get; set; } = new();
// INTENTIONALLY OMITTED: `rewrite_viewer_id` and `account_delete_reservation_status`.
// Both are presence-checked by the client via `Keys.Contains(...)` + `.ToInt()` with no
// null guard. MessagePack-CSharp writes [Key] properties unconditionally (null → Nil),
// and the System.Text.Json `WhenWritingNull` ignore only affects the plain-JSON path.
// So including these as nullable properties is a guaranteed NRE on the encrypted client
// path. We don't need them — the client tolerates their absence — so don't declare them.
// Re-add only if we have a real value to send.
// --- Agreement / consent state (all required) ---
/// <summary><c>PlayerStaticData.AgreementState</c> enum.</summary>
[Key("tos_state")]
public int TosState { get; set; }
[Key("tos_id")]
public int TosId { get; set; }
/// <summary><c>PlayerStaticData.AgreementState</c> enum.</summary>
[Key("policy_state")]
public int PolicyState { get; set; }
[Key("policy_id")]
public int PolicyId { get; set; }
[Key("kor_authority_id")]
public int KorAuthorityId { get; set; }
/// <summary><c>PlayerStaticData.AgreementState</c> enum.</summary>
[Key("kor_authority_state")]
public int KorAuthorityState { get; set; }
}
/// <summary>Current Terms of Service document id.</summary>
[Key("tos_id")]
public int TosId { get; set; }
/// <summary>Current Privacy Policy document id.</summary>
[Key("policy_id")]
public int PolicyId { get; set; }
/// <summary>Current Korean authority consent document id.</summary>
[Key("kor_authority_id")]
public int KorAuthorityId { get; set; }
}

View File

@@ -8,10 +8,6 @@ public class IndexResponse
{
#region Primitive Returns
[Key("ts_card_rotation")]
public string TsCardRotation { get; set; } = string.Empty;
[Key("is_beginner_mission")]
public int IsBeginnerMission { get; set; }
[Key("spot_point")]
public int SpotPoint { get; set; }
[Key("is_available_colosseum_free_entry")]
@@ -23,29 +19,22 @@ public class IndexResponse
[Key("room_recovery_status")]
public int RoomRecoveryStatus { get; set; }
[Key("is_battle_pass_period")]
public bool IsBattlePassPeriod { get; set; }
public int IsBattlePassPeriod { get; set; }
[Key("card_set_id_for_resource_dl_view")]
public int CardSetIdForResourceDlView { get; set; }
[Key("deck_format")]
public int DeckFormat { get; set; } = 1;
#endregion
#region Basic User Data
/// <summary>
/// The user's tutorial progress state.
/// </summary>
[Key("user_tutorial")]
public UserTutorial UserTutorial { get; set; } = new UserTutorial();
/// <summary>
/// Basic information about the user.
/// </summary>
[Key("user_info")]
public UserInfo UserInfo { get; set; } = new UserInfo();
/// <summary>
/// The in-game currency information for this user.
/// </summary>
[Key("user_crystal_count")]
public UserCurrency UserCurrency { get; set; } = new UserCurrency();
@@ -53,234 +42,163 @@ public class IndexResponse
#region Inventory Data
/// <summary>
/// Items that the user has and how many of each.
/// </summary>
[Key("user_item_list")]
public List<UserItem> UserItems { get; set; } = new List<UserItem>();
/// <summary>
/// Decks for the rotation format.
/// </summary>
[Key("user_item_list")]
public List<UserItem> UserItems { get; set; } = new();
[Key("user_deck_rotation")]
public UserFormatDeckInfo UserRotationDecks { get; set; } = new UserFormatDeckInfo();
public UserFormatDeckInfo UserRotationDecks { get; set; } = new();
/// <summary>
/// Decks for the unlimited format.
/// </summary>
[Key("user_deck_unlimited")]
public UserFormatDeckInfo UserUnlimitedDecks { get; set; } = new UserFormatDeckInfo();
/// <summary>
/// Decks for the unlimited format.
/// </summary>
public UserFormatDeckInfo UserUnlimitedDecks { get; set; } = new();
[Key("user_deck_my_rotation")]
public UserFormatDeckInfo UserMyRotationDecks { get; set; } = new UserFormatDeckInfo();
public UserFormatDeckInfo UserMyRotationDecks { get; set; } = new();
/// <summary>
/// The list of cards and how many of each this user has.
/// </summary>
[Key("user_card_list")]
public List<UserCard> UserCards { get; set; } = new List<UserCard>();
public List<UserCard> UserCards { get; set; } = new();
/// <summary>
/// The classes a user has and their stats.
/// </summary>
[Key("user_class_list")]
public List<UserClass> UserClasses { get; set; } = new List<UserClass>();
public List<UserClass> UserClasses { get; set; } = new();
/// <summary>
/// Mapping of SleeveId to a <see cref="SleeveIdentifier"/> object.
/// Wire is an array; parser iterates by index (LoadDetail.cs:358-360).
/// </summary>
[Key("user_sleeve_list")]
public Dictionary<string, SleeveIdentifier> Sleeves { get; set; } = new Dictionary<string, SleeveIdentifier>();
public List<SleeveIdentifier> Sleeves { get; set; } = new();
/// <summary>
/// The emblems available to this user.
/// </summary>
[Key("user_emblem_list")]
public List<EmblemIdentifier> UserEmblems { get; set; } = new List<EmblemIdentifier>();
public List<EmblemIdentifier> UserEmblems { get; set; } = new();
[Key("user_degree_list")]
public List<DegreeIdentifier> UserDegrees { get; set; } = new();
/// <summary>
/// The degrees available to this user.
/// </summary>
[Key("degree_id")]
public List<DegreeIdentifier> UserDegrees { get; set; } = new List<DegreeIdentifier>();
/// <summary>
/// Leader skins available to the leader.
/// Wire is an array; parser iterates by index (LoadDetail.cs:348-356).
/// </summary>
[Key("user_leader_skin_list")]
public Dictionary<string, UserLeaderSkin> LeaderSkins { get; set; } = new Dictionary<string, UserLeaderSkin>();
public List<UserLeaderSkin> LeaderSkins { get; set; } = new();
/// <summary>
/// Backgrounds for 'My Page' the user has collected.
/// Wire is string[]; parser calls .ToString() on each element (LoadDetail.cs:387-392).
/// </summary>
[Key("user_mypage_list")]
public List<int> MyPageBackgrounds { get; set; } = new List<int>();
public List<string> MyPageBackgrounds { get; set; } = new();
#endregion
#region Advanced Player Data
/// <summary>
/// Maps a deck format (as a string) to info about ranked for that format.
/// Wire is an array of 5 entries; parser uses deck_format as discriminator
/// (LoadDetail.cs:527-538).
/// </summary>
[Key("user_rank")]
public Dictionary<string, UserRankInfo> UserRankInfo { get; set; } = new Dictionary<string, UserRankInfo>();
public List<UserRankInfo> UserRankInfo { get; set; } = new();
/// <summary>
/// The number of ranked matches for each class the user has played.
/// </summary>
[Key("user_rank_match_list")]
public List<UserRankedMatches> UserRankedMatches { get; set; } = new List<UserRankedMatches>();
public List<UserRankedMatches> UserRankedMatches { get; set; } = new();
/// <summary>
/// The daily login bonuses currently going on, including if the player should receive the next reward for any.
/// </summary>
[Key("daily_login_bonus")]
public DailyLoginBonus DailyLoginBonus { get; set; } = new DailyLoginBonus();
public DailyLoginBonus DailyLoginBonus { get; set; } = new();
/// <summary>
/// User configuration for the arena.
/// </summary>
[Key("challenge_config")]
public ArenaConfig ArenaConfig { get; set; } = new ArenaConfig();
public ArenaConfig ArenaConfig { get; set; } = new();
#endregion
#region Global Data
/// <summary>
/// Cards that have had their red ether values overriden.
/// </summary>
[Key("red_ether_overwrite_list")]
public List<RedEtherOverride> RedEtherOverrides { get; set; } = new List<RedEtherOverride>();
public List<RedEtherOverride> RedEtherOverrides { get; set; } = new();
/// <summary>
/// Cards that are currently undergoing maintenance.
/// Wire is a flat number[]; parser passes it straight to SetMaintenanceCardIds
/// (LoadDetail.cs:165).
/// </summary>
[Key("maintenance_card_list")]
public List<CardIdentifier> MaintenanceCards { get; set; } = new List<CardIdentifier>();
/// <summary>
/// The arena formats currently available.
/// </summary>
public List<long> MaintenanceCards { get; set; } = new();
[Key("arena_info")]
public List<ArenaInfo> ArenaInfos { get; set; } = new List<ArenaInfo>();
public List<ArenaInfo> ArenaInfos { get; set; } = new();
/// <summary>
/// Dictionary of rank id to information about that rank.
/// Wire is an array; client uses POSITIONAL logic (index >= 24 = master ranks,
/// LoadDetail.cs:417-422). Order must match repository's ordering.
/// </summary>
[Key("rank_info")]
public Dictionary<string, RankInfo> RankInfo { get; set; } = new Dictionary<string, RankInfo>();
public List<RankInfo> RankInfo { get; set; } = new();
/// <summary>
/// Dictionary mapping a class level to information about that level.
/// Wire is an array; parser iterates by index (LoadDetail.cs:425-434).
/// </summary>
[Key("class_exp")]
public Dictionary<string, ClassExp> ClassExp { get; set; } = new Dictionary<string, ClassExp>();
public List<ClassExp> ClassExp { get; set; } = new();
/// <summary>
/// Card ids that should not show up on loading screen tips.
/// </summary>
[Key("loading_exclusion_card_list")]
public List<long> LoadingTipCardExclusions { get; set; } = new List<long>();
public List<long> LoadingTipCardExclusions { get; set; } = new();
/// <summary>
/// The default settings for every user.
/// </summary>
[Key("default_setting")]
public DefaultSettings DefaultSettings { get; set; } = new DefaultSettings();
public DefaultSettings DefaultSettings { get; set; } = new();
/// <summary>
/// Any cards that are restricted in unlimited, and the maximum count that can be run of the card.
/// </summary>
[Key("unlimited_restricted_base_card_id_list")]
public Dictionary<string, int> UnlimitedBanList { get; set; } = new Dictionary<string, int>();
public Dictionary<string, int> UnlimitedBanList { get; set; } = new();
/// <summary>
/// Sets currently available in rotation.
/// Client unconditionally accesses [1] and [Count-1] (LoadDetail.cs:184) — list MUST
/// have at least 2 entries or the client crashes.
/// </summary>
[Key("rotation_card_set_id_list")]
public List<CardSetIdentifier> RotationSets { get; set; } = new List<CardSetIdentifier>();
public List<CardSetIdentifier> RotationSets { get; set; } = new();
/// <summary>
/// Allows cards out of your 'My Rotation' to still be used. TODO investigate
/// Wire is a flat number[]; parser iterates and reads .ToInt() (LoadDetail.cs:463-468).
/// </summary>
[Key("reprinted_base_card_ids")]
public Dictionary<string, long> ReprintedCards { get; set; } = new Dictionary<string, long>();
public List<long> ReprintedCards { get; set; } = new();
/// <summary>
/// Something to do with destroying cards. TODO investigate
/// </summary>
[Key("spot_cards")]
public Dictionary<string, int> SpotCards { get; set; } = new Dictionary<string, int>();
public Dictionary<string, int> SpotCards { get; set; } = new();
/// <summary>
/// Info about the next set to be released? TODO investigate
/// </summary>
[Key("pre_release_info")]
public PreReleaseInfo? PreReleaseInfo { get; set; }
/// <summary>
/// Information for the 'My Rotation' mode.
/// </summary>
[Key("my_rotation_info")]
public MyRotationInfo? MyRotationInfo { get; set; }
/// <summary>
/// Information about some avatar mode? TODO investigate
/// </summary>
[Key("avatar_info")]
public MyRotationInfo? AvatarRotationInfo { get; set; }
/// <summary>
/// List of features that are undergoing maintenance.
/// </summary>
[Key("feature_maintenance_list")]
public List<FeatureMaintenance> FeatureMaintenances { get; set; } = new List<FeatureMaintenance>();
/// <summary>
/// Special deals on crystal purchases.
/// </summary>
public List<FeatureMaintenance> FeatureMaintenances { get; set; } = new();
[Key("special_crystal_info")]
public List<SpecialCrystalInfo> SpecialCrystalInfos { get; set; } = new List<SpecialCrystalInfo>();
/// <summary>
/// Current battle pass levels and required points for each.
/// </summary>
public List<SpecialCrystalInfo> SpecialCrystalInfos { get; set; } = new();
[Key("battle_pass_level_info")]
public Dictionary<string, BattlePassLevel>? BattlePassLevelInfo { get; set; }
/// <summary>
/// Battlefields that can currently be picked.
/// Wire is string[]; parser calls .ToString() on each element (LoadDetail.cs:493-499).
/// </summary>
[Key("open_battle_field_id_list")]
public Dictionary<string, int> OpenBattlefieldIds { get; set; } = new Dictionary<string, int>();
public List<string> OpenBattlefieldIds { get; set; } = new();
#endregion
#region Misc Data
/// <summary>
/// What loot box features are disabled for this user.
/// </summary>
[Key("loot_box_regulation")]
public LootBoxRegulations LootBoxRegulations { get; set; } = new LootBoxRegulations();
public LootBoxRegulations LootBoxRegulations { get; set; } = new();
/// <summary>
/// Something about whether the user has an invite notification.
/// </summary>
[Key("gathering_info")]
public GatheringInfo GatheringInfo { get; set; } = new GatheringInfo();
public GatheringInfo GatheringInfo { get; set; } = new();
/// <summary>
/// User configuration.
/// Spec is unclear whether this is returned at /load/index or only at /config/* endpoints
/// (load-index.md line 390). Pending live-capture confirmation; harmless extra.
/// </summary>
[Key("user_config")]
public UserConfig UserConfig { get; set; } = new UserConfig();
public UserConfig UserConfig { get; set; } = new();
#endregion
}
}

View File

@@ -5,8 +5,10 @@ namespace SVSim.EmulatedEntrypoint.Models.Dtos.Responses;
[MessagePackObject]
public class SpecialTitleCheckResponse
{
/// <summary>
/// Numeric string. "0"/"1" are the built-in default title screens; any other value
/// is treated as an asset-bundle id. When omitted, the client defaults to "0".
/// </summary>
[Key("title_image_id")]
public int TitleImageId { get; set; }
[Key("title_sound_id")]
public int TitleSoundId { get; set; }
}
public string? TitleImageId { get; set; }
}

View File

@@ -12,5 +12,5 @@ public class SleeveIdentifier
/// The id of the sleeve.
/// </summary>
[Key("sleeve_id")]
public int SleeveId { get; set; }
public long SleeveId { get; set; }
}

View File

@@ -2,15 +2,35 @@ using MessagePack;
namespace SVSim.EmulatedEntrypoint.Models.Dtos;
/// <summary>
/// Per-link entry in <c>transition_account_data</c>. Production sends three string fields per
/// entry even though <c>GameStartCheckTask.Parse</c> only reads <c>social_account_type</c>.
/// The extra two are read by adjacent tasks (<c>GetGameDataByTransitionCode</c>,
/// <c>GetGameDataBySocialAccountTask</c>) — kept here so the wire matches prod regardless of
/// which task ends up consuming the payload.
/// </summary>
[MessagePackObject]
public class TransitionAccountData
{
/// <summary>
/// The social provider's account id (e.g. SteamID as a string). Sent as string on the wire.
/// </summary>
[Key("social_account_id")]
public string SocialAccountId { get; set; }
public string? SocialAccountId { get; set; }
/// <summary>
/// <c>Cute/CuteNetworkDefine.ACCOUNT_TYPE</c> enum, **sent as string** on the wire even
/// though it's numeric. <c>GameStartCheckTask.Parse</c> calls <c>.ToInt()</c> on it so
/// LitJson coerces transparently — but matching prod's string form makes us safer against
/// future client paths that might compare it as a literal.
/// 1=GooglePlay, 2=GameCenter, 3=Facebook, 4=DMM, 5=Steam, 6=AppleID.
/// </summary>
[Key("social_account_type")]
public string SocialAccountType { get; set; }
public string? SocialAccountType { get; set; }
/// <summary>
/// The viewer id this social connection is linked to. Sent as string.
/// </summary>
[Key("connected_viewer_id")]
public string ConnectedViewerId { get; set; }
}
public string? ConnectedViewerId { get; set; }
}

View File

@@ -18,8 +18,12 @@ public class UserInfo
public DateTime LastPlayTime { get; set; }
[Key("is_received_two_pick_mission")]
public int HasReceivedPickTwoMission { get; set; }
/// <summary>
/// Birth date as yyyy-MM-dd. Parser does .ToString() on this field (LoadDetail.cs:203).
/// Format verified against live capture pending.
/// </summary>
[Key("birth")]
public long Birthday { get; set; }
public string Birthday { get; set; } = string.Empty;
[Key("selected_emblem_id")]
public long SelectedEmblemId { get; set; }
[Key("selected_degree_id")]
@@ -45,7 +49,7 @@ public class UserInfo
this.MaxFriend = viewer.Info.MaxFriends;
this.LastPlayTime = viewer.LastLogin;
this.HasReceivedPickTwoMission = viewer.MissionData.HasReceivedPickTwoMission ? 1 : 0;
this.Birthday = viewer.Info.BirthDate.Ticks;
this.Birthday = viewer.Info.BirthDate.ToString("yyyy-MM-dd");
this.SelectedEmblemId = viewer.Info.SelectedEmblem.Id;
this.SelectedDegreeId = viewer.Info.SelectedDegree.Id;
this.MissionChangeTime = viewer.MissionData.MissionChangeTime;
@@ -53,4 +57,4 @@ public class UserInfo
this.IsOfficial = viewer.Info.IsOfficial ? 1 : 0;
this.IsOfficialMarkDisplayed = viewer.Info.IsOfficialMarkDisplayed ? 1 : 0;
}
}
}

View File

@@ -21,7 +21,9 @@ public class UserLeaderSkin
{
this.Id = leaderSkin.Id;
this.Name = leaderSkin.Name;
this.ClassId = leaderSkin.Class.Id;
// Class is nullable — BaseDataSeeder maps CSV class_chara_id=0 to null. Fall back to
// the FK column (also nullable) and finally 0 for class-agnostic skins.
this.ClassId = leaderSkin.Class?.Id ?? leaderSkin.ClassId ?? 0;
this.EmoteId = leaderSkin.EmoteId;
this.IsOwned = isOwned;
}

View File

@@ -20,7 +20,7 @@ public class UserRankInfo
[Key("is_master_rank")]
public int IsMasterRank { get; set; }
[Key("is_grand_master_rank")]
public bool IsGrandMasterRank { get; set; }
public int IsGrandMasterRank { get; set; }
[Key("master_point")]
public int MasterPoints { get; set; }
[Key("period_grand_master_point")]

View File

@@ -1,10 +1,9 @@
using System.Reflection;
using DCGEngine.Database.Configuration;
using System.Text.Json.Serialization;
using Microsoft.EntityFrameworkCore;
using SVSim.Database;
using SVSim.Database.Models;
using SVSim.Database.Repositories.Card;
using SVSim.Database.Repositories.Collectibles;
using SVSim.Database.Repositories.Deck;
using SVSim.Database.Repositories.Globals;
using SVSim.Database.Repositories.Viewer;
using SVSim.EmulatedEntrypoint.Middlewares;
@@ -21,7 +20,14 @@ public class Program
// Add services to the container.
builder.Services.AddControllers();
builder.Services.AddControllers().AddJsonOptions(opt =>
{
// Production omits null/optional fields entirely; the client uses
// `Keys.Contains(name)` as a presence check and calls `.ToInt()` (etc.) on the
// value without a null guard. Emitting `"key":null` makes Contains return true and
// crashes the client. Match prod by dropping nulls during serialization.
opt.JsonSerializerOptions.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull;
});
// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
@@ -38,16 +44,14 @@ public class Program
});
builder.Services.AddTransient<IViewerRepository, ViewerRepository>();
builder.Services.AddTransient<ICardRepository, CardRepository>();
builder.Services.AddTransient<ICollectionRepository, CollectionRepository>();
builder.Services.AddTransient<IGlobalsRepository, GlobalsRepository>();
builder.Services.AddTransient<IDeckRepository, DeckRepository>();
#endregion
builder.Services.AddTransient<ShadowverseTranslationMiddleware>();
builder.Services.AddTransient<SessionidMappingMiddleware>();
builder.Services.Configure<DCGEDatabaseConfiguration>(opt =>
{
opt.DbSetSearchAssemblies = new List<Assembly> { Assembly.GetAssembly(typeof(SVSimDbContext)) };
});
builder.Services.AddSingleton<ShadowverseSessionService>();
builder.Services.AddSingleton<SteamSessionService>();
builder.Services.AddAuthentication()
@@ -60,11 +64,16 @@ public class Program
var app = builder.Build();
// Update database
// Update database (skipped for non-relational providers, e.g. InMemory in tests, and
// skipped under the "Testing" environment where the test fixture has already called
// EnsureCreated against a SQLite in-memory DB — the Postgres migrations would fail there).
using (var scope = app.Services.CreateScope())
{
var dbContext = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
dbContext.UpdateDatabase();
if (dbContext.Database.IsRelational() && !app.Environment.IsEnvironment("Testing"))
{
dbContext.UpdateDatabase();
}
}
app.UseHttpLogging();

View File

@@ -23,9 +23,9 @@
</ItemGroup>
<ItemGroup>
<Folder Include="Configuration\" />
<Folder Include="Data\" />
<Folder Include="Utility\" />
<Content Include="Data\*.csv">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
</ItemGroup>
<ItemGroup>

View File

@@ -1,25 +1,15 @@
using System.Collections.Concurrent;
using System.Globalization;
using Microsoft.Extensions.Caching.Memory;
using Steamworks;
namespace SVSim.EmulatedEntrypoint.Services;
public class SteamSessionService : IDisposable
{
private readonly ConcurrentDictionary<string, ulong> _validatedSessionTickets;
private readonly ConcurrentDictionary<string, ulong> _validatedSessionTickets = new();
private readonly object _initLock = new();
private bool _steamInitialized;
private const int ShadowVerseAppId = 453480;
public SteamSessionService()
{
_validatedSessionTickets = new ConcurrentDictionary<string, ulong>();
SteamServer.Init(ShadowVerseAppId, new SteamServerInit
{
GamePort = default,
QueryPort = default
});
}
/// <summary>
/// Validates if a given session ticket is valid, and matches up with the given steamid.
@@ -34,6 +24,8 @@ public class SteamSessionService : IDisposable
return storedSteamId == steamId;
}
EnsureSteamInitialized();
List<byte> ticketBytes = new List<byte>();
for (int i = 0; i < ticket.Length; i += 2)
{
@@ -45,12 +37,30 @@ public class SteamSessionService : IDisposable
{
_validatedSessionTickets.TryAdd(ticket, steamId);
}
return steamCheckResults;
}
private void EnsureSteamInitialized()
{
if (_steamInitialized) return;
lock (_initLock)
{
if (_steamInitialized) return;
SteamServer.Init(ShadowVerseAppId, new SteamServerInit
{
GamePort = default,
QueryPort = default
});
_steamInitialized = true;
}
}
public void Dispose()
{
SteamServer.Shutdown();
if (_steamInitialized)
{
SteamServer.Shutdown();
}
}
}
}

View File

@@ -15,10 +15,21 @@
<PackageReference Include="NUnit3TestAdapter" Version="4.2.1" />
<PackageReference Include="NUnit.Analyzers" Version="3.6.1" />
<PackageReference Include="coverlet.collector" Version="6.0.0" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="8.0.8" />
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="8.0.8" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.8" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\SVSim.EmulatedEntrypoint\SVSim.EmulatedEntrypoint.csproj" />
</ItemGroup>
<ItemGroup>
<!-- BaseDataSeeder reads CSVs from the runtime "Data" folder; mirror them into the test
output so HasData seeding fires when EnsureCreated builds the SQLite schema. -->
<Content Include="..\SVSim.EmulatedEntrypoint\Data\*.csv" Link="Data\%(Filename)%(Extension)">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
</ItemGroup>
</Project>

View File

@@ -1,21 +1,10 @@
using SVSim.EmulatedEntrypoint.Services;
namespace SVSim.UnitTests;
public class Tests
public class SmokeTests
{
[SetUp]
public void Setup()
{
}
[Test]
public void Test1()
public void CanLoadAssembly()
{
const string ticket =
"140000005ee7d30c1263e214e133a10001001001e07cd866180000000100000002000000b8526bb7b8946cd27c214574f1000000b20000003200000004000000e133a1000100100168eb0600488cc2443101a8c0000000008165d4660115f06601005c7e010000000000cad61456a2b83d39595c3e3749b96b4537ebde88d048103a6f6c7b2b81ee68711378836872a11422f5bd16fad803f81122c5ae98d986b693bbbc00ac7d30a8f85af2c1a7dce57751eb2c7f21130284aa8d9ee787246c8ccc138f05936bacb1ba4baba5fa5fbf6158002cf7207ae25a6f6ee8e3fc8edbb84903d346a249179637";
using var steamService = new SteamSessionService();
bool validTicket = steamService.IsTicketValidForUser(ticket, 76561197970830305);
Assert.That(validTicket, Is.EqualTo(true));
Assert.That(typeof(SVSim.EmulatedEntrypoint.Program).Assembly, Is.Not.Null);
}
}
}