Practice battles work

This commit is contained in:
gamer147
2026-05-23 22:46:11 -04:00
parent 704542786a
commit 21b97269ff
15 changed files with 34968 additions and 82 deletions

File diff suppressed because one or more lines are too long

View File

@@ -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)

File diff suppressed because it is too large Load Diff

View File

@@ -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");
}
}
}

View File

@@ -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")

View 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; }
}

View File

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

View File

@@ -32,4 +32,5 @@ public interface IGlobalsRepository
Task<List<FeatureMaintenanceEntry>> GetFeatureMaintenances();
Task<PreReleaseInfo?> GetPreReleaseInfo();
Task<List<ShadowverseCardSetEntry>> GetRotationCardSets();
Task<List<PracticeOpponentEntry>> GetPracticeOpponents();
}

View File

@@ -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

View File

@@ -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

View File

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

View File

@@ -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);
}
}
}

View File

@@ -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")]

View File

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

View File

@@ -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()
{