Practice battles work
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -28,6 +28,7 @@ public class GlobalsImporter
|
||||
JsonElement? mypageIndex = LoadCapture(capturesDir, "mypage-index");
|
||||
JsonElement? deckInfo = LoadCapture(capturesDir, "deck-info");
|
||||
JsonElement? paymentItemList = LoadCapture(capturesDir, "payment-item-list");
|
||||
JsonElement? practiceInfo = LoadCapture(capturesDir, "practice-info");
|
||||
|
||||
int total = 0;
|
||||
|
||||
@@ -69,6 +70,11 @@ public class GlobalsImporter
|
||||
total += await ImportPaymentItems(context, paymentItemList.Value);
|
||||
}
|
||||
|
||||
if (practiceInfo.HasValue)
|
||||
{
|
||||
total += await ImportPracticeOpponents(context, practiceInfo.Value);
|
||||
}
|
||||
|
||||
await context.SaveChangesAsync();
|
||||
Console.WriteLine($"[GlobalsImporter] Done: {total} total rows changed.");
|
||||
return total;
|
||||
@@ -694,6 +700,47 @@ public class GlobalsImporter
|
||||
private static decimal ParseDecimal(string s) =>
|
||||
decimal.TryParse(s, System.Globalization.NumberStyles.Number, System.Globalization.CultureInfo.InvariantCulture, out var d) ? d : 0m;
|
||||
|
||||
// ---------- Practice Opponents ----------
|
||||
|
||||
/// <summary>
|
||||
/// Capture is the full /practice/info envelope; <c>data</c> is a JSON ARRAY (not an object,
|
||||
/// unlike most endpoints). Each row is one AI opponent row keyed on practice_id. Prod sends
|
||||
/// numeric fields as strings — GetInt tolerates both. Rows present in the DB but missing
|
||||
/// from the capture are LEFT INTACT (consistent with the rest of GlobalsImporter; partial
|
||||
/// captures shouldn't silently delete entries).
|
||||
/// </summary>
|
||||
private async Task<int> ImportPracticeOpponents(SVSimDbContext context, JsonElement practiceData)
|
||||
{
|
||||
if (practiceData.ValueKind != JsonValueKind.Array) return 0;
|
||||
|
||||
var existing = await context.PracticeOpponents.ToDictionaryAsync(e => e.Id);
|
||||
int created = 0, updated = 0;
|
||||
|
||||
foreach (var row in practiceData.EnumerateArray())
|
||||
{
|
||||
int practiceId = GetInt(row, "practice_id");
|
||||
if (practiceId == 0) continue; // malformed row
|
||||
|
||||
var entry = existing.TryGetValue(practiceId, out var ex) ? ex : new PracticeOpponentEntry { Id = practiceId };
|
||||
entry.TextId = GetString(row, "text_id");
|
||||
entry.ClassId = GetInt(row, "class_id");
|
||||
entry.CharaId = GetInt(row, "chara_id");
|
||||
entry.DegreeId = GetInt(row, "degree_id");
|
||||
entry.AiDeckLevel = GetInt(row, "ai_deck_level");
|
||||
entry.AiLogicLevel = GetInt(row, "ai_logic_level");
|
||||
entry.AiMaxLife = GetInt(row, "ai_max_life");
|
||||
entry.Battle3dFieldId = GetString(row, "battle3dfield_id", "1");
|
||||
entry.IsMaintenance = GetBool(row, "is_maintenance");
|
||||
entry.IsCampaignPractice = GetBool(row, "is_campaign_practice");
|
||||
|
||||
if (ex is null) { context.PracticeOpponents.Add(entry); created++; }
|
||||
else updated++;
|
||||
}
|
||||
|
||||
Console.WriteLine($"[GlobalsImporter] PracticeOpponents: +{created}/~{updated}");
|
||||
return created + updated;
|
||||
}
|
||||
|
||||
// ---------- Helpers ----------
|
||||
|
||||
private static void WarnOrphans(string label, int count)
|
||||
|
||||
34644
SVSim.Database/Migrations/20260524022437_PracticeOpponents.Designer.cs
generated
Normal file
34644
SVSim.Database/Migrations/20260524022437_PracticeOpponents.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,46 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace SVSim.Database.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class PracticeOpponents : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "PracticeOpponents",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "integer", nullable: false),
|
||||
PracticeId = table.Column<int>(type: "integer", nullable: false),
|
||||
TextId = table.Column<string>(type: "text", nullable: false),
|
||||
ClassId = table.Column<int>(type: "integer", nullable: false),
|
||||
CharaId = table.Column<int>(type: "integer", nullable: false),
|
||||
DegreeId = table.Column<int>(type: "integer", nullable: false),
|
||||
AiDeckLevel = table.Column<int>(type: "integer", nullable: false),
|
||||
AiLogicLevel = table.Column<int>(type: "integer", nullable: false),
|
||||
AiMaxLife = table.Column<int>(type: "integer", nullable: false),
|
||||
Battle3dFieldId = table.Column<string>(type: "text", nullable: false),
|
||||
IsMaintenance = table.Column<bool>(type: "boolean", nullable: false),
|
||||
IsCampaignPractice = table.Column<bool>(type: "boolean", nullable: false),
|
||||
DateCreated = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
DateUpdated = table.Column<DateTime>(type: "timestamp with time zone", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_PracticeOpponents", x => x.Id);
|
||||
});
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "PracticeOpponents");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -25486,6 +25486,57 @@ namespace SVSim.Database.Migrations
|
||||
b.ToTable("PaymentItems");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SVSim.Database.Models.PracticeOpponentEntry", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("AiDeckLevel")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("AiLogicLevel")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("AiMaxLife")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Battle3dFieldId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("CharaId")
|
||||
.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>("DegreeId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<bool>("IsCampaignPractice")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("IsMaintenance")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<int>("PracticeId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("TextId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("PracticeOpponents");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SVSim.Database.Models.PreReleaseInfo", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
|
||||
49
SVSim.Database/Models/PracticeOpponentEntry.cs
Normal file
49
SVSim.Database/Models/PracticeOpponentEntry.cs
Normal file
@@ -0,0 +1,49 @@
|
||||
using SVSim.Database.Common;
|
||||
|
||||
namespace SVSim.Database.Models;
|
||||
|
||||
/// <summary>
|
||||
/// One row per AI opponent shown on the practice (solo-play) opponent select screen.
|
||||
/// Populated from prod /practice/info captures by SVSim.Bootstrap.GlobalsImporter.
|
||||
///
|
||||
/// The (<see cref="ClassId"/>, <see cref="AiDeckLevel"/>) pair MUST exist in the client's
|
||||
/// baked-in master CSV `ai/practice_ai_setting`; if it doesn't, the client's
|
||||
/// PracticeAISettingDataSet.GetSettingData throws InvalidOperationException and the
|
||||
/// difficulty-select dialog crashes BEFORE /practice/start is sent. Prod's catalog is
|
||||
/// the safe source of truth — we can't see the CSV directly.
|
||||
/// </summary>
|
||||
public class PracticeOpponentEntry : BaseEntity<int>
|
||||
{
|
||||
/// <summary>Practice slot id (Id = practice_id from the wire; also unique).</summary>
|
||||
public int PracticeId { get => Id; set => Id = value; }
|
||||
|
||||
/// <summary>Text-table key resolved client-side via Data.Master.GetPracticeText.</summary>
|
||||
public string TextId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Class (leader) id the AI plays.</summary>
|
||||
public int ClassId { get; set; }
|
||||
|
||||
/// <summary>Portrait / character id (leader art).</summary>
|
||||
public int CharaId { get; set; }
|
||||
|
||||
/// <summary>Title-degree id shown next to the AI name. -1 when unset.</summary>
|
||||
public int DegreeId { get; set; }
|
||||
|
||||
/// <summary>AI deck-strength tier; key into the client's practice_ai_setting CSV.</summary>
|
||||
public int AiDeckLevel { get; set; }
|
||||
|
||||
/// <summary>AI decision-making tier.</summary>
|
||||
public int AiLogicLevel { get; set; }
|
||||
|
||||
/// <summary>Starting HP for the AI side (typically 20; 10 for the easiest "tutorial" rows).</summary>
|
||||
public int AiMaxLife { get; set; }
|
||||
|
||||
/// <summary>3D battlefield asset id (string on the wire; client int.TryParse's it).</summary>
|
||||
public string Battle3dFieldId { get; set; } = "1";
|
||||
|
||||
/// <summary>true => entry shown but disabled with a maintenance suffix.</summary>
|
||||
public bool IsMaintenance { get; set; }
|
||||
|
||||
/// <summary>true => entry is a special event-tied "campaign" practice.</summary>
|
||||
public bool IsCampaignPractice { get; set; }
|
||||
}
|
||||
@@ -111,4 +111,7 @@ public class GlobalsRepository : IGlobalsRepository
|
||||
|
||||
public Task<List<ShadowverseCardSetEntry>> GetRotationCardSets() =>
|
||||
_dbContext.CardSets.AsNoTracking().Where(s => s.IsInRotation).ToListAsync();
|
||||
|
||||
public Task<List<PracticeOpponentEntry>> GetPracticeOpponents() =>
|
||||
_dbContext.PracticeOpponents.AsNoTracking().OrderBy(e => e.ClassId).ThenBy(e => e.Id).ToListAsync();
|
||||
}
|
||||
|
||||
@@ -32,4 +32,5 @@ public interface IGlobalsRepository
|
||||
Task<List<FeatureMaintenanceEntry>> GetFeatureMaintenances();
|
||||
Task<PreReleaseInfo?> GetPreReleaseInfo();
|
||||
Task<List<ShadowverseCardSetEntry>> GetRotationCardSets();
|
||||
Task<List<PracticeOpponentEntry>> GetPracticeOpponents();
|
||||
}
|
||||
|
||||
@@ -59,6 +59,7 @@ public class SVSimDbContext : DbContext
|
||||
public DbSet<MaintenanceCardEntry> MaintenanceCards => Set<MaintenanceCardEntry>();
|
||||
public DbSet<FeatureMaintenanceEntry> FeatureMaintenances => Set<FeatureMaintenanceEntry>();
|
||||
public DbSet<PreReleaseInfo> PreReleaseInfos => Set<PreReleaseInfo>();
|
||||
public DbSet<PracticeOpponentEntry> PracticeOpponents => Set<PracticeOpponentEntry>();
|
||||
|
||||
#endregion
|
||||
|
||||
|
||||
@@ -256,13 +256,6 @@ public class DeckController : SVSimController
|
||||
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
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
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.Database.Repositories.Deck;
|
||||
using SVSim.Database.Repositories.Globals;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.Common;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.Practice;
|
||||
@@ -13,94 +12,59 @@ 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 IDeckRepository _deckRepository;
|
||||
private readonly IGlobalsRepository _globalsRepository;
|
||||
|
||||
private readonly IViewerRepository _viewerRepository;
|
||||
|
||||
public PracticeController(IViewerRepository viewerRepository)
|
||||
public PracticeController(IDeckRepository deckRepository, IGlobalsRepository globalsRepository)
|
||||
{
|
||||
_viewerRepository = viewerRepository;
|
||||
_deckRepository = deckRepository;
|
||||
_globalsRepository = globalsRepository;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// /practice/info — returns the AI opponent catalog. Response data is a JSON array
|
||||
/// directly (not wrapped in an object), per spec.
|
||||
/// directly (not wrapped in an object), per spec. Backed by PracticeOpponents table,
|
||||
/// seeded by SVSim.Bootstrap from prod-captures/practice-info-*.json.
|
||||
/// </summary>
|
||||
[HttpPost("info")]
|
||||
public Task<List<PracticeOpponent>> Info(BaseRequest request)
|
||||
public async Task<List<PracticeOpponent>> Info(BaseRequest request)
|
||||
{
|
||||
return Task.FromResult(StubOpponents);
|
||||
var rows = await _globalsRepository.GetPracticeOpponents();
|
||||
return rows.Select(e => new PracticeOpponent
|
||||
{
|
||||
PracticeId = e.PracticeId,
|
||||
TextId = e.TextId,
|
||||
ClassId = e.ClassId,
|
||||
CharaId = e.CharaId,
|
||||
DegreeId = e.DegreeId,
|
||||
AiDeckLevel = e.AiDeckLevel,
|
||||
AiLogicLevel = e.AiLogicLevel,
|
||||
AiMaxLife = e.AiMaxLife,
|
||||
Battle3dFieldId = e.Battle3dFieldId,
|
||||
IsMaintenance = e.IsMaintenance,
|
||||
IsCampaignPractice = e.IsCampaignPractice,
|
||||
}).ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// /practice/deck_list — returns viewer's decks scoped by format (always Format.All
|
||||
/// per spec, server can ignore the request field).
|
||||
/// per spec, server can ignore the request field). Fetched via IDeckRepository so the
|
||||
/// DeckCard.Card navigation is Included; going through the heavier viewer-graph query
|
||||
/// drops that ThenInclude and ships 40 zeros instead of real card ids, which then
|
||||
/// NREs the client's SBattleLoad.InitPlayer (CardCreator returns null on id=0).
|
||||
/// </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();
|
||||
}
|
||||
if (!TryGetViewerId(out long viewerId)) return Unauthorized();
|
||||
|
||||
Viewer? viewer = await _viewerRepository.GetViewerByShortUdid(shortUdid);
|
||||
if (viewer is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
var byFormat = await _deckRepository.GetDecksByFormats(viewerId, new[] { Format.Rotation, Format.Unlimited });
|
||||
|
||||
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()
|
||||
UserDeckRotation = byFormat[Format.Rotation].Select(d => new UserDeck(d)).ToList(),
|
||||
UserDeckUnlimited = byFormat[Format.Unlimited].Select(d => new UserDeck(d)).ToList(),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using SVSim.EmulatedEntrypoint.Constants;
|
||||
using SVSim.EmulatedEntrypoint.Security;
|
||||
using SVSim.EmulatedEntrypoint.Security.SteamSessionAuthentication;
|
||||
|
||||
@@ -14,5 +15,16 @@ namespace SVSim.EmulatedEntrypoint.Controllers
|
||||
[Authorize(AuthenticationSchemes = SteamAuthenticationConstants.SchemeName)]
|
||||
public abstract class SVSimController : ControllerBase
|
||||
{
|
||||
/// <summary>
|
||||
/// Reads the authenticated viewer's internal id from the ViewerId claim populated by
|
||||
/// <c>SteamSessionAuthenticationHandler</c>. Returns false (and viewerId = 0) when the
|
||||
/// claim is missing or unparseable — handler should respond with Unauthorized().
|
||||
/// </summary>
|
||||
protected 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,9 +45,9 @@ public class PracticeOpponent
|
||||
[JsonPropertyName("battle3dfield_id")]
|
||||
[Key("battle3dfield_id")] public string Battle3dFieldId { get; set; } = "1";
|
||||
|
||||
/// <summary>Optional. true => entry disabled, client prepends maintenance suffix.</summary>
|
||||
/// <summary>true => entry disabled, client prepends maintenance suffix. Must always be emitted: client reads with `data["is_maintenance"] != null` but LitJson throws KeyNotFoundException on a missing key.</summary>
|
||||
[JsonPropertyName("is_maintenance")]
|
||||
[Key("is_maintenance")] public bool? IsMaintenance { get; set; }
|
||||
[Key("is_maintenance")] public bool IsMaintenance { get; set; }
|
||||
|
||||
/// <summary>true => entry is a special "campaign" practice (event-tied).</summary>
|
||||
[JsonPropertyName("is_campaign_practice")]
|
||||
|
||||
@@ -7,10 +7,11 @@ namespace SVSim.EmulatedEntrypoint.Models.Dtos.Responses.Practice;
|
||||
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.
|
||||
/// Mission/achievement evaluation snapshot. Client reads it via
|
||||
/// `data.Keys.Contains("mission_parameter")` so omitting the key is technically
|
||||
/// safe — but prod always emits `mission_parameter: []` and matching prod exactly
|
||||
/// avoids surprises if any other code path drops the defensive check.
|
||||
/// </summary>
|
||||
[JsonPropertyName("mission_parameter")]
|
||||
[Key("mission_parameter")] public object? MissionParameter { get; set; }
|
||||
[Key("mission_parameter")] public List<object> MissionParameter { get; set; } = new();
|
||||
}
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using SVSim.Database;
|
||||
using SVSim.Database.Enums;
|
||||
using SVSim.Database.Models;
|
||||
using SVSim.Database.Repositories.Deck;
|
||||
using SVSim.EmulatedEntrypoint.Extensions;
|
||||
using SVSim.UnitTests.Infrastructure;
|
||||
|
||||
@@ -25,6 +30,9 @@ public class PracticeControllerTests
|
||||
public async Task Info_returns_non_empty_opponent_array()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
// Practice opponents are bootstrapped from prod-captures/practice-info-*.json into the
|
||||
// PracticeOpponents table — empty by default in tests, so seed first.
|
||||
await factory.SeedGlobalsAsync();
|
||||
long viewerId = await factory.SeedViewerAsync();
|
||||
using var client = factory.CreateAuthenticatedClient(viewerId);
|
||||
|
||||
@@ -41,6 +49,26 @@ public class PracticeControllerTests
|
||||
Assert.That(doc.RootElement[0].GetProperty("practice_id").GetInt32(), Is.GreaterThan(0));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Info_returns_empty_array_when_db_not_bootstrapped()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
// Skip SeedGlobalsAsync — table is empty. /practice/info must still 200, not 500: the
|
||||
// client treats an empty array as "no opponents" and the practice menu just shows nothing.
|
||||
long viewerId = await factory.SeedViewerAsync();
|
||||
using var client = factory.CreateAuthenticatedClient(viewerId);
|
||||
|
||||
var response = await client.PostAsync("/practice/info",
|
||||
new StringContent(BaseRequestJson, Encoding.UTF8, "application/json"));
|
||||
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK), body);
|
||||
|
||||
using var doc = JsonDocument.Parse(body);
|
||||
Assert.That(doc.RootElement.ValueKind, Is.EqualTo(JsonValueKind.Array));
|
||||
Assert.That(doc.RootElement.GetArrayLength(), Is.EqualTo(0));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task DeckList_returns_viewer_decks()
|
||||
{
|
||||
@@ -65,6 +93,51 @@ public class PracticeControllerTests
|
||||
Assert.That(unlimited[0].GetProperty("deck_name").GetString(), Is.EqualTo("Unlimited Deck"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task DeckList_card_id_array_contains_real_card_ids()
|
||||
{
|
||||
// Regression for the deck-include bug: PracticeController used to load the viewer
|
||||
// via GetViewerByShortUdid, which Includes Decks but NOT Decks.Cards.Card. The
|
||||
// DeckCard.Card navigation defaults to `new ShadowverseCardEntry()` (Id=0), so the
|
||||
// wire response shipped 40 zeros — which then NREs the client's SBattleLoad
|
||||
// (CardCreator returns null for id=0 → BattlePlayerBase.AddToDeck(null)). Asserts
|
||||
// a real card id round-trips end-to-end so the same .Include drop can't reappear.
|
||||
using var factory = new SVSimTestFactory();
|
||||
long viewerId = await factory.SeedViewerAsync();
|
||||
|
||||
const long CardId = 900_123_456L;
|
||||
using (var scope = factory.Services.CreateScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
db.Cards.Add(new ShadowverseCardEntry { Id = CardId, Name = "Regression Card" });
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
await factory.SeedDeckAsync(viewerId, Format.Rotation, number: 1);
|
||||
using (var scope = factory.Services.CreateScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
var repo = scope.ServiceProvider.GetRequiredService<IDeckRepository>();
|
||||
var card = await db.Cards.FirstAsync(c => c.Id == CardId);
|
||||
await repo.UpsertDeck(viewerId, Format.Rotation, 1,
|
||||
d => d.Cards = new List<DeckCard> { new() { Card = card, Count = 3 } });
|
||||
}
|
||||
|
||||
using var client = factory.CreateAuthenticatedClient(viewerId);
|
||||
var response = await client.PostAsync("/practice/deck_list",
|
||||
new StringContent(DeckFormatRequestJson(Format.All), Encoding.UTF8, "application/json"));
|
||||
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK), body);
|
||||
|
||||
using var doc = JsonDocument.Parse(body);
|
||||
var cards = doc.RootElement.GetProperty("user_deck_rotation")[0].GetProperty("card_id_array");
|
||||
Assert.That(cards.GetArrayLength(), Is.EqualTo(3), "DeckCard.Count should expand into Count copies in card_id_array");
|
||||
for (int i = 0; i < 3; i++)
|
||||
{
|
||||
Assert.That(cards[i].GetInt64(), Is.EqualTo(CardId), "card_id must round-trip — Includes are dropping DeckCard.Card");
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task DeckList_empty_when_viewer_has_none()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user