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,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();
}
}
}
}