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? mypageIndex = LoadCapture(capturesDir, "mypage-index");
|
||||||
JsonElement? deckInfo = LoadCapture(capturesDir, "deck-info");
|
JsonElement? deckInfo = LoadCapture(capturesDir, "deck-info");
|
||||||
JsonElement? paymentItemList = LoadCapture(capturesDir, "payment-item-list");
|
JsonElement? paymentItemList = LoadCapture(capturesDir, "payment-item-list");
|
||||||
|
JsonElement? practiceInfo = LoadCapture(capturesDir, "practice-info");
|
||||||
|
|
||||||
int total = 0;
|
int total = 0;
|
||||||
|
|
||||||
@@ -69,6 +70,11 @@ public class GlobalsImporter
|
|||||||
total += await ImportPaymentItems(context, paymentItemList.Value);
|
total += await ImportPaymentItems(context, paymentItemList.Value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (practiceInfo.HasValue)
|
||||||
|
{
|
||||||
|
total += await ImportPracticeOpponents(context, practiceInfo.Value);
|
||||||
|
}
|
||||||
|
|
||||||
await context.SaveChangesAsync();
|
await context.SaveChangesAsync();
|
||||||
Console.WriteLine($"[GlobalsImporter] Done: {total} total rows changed.");
|
Console.WriteLine($"[GlobalsImporter] Done: {total} total rows changed.");
|
||||||
return total;
|
return total;
|
||||||
@@ -694,6 +700,47 @@ public class GlobalsImporter
|
|||||||
private static decimal ParseDecimal(string s) =>
|
private static decimal ParseDecimal(string s) =>
|
||||||
decimal.TryParse(s, System.Globalization.NumberStyles.Number, System.Globalization.CultureInfo.InvariantCulture, out var d) ? d : 0m;
|
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 ----------
|
// ---------- Helpers ----------
|
||||||
|
|
||||||
private static void WarnOrphans(string label, int count)
|
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");
|
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 =>
|
modelBuilder.Entity("SVSim.Database.Models.PreReleaseInfo", b =>
|
||||||
{
|
{
|
||||||
b.Property<int>("Id")
|
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() =>
|
public Task<List<ShadowverseCardSetEntry>> GetRotationCardSets() =>
|
||||||
_dbContext.CardSets.AsNoTracking().Where(s => s.IsInRotation).ToListAsync();
|
_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<List<FeatureMaintenanceEntry>> GetFeatureMaintenances();
|
||||||
Task<PreReleaseInfo?> GetPreReleaseInfo();
|
Task<PreReleaseInfo?> GetPreReleaseInfo();
|
||||||
Task<List<ShadowverseCardSetEntry>> GetRotationCardSets();
|
Task<List<ShadowverseCardSetEntry>> GetRotationCardSets();
|
||||||
|
Task<List<PracticeOpponentEntry>> GetPracticeOpponents();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ public class SVSimDbContext : DbContext
|
|||||||
public DbSet<MaintenanceCardEntry> MaintenanceCards => Set<MaintenanceCardEntry>();
|
public DbSet<MaintenanceCardEntry> MaintenanceCards => Set<MaintenanceCardEntry>();
|
||||||
public DbSet<FeatureMaintenanceEntry> FeatureMaintenances => Set<FeatureMaintenanceEntry>();
|
public DbSet<FeatureMaintenanceEntry> FeatureMaintenances => Set<FeatureMaintenanceEntry>();
|
||||||
public DbSet<PreReleaseInfo> PreReleaseInfos => Set<PreReleaseInfo>();
|
public DbSet<PreReleaseInfo> PreReleaseInfos => Set<PreReleaseInfo>();
|
||||||
|
public DbSet<PracticeOpponentEntry> PracticeOpponents => Set<PracticeOpponentEntry>();
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
|
|||||||
@@ -256,13 +256,6 @@ public class DeckController : SVSimController
|
|||||||
return Task.FromResult<ActionResult<EmptyResponse>>(new EmptyResponse());
|
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>
|
/// <summary>
|
||||||
/// Convert a flat `card_id_array` (cards repeated for count) into a grouped DeckCard list.
|
/// 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
|
/// Cards not in the DB are silently dropped — until CardImport lands the result is always
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using SVSim.Database.Enums;
|
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;
|
||||||
|
using SVSim.Database.Repositories.Deck;
|
||||||
|
using SVSim.Database.Repositories.Globals;
|
||||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests;
|
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests;
|
||||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.Common;
|
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.Common;
|
||||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.Practice;
|
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.Practice;
|
||||||
@@ -13,94 +12,59 @@ namespace SVSim.EmulatedEntrypoint.Controllers;
|
|||||||
|
|
||||||
public class PracticeController : SVSimController
|
public class PracticeController : SVSimController
|
||||||
{
|
{
|
||||||
// Hand-curated AI opponents (audit B14 pattern). Replace with master data when the
|
private readonly IDeckRepository _deckRepository;
|
||||||
// practice subsystem is built out.
|
private readonly IGlobalsRepository _globalsRepository;
|
||||||
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(IDeckRepository deckRepository, IGlobalsRepository globalsRepository)
|
||||||
|
|
||||||
public PracticeController(IViewerRepository viewerRepository)
|
|
||||||
{
|
{
|
||||||
_viewerRepository = viewerRepository;
|
_deckRepository = deckRepository;
|
||||||
|
_globalsRepository = globalsRepository;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// /practice/info — returns the AI opponent catalog. Response data is a JSON array
|
/// /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>
|
/// </summary>
|
||||||
[HttpPost("info")]
|
[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>
|
/// <summary>
|
||||||
/// /practice/deck_list — returns viewer's decks scoped by format (always Format.All
|
/// /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>
|
/// </summary>
|
||||||
[HttpPost("deck_list")]
|
[HttpPost("deck_list")]
|
||||||
public async Task<ActionResult<PracticeDeckListResponse>> DeckList(DeckFormatRequest request)
|
public async Task<ActionResult<PracticeDeckListResponse>> DeckList(DeckFormatRequest request)
|
||||||
{
|
{
|
||||||
var shortUdidClaim = User.Claims.FirstOrDefault(c => c.Type == ShadowverseClaimTypes.ShortUdidClaim)?.Value;
|
if (!TryGetViewerId(out long viewerId)) return Unauthorized();
|
||||||
if (shortUdidClaim is null || !long.TryParse(shortUdidClaim, out long shortUdid))
|
|
||||||
{
|
|
||||||
return Unauthorized();
|
|
||||||
}
|
|
||||||
|
|
||||||
Viewer? viewer = await _viewerRepository.GetViewerByShortUdid(shortUdid);
|
var byFormat = await _deckRepository.GetDecksByFormats(viewerId, new[] { Format.Rotation, Format.Unlimited });
|
||||||
if (viewer is null)
|
|
||||||
{
|
|
||||||
return NotFound();
|
|
||||||
}
|
|
||||||
|
|
||||||
return new PracticeDeckListResponse
|
return new PracticeDeckListResponse
|
||||||
{
|
{
|
||||||
MaintenanceCardList = new List<long>(),
|
MaintenanceCardList = new List<long>(),
|
||||||
UserDeckRotation = viewer.Decks.Where(d => d.Format == Format.Rotation)
|
UserDeckRotation = byFormat[Format.Rotation].Select(d => new UserDeck(d)).ToList(),
|
||||||
.Select(d => new UserDeck(d)).ToList(),
|
UserDeckUnlimited = byFormat[Format.Unlimited].Select(d => new UserDeck(d)).ToList(),
|
||||||
UserDeckUnlimited = viewer.Decks.Where(d => d.Format == Format.Unlimited)
|
|
||||||
.Select(d => new UserDeck(d)).ToList()
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Http;
|
using Microsoft.AspNetCore.Http;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using SVSim.EmulatedEntrypoint.Constants;
|
||||||
using SVSim.EmulatedEntrypoint.Security;
|
using SVSim.EmulatedEntrypoint.Security;
|
||||||
using SVSim.EmulatedEntrypoint.Security.SteamSessionAuthentication;
|
using SVSim.EmulatedEntrypoint.Security.SteamSessionAuthentication;
|
||||||
|
|
||||||
@@ -14,5 +15,16 @@ namespace SVSim.EmulatedEntrypoint.Controllers
|
|||||||
[Authorize(AuthenticationSchemes = SteamAuthenticationConstants.SchemeName)]
|
[Authorize(AuthenticationSchemes = SteamAuthenticationConstants.SchemeName)]
|
||||||
public abstract class SVSimController : ControllerBase
|
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")]
|
[JsonPropertyName("battle3dfield_id")]
|
||||||
[Key("battle3dfield_id")] public string Battle3dFieldId { get; set; } = "1";
|
[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")]
|
[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>
|
/// <summary>true => entry is a special "campaign" practice (event-tied).</summary>
|
||||||
[JsonPropertyName("is_campaign_practice")]
|
[JsonPropertyName("is_campaign_practice")]
|
||||||
|
|||||||
@@ -7,10 +7,11 @@ namespace SVSim.EmulatedEntrypoint.Models.Dtos.Responses.Practice;
|
|||||||
public class PracticeStartResponse
|
public class PracticeStartResponse
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Optional mission/achievement evaluation snapshot. Spec: safe to omit entirely;
|
/// Mission/achievement evaluation snapshot. Client reads it via
|
||||||
/// client tolerates absence (defensive `Keys.Contains` check). Always null in our
|
/// `data.Keys.Contains("mission_parameter")` so omitting the key is technically
|
||||||
/// minimal impl 窶・we don't model missions.
|
/// safe — but prod always emits `mission_parameter: []` and matching prod exactly
|
||||||
|
/// avoids surprises if any other code path drops the defensive check.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[JsonPropertyName("mission_parameter")]
|
[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.Net;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using SVSim.Database;
|
||||||
using SVSim.Database.Enums;
|
using SVSim.Database.Enums;
|
||||||
|
using SVSim.Database.Models;
|
||||||
|
using SVSim.Database.Repositories.Deck;
|
||||||
using SVSim.EmulatedEntrypoint.Extensions;
|
using SVSim.EmulatedEntrypoint.Extensions;
|
||||||
using SVSim.UnitTests.Infrastructure;
|
using SVSim.UnitTests.Infrastructure;
|
||||||
|
|
||||||
@@ -25,6 +30,9 @@ public class PracticeControllerTests
|
|||||||
public async Task Info_returns_non_empty_opponent_array()
|
public async Task Info_returns_non_empty_opponent_array()
|
||||||
{
|
{
|
||||||
using var factory = new SVSimTestFactory();
|
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();
|
long viewerId = await factory.SeedViewerAsync();
|
||||||
using var client = factory.CreateAuthenticatedClient(viewerId);
|
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));
|
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]
|
[Test]
|
||||||
public async Task DeckList_returns_viewer_decks()
|
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"));
|
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]
|
[Test]
|
||||||
public async Task DeckList_empty_when_viewer_has_none()
|
public async Task DeckList_empty_when_viewer_has_none()
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user