Forgot unversioned xd

This commit is contained in:
gamer147
2026-05-23 14:18:18 -04:00
parent 6b70850b7b
commit bf6ddf5428
46 changed files with 43610 additions and 0 deletions

View File

@@ -0,0 +1,147 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using SVSim.Database;
using SVSim.Database.Enums;
using SVSim.Database.Models;
using SVSim.Database.Repositories.Viewer;
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.Admin;
using SVSim.EmulatedEntrypoint.Models.Dtos.Responses.Admin;
namespace SVSim.EmulatedEntrypoint.Controllers;
/// <summary>
/// Util endpoints for bootstrapping the dev environment. Anonymous-allowed today — security
/// audit pending (don't expose these to the public internet).
/// </summary>
public class AdminController : SVSimController
{
private readonly IViewerRepository _viewerRepository;
private readonly SVSimDbContext _dbContext;
public AdminController(IViewerRepository viewerRepository, SVSimDbContext dbContext)
{
_viewerRepository = viewerRepository;
_dbContext = dbContext;
}
/// <summary>
/// Upsert a viewer from external data (typically captured from the live game via the
/// SVSimLoader dump). Matches existing viewers by SteamId; creates a new one if missing.
/// Only essential fields are imported today — extend as needed.
/// </summary>
[AllowAnonymous]
[HttpPost("import_viewer")]
public async Task<ActionResult<ImportViewerResponse>> ImportViewer(ImportViewerRequest request)
{
if (request.SteamId == 0)
{
return BadRequest("steam_id is required");
}
// SocialAccountConnection is [Owned]-by-Viewer — can't query the owned table directly;
// look up the Viewer with a matching owned connection instead.
var existing = await _dbContext.Viewers
.AsNoTracking()
.FirstOrDefaultAsync(v => v.SocialAccountConnections.Any(sac =>
sac.AccountType == SocialAccountType.Steam && sac.AccountId == request.SteamId));
long viewerId;
bool wasCreated;
if (existing is null)
{
var created = await _viewerRepository.RegisterViewer(
request.DisplayName ?? "Imported Viewer",
SocialAccountType.Steam,
request.SteamId);
viewerId = created.Id;
wasCreated = true;
}
else
{
viewerId = existing.Id;
wasCreated = false;
}
// Reload with all the nav properties we need to mutate. RegisterViewer SaveChanges'd
// already, so we re-fetch with full graph and apply the updates.
var viewer = await _dbContext.Viewers
.Include(v => v.Info).ThenInclude(i => i.SelectedEmblem)
.Include(v => v.Info).ThenInclude(i => i.SelectedDegree)
.Include(v => v.Currency)
.Include(v => v.MissionData)
.Include(v => v.Classes).ThenInclude(c => c.Class)
.Include(v => v.Sleeves)
.Include(v => v.Emblems)
.Include(v => v.Degrees)
.Include(v => v.LeaderSkins)
.Include(v => v.MyPageBackgrounds)
.FirstAsync(v => v.Id == viewerId);
if (request.DisplayName is not null) viewer.DisplayName = request.DisplayName;
if (request.CountryCode is not null) viewer.Info.CountryCode = request.CountryCode;
if (request.TutorialState.HasValue) viewer.MissionData.TutorialState = request.TutorialState.Value;
if (request.Currency is not null)
{
if (request.Currency.Crystals.HasValue) viewer.Currency.Crystals = request.Currency.Crystals.Value;
if (request.Currency.Rupees.HasValue) viewer.Currency.Rupees = request.Currency.Rupees.Value;
if (request.Currency.RedEther.HasValue) viewer.Currency.RedEther = request.Currency.RedEther.Value;
}
if (request.SelectedEmblemId.HasValue)
{
var emblem = await _dbContext.Emblems.FindAsync(request.SelectedEmblemId.Value);
if (emblem is not null) viewer.Info.SelectedEmblem = emblem;
}
if (request.SelectedDegreeId.HasValue)
{
var degree = await _dbContext.Degrees.FindAsync(request.SelectedDegreeId.Value);
if (degree is not null) viewer.Info.SelectedDegree = degree;
}
await ReplaceOwned(viewer.Sleeves, request.OwnedSleeveIds, _dbContext.Sleeves);
await ReplaceOwned(viewer.Emblems, request.OwnedEmblemIds, _dbContext.Emblems);
await ReplaceOwned(viewer.Degrees, request.OwnedDegreeIds, _dbContext.Degrees);
await ReplaceOwned(viewer.LeaderSkins, request.OwnedLeaderSkinIds, _dbContext.LeaderSkins);
await ReplaceOwned(viewer.MyPageBackgrounds, request.OwnedMyPageBackgroundIds, _dbContext.MyPageBackgrounds);
if (request.Classes is not null)
{
foreach (var importClass in request.Classes)
{
var existingClass = viewer.Classes.FirstOrDefault(c => c.Class.Id == importClass.ClassId);
if (existingClass is not null)
{
existingClass.Level = importClass.Level;
existingClass.Exp = importClass.Exp;
}
}
}
await _dbContext.SaveChangesAsync();
return new ImportViewerResponse
{
ViewerId = viewer.Id,
ShortUdid = viewer.ShortUdid,
WasCreated = wasCreated
};
}
/// <summary>
/// Replaces the owned-collection with the master rows matching the supplied ids.
/// Null `ids` is a no-op (preserve existing). Empty list clears the collection.
/// </summary>
private async Task ReplaceOwned<TEntity>(List<TEntity> owned, List<int>? ids, DbSet<TEntity> table)
where TEntity : class
{
if (ids is null) return;
owned.Clear();
if (ids.Count == 0) return;
var rows = await table.Where(e => ids.Contains(EF.Property<int>(e, "Id"))).ToListAsync();
owned.AddRange(rows);
}
}

View File

@@ -0,0 +1,213 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using SVSim.Database;
using SVSim.Database.Enums;
using SVSim.Database.Models;
using SVSim.Database.Repositories.Deck;
using SVSim.EmulatedEntrypoint.Constants;
using SVSim.EmulatedEntrypoint.Models.Dtos;
using SVSim.EmulatedEntrypoint.Models.Dtos.Common;
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.Common;
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.Deck;
using SVSim.EmulatedEntrypoint.Models.Dtos.Responses.Deck;
namespace SVSim.EmulatedEntrypoint.Controllers;
public class DeckController : SVSimController
{
private readonly IDeckRepository _deckRepository;
private readonly SVSimDbContext _dbContext;
public DeckController(IDeckRepository deckRepository, SVSimDbContext dbContext)
{
_deckRepository = deckRepository;
_dbContext = dbContext;
}
// TODO: API-side deck_format values may not match the Format enum (see audit on /load/index
// open question — `Data.FormatConvertApi` reverse-mapping not yet captured). For now we cast
// directly; verify against live traffic and add a conversion table if mismatched.
private static Format AsFormat(int apiValue) => (Format)apiValue;
[HttpPost("info")]
public async Task<ActionResult<DeckListResponse>> Info(DeckInfoRequest request)
{
if (!TryGetViewerId(out long viewerId)) return Unauthorized();
var decks = await _deckRepository.GetDecks(viewerId, AsFormat(request.DeckFormat));
return new DeckListResponse
{
UserDeckList = decks.Select(d => new UserDeck(d)).ToList()
};
}
[HttpPost("my_list")]
public async Task<ActionResult<DeckListResponse>> MyList(DeckFormatRequest request)
{
if (!TryGetViewerId(out long viewerId)) return Unauthorized();
var decks = await _deckRepository.GetDecks(viewerId, AsFormat(request.DeckFormat));
return new DeckListResponse
{
UserDeckList = decks.Select(d => new UserDeck(d)).ToList()
};
}
[HttpPost("get_empty_deck_number")]
public async Task<ActionResult<EmptyDeckNumberResponse>> GetEmptyDeckNumber(DeckFormatRequest request)
{
if (!TryGetViewerId(out long viewerId)) return Unauthorized();
return new EmptyDeckNumberResponse
{
EmptyDeckNum = await _deckRepository.GetEmptyDeckNumber(viewerId, AsFormat(request.DeckFormat))
};
}
[HttpPost("update")]
public async Task<ActionResult<DeckUpdateResponse>> Update(DeckUpdateRequest request)
{
if (!TryGetViewerId(out long viewerId)) return Unauthorized();
var format = AsFormat(request.DeckFormat);
if (request.IsDelete == 1)
{
await _deckRepository.DeleteDecks(viewerId, format, new[] { request.DeckNo });
}
else
{
var cls = await _dbContext.Classes.FindAsync(request.ClassId);
var sleeve = await _dbContext.Sleeves.FindAsync((int)request.SleeveId);
var skin = await _dbContext.LeaderSkins.FindAsync(request.LeaderSkinId);
var cards = await ResolveDeckCards(request.CardIdArray);
await _deckRepository.UpsertDeck(viewerId, format, request.DeckNo, deck =>
{
deck.Name = request.DeckName ?? string.Empty;
if (cls is not null) deck.Class = cls;
if (sleeve is not null) deck.Sleeve = sleeve;
if (skin is not null) deck.LeaderSkin = skin;
deck.RandomLeaderSkin = request.IsRandomLeaderSkin;
deck.Cards = cards;
});
}
var decks = await _deckRepository.GetDecks(viewerId, format);
return new DeckUpdateResponse
{
UserDeckList = decks.Select(d => new UserDeck(d)).ToList()
};
}
[HttpPost("update_name")]
public async Task<ActionResult<SingleDeckResponse>> UpdateName(DeckUpdateNameRequest request)
{
if (!TryGetViewerId(out long viewerId)) return Unauthorized();
var deck = await _deckRepository.UpsertDeck(viewerId, AsFormat(request.DeckFormat), request.DeckNo,
d => d.Name = request.DeckName ?? string.Empty);
return new SingleDeckResponse { UserDeck = new UserDeck(deck) };
}
[HttpPost("update_sleeve")]
public async Task<ActionResult<SingleDeckResponse>> UpdateSleeve(DeckUpdateSleeveRequest request)
{
if (!TryGetViewerId(out long viewerId)) return Unauthorized();
var sleeve = await _dbContext.Sleeves.FindAsync((int)request.SleeveId);
if (sleeve is null) return BadRequest($"Unknown sleeve {request.SleeveId}");
var deck = await _deckRepository.UpsertDeck(viewerId, AsFormat(request.DeckFormat), request.DeckNo,
d => d.Sleeve = sleeve);
return new SingleDeckResponse { UserDeck = new UserDeck(deck) };
}
[HttpPost("update_leader_skin")]
public async Task<ActionResult<SingleDeckResponse>> UpdateLeaderSkin(DeckUpdateLeaderSkinRequest request)
{
if (!TryGetViewerId(out long viewerId)) return Unauthorized();
var skin = await _dbContext.LeaderSkins.FindAsync(request.LeaderSkinId);
if (skin is null) return BadRequest($"Unknown leader skin {request.LeaderSkinId}");
var deck = await _deckRepository.UpsertDeck(viewerId, AsFormat(request.DeckFormat), request.DeckNo,
d =>
{
d.LeaderSkin = skin;
d.RandomLeaderSkin = false;
});
return new SingleDeckResponse { UserDeck = new UserDeck(deck) };
}
// TODO: schema doesn't yet model the random-leader-skin pool — we just pick one and persist
// that. Add a join table (DeckLeaderSkinPool) when ranked play / random skins become a real
// feature. For now the UI flow still works (server returns a single chosen skin per spec).
[HttpPost("update_random_leader_skin")]
public async Task<ActionResult<SingleDeckResponse>> UpdateRandomLeaderSkin(DeckUpdateRandomLeaderSkinRequest request)
{
if (!TryGetViewerId(out long viewerId)) return Unauthorized();
var pool = request.LeaderSkinIdList ?? new List<int>();
if (pool.Count == 0) return BadRequest("leader_skin_id_list must contain at least one id");
int chosenId = pool[Random.Shared.Next(pool.Count)];
var skin = await _dbContext.LeaderSkins.FindAsync(chosenId);
if (skin is null) return BadRequest($"Unknown leader skin {chosenId}");
var deck = await _deckRepository.UpsertDeck(viewerId, AsFormat(request.DeckFormat), request.DeckNo,
d =>
{
d.LeaderSkin = skin;
d.RandomLeaderSkin = true;
});
return new SingleDeckResponse { UserDeck = new UserDeck(deck) };
}
[HttpPost("update_order")]
public async Task<ActionResult<EmptyResponse>> UpdateOrder(DeckOrderRequest request)
{
if (!TryGetViewerId(out long viewerId)) return Unauthorized();
// Deck `Number` IS the slot order — the client sends the same slot numbers in a new
// sequence. Today we don't model "display order" separately from "slot number", so
// reordering is a no-op server-side. When a separate Order column lands, persist here.
return new EmptyResponse();
}
[HttpPost("delete_deck_list")]
public async Task<ActionResult<EmptyResponse>> DeleteDeckList(DeckDeleteListRequest request)
{
if (!TryGetViewerId(out long viewerId)) return Unauthorized();
var nos = request.DeckNoList ?? new List<int>();
if (nos.Count > 0) await _deckRepository.DeleteDecks(viewerId, AsFormat(request.DeckFormat), nos);
return new EmptyResponse();
}
// /deck/set_deck_redis — server side is a Redis-cached "active deck per class" hint for
// matchmaking. We don't model matchmaking yet; acknowledge the call and move on (real
// server may not persist this either; the `_redis` suffix suggests cache-only).
[HttpPost("set_deck_redis")]
public Task<ActionResult<EmptyResponse>> SetDeckRedis(SetDeckRedisRequest request)
{
if (!TryGetViewerId(out long _)) return Task.FromResult<ActionResult<EmptyResponse>>(Unauthorized());
return Task.FromResult<ActionResult<EmptyResponse>>(new EmptyResponse());
}
private bool TryGetViewerId(out long viewerId)
{
viewerId = 0;
var claim = User.Claims.FirstOrDefault(c => c.Type == ShadowverseClaimTypes.ViewerIdClaim)?.Value;
return claim is not null && long.TryParse(claim, out viewerId);
}
/// <summary>
/// Convert a flat `card_id_array` (cards repeated for count) into a grouped DeckCard list.
/// Cards not in the DB are silently dropped — until CardImport lands the result is always
/// empty, which is acceptable for the deck-editing flow (UI saves what it can).
/// </summary>
private async Task<List<DeckCard>> ResolveDeckCards(List<long>? cardIdArray)
{
if (cardIdArray is null || cardIdArray.Count == 0) return new List<DeckCard>();
var grouped = cardIdArray.GroupBy(id => id).Select(g => new { Id = g.Key, Count = g.Count() }).ToList();
var ids = grouped.Select(g => g.Id).ToList();
var cards = await _dbContext.Cards.Where(c => ids.Contains(c.Id)).ToDictionaryAsync(c => c.Id);
return grouped
.Where(g => cards.ContainsKey(g.Id))
.Select(g => new DeckCard { Card = cards[g.Id], Count = g.Count })
.ToList();
}
}

View File

@@ -0,0 +1,133 @@
using Microsoft.AspNetCore.Mvc;
using SVSim.Database.Enums;
using SVSim.Database.Models;
using SVSim.Database.Repositories.Viewer;
using SVSim.EmulatedEntrypoint.Constants;
using SVSim.EmulatedEntrypoint.Models.Dtos;
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests;
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.Common;
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.Practice;
using SVSim.EmulatedEntrypoint.Models.Dtos.Responses.Practice;
namespace SVSim.EmulatedEntrypoint.Controllers;
public class PracticeController : SVSimController
{
// Hand-curated AI opponents (audit B14 pattern). Replace with master data when the
// practice subsystem is built out.
private static readonly List<PracticeOpponent> StubOpponents = new()
{
new PracticeOpponent
{
PracticeId = 1,
TextId = "Practice_001",
ClassId = 1,
CharaId = 1,
DegreeId = 0,
AiDeckLevel = 1,
AiLogicLevel = 1,
AiMaxLife = 20,
Battle3dFieldId = "1",
IsCampaignPractice = false
},
new PracticeOpponent
{
PracticeId = 2,
TextId = "Practice_002",
ClassId = 2,
CharaId = 2,
DegreeId = 0,
AiDeckLevel = 2,
AiLogicLevel = 2,
AiMaxLife = 20,
Battle3dFieldId = "1",
IsCampaignPractice = false
},
new PracticeOpponent
{
PracticeId = 3,
TextId = "Practice_003",
ClassId = 3,
CharaId = 3,
DegreeId = 0,
AiDeckLevel = 3,
AiLogicLevel = 3,
AiMaxLife = 25,
Battle3dFieldId = "1",
IsCampaignPractice = false
}
};
private readonly IViewerRepository _viewerRepository;
public PracticeController(IViewerRepository viewerRepository)
{
_viewerRepository = viewerRepository;
}
/// <summary>
/// /practice/info — returns the AI opponent catalog. Response data is a JSON array
/// directly (not wrapped in an object), per spec.
/// </summary>
[HttpPost("info")]
public Task<List<PracticeOpponent>> Info(BaseRequest request)
{
return Task.FromResult(StubOpponents);
}
/// <summary>
/// /practice/deck_list — returns viewer's decks scoped by format (always Format.All
/// per spec, server can ignore the request field).
/// </summary>
[HttpPost("deck_list")]
public async Task<ActionResult<PracticeDeckListResponse>> DeckList(DeckFormatRequest request)
{
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();
}
return new PracticeDeckListResponse
{
MaintenanceCardList = new List<long>(),
UserDeckRotation = viewer.Decks.Where(d => d.Format == Format.Rotation)
.Select(d => new UserDeck(d)).ToList(),
UserDeckUnlimited = viewer.Decks.Where(d => d.Format == Format.Unlimited)
.Select(d => new UserDeck(d)).ToList()
};
}
/// <summary>
/// /practice/start — server is essentially a no-op for practice. Spec: empty body
/// response is fine; client tolerates missing mission_parameter.
/// </summary>
[HttpPost("start")]
public Task<PracticeStartResponse> Start(BaseRequest request)
{
return Task.FromResult(new PracticeStartResponse());
}
/// <summary>
/// /practice/finish — accept the recovery_data blob without validation; return zero
/// XP / no rewards. Class XP bookkeeping is deferred until a per-class XP store exists.
/// </summary>
[HttpPost("finish")]
public Task<PracticeFinishResponse> Finish(PracticeFinishRequest request)
{
return Task.FromResult(new PracticeFinishResponse
{
GetClassExperience = 0,
ClassExperience = 0,
ClassLevel = 1,
AchievedInfo = new Dictionary<string, object>(),
RewardList = new List<Models.Dtos.Common.Reward>()
});
}
}

View File

@@ -0,0 +1,14 @@
using MessagePack;
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Common;
/// <summary>
/// Shared empty response. Used for endpoints whose spec mock is `"data": {}`
/// (set_deck_redis, update_order, delete_deck_list, etc.). Includes a sentinel
/// nullable field so MessagePack-CSharp emits a string-keyed empty map cleanly.
/// </summary>
[MessagePackObject]
public class EmptyResponse
{
[Key("_")] public object? Reserved { get; set; }
}

View File

@@ -0,0 +1,15 @@
using MessagePack;
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Common;
/// <summary>
/// Stub for the Reward shape (spec: common/types.ts.md#reward). Fleshed out when actual
/// reward-granting flows land. Today's endpoints all emit empty reward_list arrays.
/// </summary>
[MessagePackObject]
public class Reward
{
[Key("type")] public int? Type { get; set; }
[Key("value")] public long? Value { get; set; }
[Key("num")] public int? Num { get; set; }
}

View File

@@ -0,0 +1,43 @@
using System.Text.Json.Serialization;
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Requests.Admin;
/// <summary>
/// Snake-case JSON. Only used by the import endpoint (plain JSON over HTTP, not the
/// Unity msgpack path) so no MessagePack attributes are needed.
/// </summary>
public class ImportViewerRequest
{
[JsonPropertyName("steam_id")] public ulong SteamId { get; set; }
[JsonPropertyName("display_name")] public string? DisplayName { get; set; }
[JsonPropertyName("country_code")] public string? CountryCode { get; set; }
[JsonPropertyName("tutorial_state")] public int? TutorialState { get; set; }
[JsonPropertyName("selected_emblem_id")] public int? SelectedEmblemId { get; set; }
[JsonPropertyName("selected_degree_id")] public int? SelectedDegreeId { get; set; }
[JsonPropertyName("currency")] public ImportCurrency? Currency { get; set; }
[JsonPropertyName("owned_sleeve_ids")] public List<int>? OwnedSleeveIds { get; set; }
[JsonPropertyName("owned_emblem_ids")] public List<int>? OwnedEmblemIds { get; set; }
[JsonPropertyName("owned_degree_ids")] public List<int>? OwnedDegreeIds { get; set; }
[JsonPropertyName("owned_leader_skin_ids")] public List<int>? OwnedLeaderSkinIds { get; set; }
[JsonPropertyName("owned_mypage_background_ids")] public List<int>? OwnedMyPageBackgroundIds { get; set; }
[JsonPropertyName("classes")] public List<ImportClassData>? Classes { get; set; }
}
public class ImportCurrency
{
[JsonPropertyName("crystals")] public ulong? Crystals { get; set; }
[JsonPropertyName("rupees")] public ulong? Rupees { get; set; }
[JsonPropertyName("red_ether")] public ulong? RedEther { get; set; }
}
public class ImportClassData
{
[JsonPropertyName("class_id")] public int ClassId { get; set; }
[JsonPropertyName("level")] public int Level { get; set; }
[JsonPropertyName("exp")] public int Exp { get; set; }
}

View File

@@ -0,0 +1,13 @@
using MessagePack;
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Requests.Common;
/// <summary>
/// Common request shape for endpoints scoped by deck format (`/deck/info`,
/// `/practice/deck_list`, etc.). Spec: common/types.ts.md#deck-format-scoped-requests.
/// </summary>
[MessagePackObject]
public class DeckFormatRequest : BaseRequest
{
[Key("deck_format")] public int DeckFormat { get; set; }
}

View File

@@ -0,0 +1,10 @@
using MessagePack;
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Requests.Deck;
[MessagePackObject]
public class DeckDeleteListRequest : BaseRequest
{
[Key("deck_no_list")] public List<int>? DeckNoList { get; set; }
[Key("deck_format")] public int DeckFormat { get; set; }
}

View File

@@ -0,0 +1,16 @@
using MessagePack;
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.Common;
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Requests.Deck;
/// <summary>
/// /deck/info — standard request is `DeckFormatRequest`. Copy-source overload adds
/// `create_deck_format` (the format the user is creating the new deck IN). Server can
/// ignore create_deck_format and return the standard shape; only matters for the
/// cross-format deck-copy UI flow.
/// </summary>
[MessagePackObject]
public class DeckInfoRequest : DeckFormatRequest
{
[Key("create_deck_format")] public int? CreateDeckFormat { get; set; }
}

View File

@@ -0,0 +1,10 @@
using MessagePack;
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Requests.Deck;
[MessagePackObject]
public class DeckOrderRequest : BaseRequest
{
[Key("deck_order")] public List<int>? DeckOrder { get; set; }
[Key("deck_format")] public int DeckFormat { get; set; }
}

View File

@@ -0,0 +1,11 @@
using MessagePack;
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Requests.Deck;
[MessagePackObject]
public class DeckUpdateLeaderSkinRequest : BaseRequest
{
[Key("deck_no")] public int DeckNo { get; set; }
[Key("leader_skin_id")] public int LeaderSkinId { get; set; }
[Key("deck_format")] public int DeckFormat { get; set; }
}

View File

@@ -0,0 +1,11 @@
using MessagePack;
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Requests.Deck;
[MessagePackObject]
public class DeckUpdateNameRequest : BaseRequest
{
[Key("deck_no")] public int DeckNo { get; set; }
[Key("deck_name")] public string? DeckName { get; set; }
[Key("deck_format")] public int DeckFormat { get; set; }
}

View File

@@ -0,0 +1,11 @@
using MessagePack;
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Requests.Deck;
[MessagePackObject]
public class DeckUpdateRandomLeaderSkinRequest : BaseRequest
{
[Key("deck_format")] public int DeckFormat { get; set; }
[Key("deck_no")] public int DeckNo { get; set; }
[Key("leader_skin_id_list")] public List<int>? LeaderSkinIdList { get; set; }
}

View File

@@ -0,0 +1,27 @@
using MessagePack;
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Requests.Deck;
[MessagePackObject]
public class DeckUpdateRequest : BaseRequest
{
[Key("deck_no")] public int DeckNo { get; set; }
[Key("class_id")] public int ClassId { get; set; }
[Key("leader_skin_id")] public int LeaderSkinId { get; set; }
[Key("is_random_leader_skin")] public bool IsRandomLeaderSkin { get; set; }
[Key("leader_skin_id_list")] public List<int>? LeaderSkinIdList { get; set; }
[Key("sleeve_id")] public long SleeveId { get; set; }
[Key("deck_name")] public string? DeckName { get; set; }
/// <summary>0 = save the deck, 1 = delete this deck slot.</summary>
[Key("is_delete")] public int IsDelete { get; set; }
[Key("card_id_array")] public List<long>? CardIdArray { get; set; }
[Key("deck_format")] public int DeckFormat { get; set; }
/// <summary>MyRotation rule-set id (only when deck_format = MyRotation).</summary>
[Key("rotation_id")] public string? RotationId { get; set; }
/// <summary>Crossover sub-class id (only when deck_format = Crossover).</summary>
[Key("sub_class_id")] public int? SubClassId { get; set; }
}

View File

@@ -0,0 +1,11 @@
using MessagePack;
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Requests.Deck;
[MessagePackObject]
public class DeckUpdateSleeveRequest : BaseRequest
{
[Key("deck_no")] public int DeckNo { get; set; }
[Key("sleeve_id")] public long SleeveId { get; set; }
[Key("deck_format")] public int DeckFormat { get; set; }
}

View File

@@ -0,0 +1,10 @@
using MessagePack;
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Requests.Deck;
[MessagePackObject]
public class SetDeckRedisRequest : BaseRequest
{
[Key("deck_no")] public int DeckNo { get; set; }
[Key("class_id")] public int ClassId { get; set; }
}

View File

@@ -0,0 +1,30 @@
using MessagePack;
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Requests.Practice;
[MessagePackObject]
public class PracticeFinishRequest : BaseRequest
{
[Key("deck_no")] public int DeckNo { get; set; }
[Key("is_win")] public int IsWin { get; set; }
[Key("evolve_count")] public int EvolveCount { get; set; }
[Key("total_turn")] public int TotalTurn { get; set; }
[Key("enemy_class_id")] public int EnemyClassId { get; set; }
[Key("difficulty")] public int Difficulty { get; set; }
[Key("deck_format")] public int DeckFormat { get; set; }
[Key("class_id")] public int ClassId { get; set; }
[Key("mission")] public Dictionary<string, int>? Mission { get; set; }
/// <summary>
/// JSON blob — `recovery_single.json` serialized to string. Always present; not validated
/// server-side (audit-flagged as out of scope for v1).
/// </summary>
[Key("recovery_data")] public string? RecoveryData { get; set; }
/// <summary>
/// Misspelled the same way in every solo finish endpoint — preserved on the wire.
/// See spec note on practice-finish.md.
/// </summary>
[Key("prosessing_time_data")] public List<string>? ProsessingTimeData { get; set; }
}

View File

@@ -0,0 +1,10 @@
using System.Text.Json.Serialization;
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Responses.Admin;
public class ImportViewerResponse
{
[JsonPropertyName("viewer_id")] public long ViewerId { get; set; }
[JsonPropertyName("short_udid")] public long ShortUdid { get; set; }
[JsonPropertyName("was_created")] public bool WasCreated { get; set; }
}

View File

@@ -0,0 +1,15 @@
using MessagePack;
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Responses.Deck;
/// <summary>
/// Shape consumed by `DeckGroupListData(jsonData, format)` for a single-format call —
/// the format-scoped decks land under `user_deck_list` (vs. the per-format keys used
/// by /practice/deck_list with Format.All).
/// </summary>
[MessagePackObject]
public class DeckListResponse
{
[Key("maintenance_card_list")] public List<long> MaintenanceCardList { get; set; } = new();
[Key("user_deck_list")] public List<UserDeck>? UserDeckList { get; set; }
}

View File

@@ -0,0 +1,17 @@
using MessagePack;
using SVSim.EmulatedEntrypoint.Models.Dtos.Common;
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Responses.Deck;
/// <summary>
/// /deck/update response. Minimum-viable per spec is just {achieved_info, reward_list};
/// the full shape also includes the refreshed deck list. We include user_deck_list to
/// save the client a follow-up /deck/info round-trip.
/// </summary>
[MessagePackObject]
public class DeckUpdateResponse
{
[Key("user_deck_list")] public List<UserDeck>? UserDeckList { get; set; }
[Key("achieved_info")] public Dictionary<string, object> AchievedInfo { get; set; } = new();
[Key("reward_list")] public List<Reward> RewardList { get; set; } = new();
}

View File

@@ -0,0 +1,10 @@
using MessagePack;
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Responses.Deck;
[MessagePackObject]
public class EmptyDeckNumberResponse
{
/// <summary>The next free deck slot number. 0 indicates "no slots available".</summary>
[Key("empty_deck_num")] public int EmptyDeckNum { get; set; }
}

View File

@@ -0,0 +1,15 @@
using MessagePack;
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Responses.Deck;
/// <summary>
/// Single-deck-update response. Consumed by DeckListUtility.DeckUpdate(user_deck,
/// format, DeckAttributeType.CustomDeck). Shape is "one UserDeck wrapped under
/// `user_deck` key" — same for update_name, update_sleeve, update_leader_skin,
/// update_random_leader_skin.
/// </summary>
[MessagePackObject]
public class SingleDeckResponse
{
[Key("user_deck")] public UserDeck? UserDeck { get; set; }
}

View File

@@ -0,0 +1,21 @@
using MessagePack;
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Responses.Practice;
/// <summary>
/// Same shape consumed by DeckGroupListData(jsonData, Format.All). Per-format keys are
/// conditional — omit (don't send empty arrays) for formats the server doesn't enable.
/// </summary>
[MessagePackObject]
public class PracticeDeckListResponse
{
/// <summary>Card ids currently disabled for maintenance (client unions with global list).</summary>
[Key("maintenance_card_list")] public List<long> MaintenanceCardList { get; set; } = new();
[Key("user_deck_rotation")] public List<UserDeck>? UserDeckRotation { get; set; }
[Key("user_deck_unlimited")] public List<UserDeck>? UserDeckUnlimited { get; set; }
// The remaining format keys (pre_rotation, crossover, my_rotation, avatar, default_deck_list,
// trial_deck_list, crossover_trial_deck_list, build_deck_list, user_leader_skin_setting_list)
// are all conditional — added when those formats are enabled.
}

View File

@@ -0,0 +1,26 @@
using MessagePack;
using SVSim.EmulatedEntrypoint.Models.Dtos.Common;
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Responses.Practice;
[MessagePackObject]
public class PracticeFinishResponse
{
/// <summary>Class XP gained this match.</summary>
[Key("get_class_experience")] public int GetClassExperience { get; set; }
/// <summary>Total accumulated class XP for the played class after this match.</summary>
[Key("class_experience")] public int ClassExperience { get; set; }
/// <summary>Class level after this match (post-promotion if XP rolled over).</summary>
[Key("class_level")] public int ClassLevel { get; set; } = 1;
/// <summary>
/// Missions / achievements / rewards rollup. Empty dict means "nothing accumulated"
/// (spec: parser tolerates empty object).
/// </summary>
[Key("achieved_info")] public Dictionary<string, object> AchievedInfo { get; set; } = new();
/// <summary>Standard reward grants applied to user's inventory. Empty by default.</summary>
[Key("reward_list")] public List<Reward> RewardList { get; set; } = new();
}

View File

@@ -0,0 +1,43 @@
using MessagePack;
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Responses.Practice;
[MessagePackObject]
public class PracticeOpponent
{
/// <summary>Practice slot id (unique per entry; AI opponent identifier).</summary>
[Key("practice_id")] public int PracticeId { get; set; }
/// <summary>
/// Text-table id resolved client-side via Data.Master.GetPracticeText(text_id).
/// Stringified int — client calls .ToString() before lookup. Sent as string to be safe.
/// </summary>
[Key("text_id")] public string TextId { get; set; } = string.Empty;
/// <summary>Class (leader) id the AI plays.</summary>
[Key("class_id")] public int ClassId { get; set; }
/// <summary>Portrait / character id (which leader art the AI uses).</summary>
[Key("chara_id")] public int CharaId { get; set; }
/// <summary>Title-degree id shown next to the AI's name.</summary>
[Key("degree_id")] public int DegreeId { get; set; }
/// <summary>AI deck-strength tier (drives which preset deck the AI uses).</summary>
[Key("ai_deck_level")] public int AiDeckLevel { get; set; }
/// <summary>AI decision-making tier.</summary>
[Key("ai_logic_level")] public int AiLogicLevel { get; set; }
/// <summary>Starting HP for the AI side (often 20).</summary>
[Key("ai_max_life")] public int AiMaxLife { get; set; } = 20;
/// <summary>3D battle-field asset id (string on the wire; client int.TryParse's it).</summary>
[Key("battle3dfield_id")] public string Battle3dFieldId { get; set; } = "1";
/// <summary>Optional. true => entry disabled, client prepends maintenance suffix.</summary>
[Key("is_maintenance")] public bool? IsMaintenance { get; set; }
/// <summary>true => entry is a special "campaign" practice (event-tied).</summary>
[Key("is_campaign_practice")] public bool IsCampaignPractice { get; set; }
}

View File

@@ -0,0 +1,14 @@
using MessagePack;
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Responses.Practice;
[MessagePackObject]
public class PracticeStartResponse
{
/// <summary>
/// Optional mission/achievement evaluation snapshot. Spec: safe to omit entirely;
/// client tolerates absence (defensive `Keys.Contains` check). Always null in our
/// minimal impl — we don't model missions.
/// </summary>
[Key("mission_parameter")] public object? MissionParameter { get; set; }
}