From f754ef1ad341a9cec516ec2b7bba77d598f87e1a Mon Sep 17 00:00:00 2001 From: gamer147 Date: Fri, 29 May 2026 21:03:10 -0400 Subject: [PATCH] fix(import): tolerate numeric my_rotation_id; skip empty deck slots MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A real /load/index dump emits my_rotation_id as a bare number (0) for unset MyRotation slots, which 400'd against the string? DTO field (AllowReadingFromString only covers string->number). FlexibleStringConverter accepts either form. Also skip empty deck slots (no cards) on import — a dump carries every slot, mostly empty placeholders the client manages itself. Co-Authored-By: Claude Opus 4.8 --- .../Controllers/AdminController.cs | 6 +++ .../Dtos/Common/FlexibleStringConverter.cs | 29 +++++++++++ .../Requests/Admin/ImportViewerRequest.cs | 7 ++- .../Controllers/AdminControllerTests.cs | 50 +++++++++++++++++++ 4 files changed, 91 insertions(+), 1 deletion(-) create mode 100644 SVSim.EmulatedEntrypoint/Models/Dtos/Common/FlexibleStringConverter.cs diff --git a/SVSim.EmulatedEntrypoint/Controllers/AdminController.cs b/SVSim.EmulatedEntrypoint/Controllers/AdminController.cs index 5fa212e..8b5c28c 100644 --- a/SVSim.EmulatedEntrypoint/Controllers/AdminController.cs +++ b/SVSim.EmulatedEntrypoint/Controllers/AdminController.cs @@ -206,6 +206,12 @@ public class AdminController : SVSimController foreach (var d in request.Decks) { + // A /load/index dump carries every deck slot, most of them empty placeholders + // (no cards). Skip them: the client manages empty slots itself (it's why the old + // default-deck cloning was removed), and importing empty MyRotation slots would + // otherwise persist decks with a bogus rotation id. + if ((d.CardIdArray?.Count ?? 0) == 0) continue; + Format format; try { format = FormatExtensions.FromApi(d.DeckFormat); } catch (ArgumentOutOfRangeException) { continue; } // skip unsupported wire format diff --git a/SVSim.EmulatedEntrypoint/Models/Dtos/Common/FlexibleStringConverter.cs b/SVSim.EmulatedEntrypoint/Models/Dtos/Common/FlexibleStringConverter.cs new file mode 100644 index 0000000..38ef57d --- /dev/null +++ b/SVSim.EmulatedEntrypoint/Models/Dtos/Common/FlexibleStringConverter.cs @@ -0,0 +1,29 @@ +using System.Globalization; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SVSim.EmulatedEntrypoint.Models.Dtos.Common; + +/// +/// Reads a JSON string OR number as a nullable string, tolerating prod's polymorphic id fields. +/// rotation_id on a /load/index UserDeck is a numeric string ("10008") for real +/// MyRotation decks but a bare number (0) for unset slots — and the global +/// AllowReadingFromString only covers the string→number direction, not number→string, so a +/// plain string? property 400s on the numeric form. Null stays null; numbers serialize via +/// invariant culture so a captured 0 round-trips to "0". +/// +public sealed class FlexibleStringConverter : JsonConverter +{ + public override string? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => + reader.TokenType switch + { + JsonTokenType.Null => null, + JsonTokenType.String => reader.GetString(), + JsonTokenType.Number when reader.TryGetInt64(out var n) => n.ToString(CultureInfo.InvariantCulture), + JsonTokenType.Number => reader.GetDouble().ToString(CultureInfo.InvariantCulture), + _ => throw new JsonException($"Unexpected token {reader.TokenType} for a string-or-number field.") + }; + + public override void Write(Utf8JsonWriter writer, string? value, JsonSerializerOptions options) => + writer.WriteStringValue(value); +} diff --git a/SVSim.EmulatedEntrypoint/Models/Dtos/Requests/Admin/ImportViewerRequest.cs b/SVSim.EmulatedEntrypoint/Models/Dtos/Requests/Admin/ImportViewerRequest.cs index 0ea16b7..9f17994 100644 --- a/SVSim.EmulatedEntrypoint/Models/Dtos/Requests/Admin/ImportViewerRequest.cs +++ b/SVSim.EmulatedEntrypoint/Models/Dtos/Requests/Admin/ImportViewerRequest.cs @@ -1,4 +1,5 @@ using System.Text.Json.Serialization; +using SVSim.EmulatedEntrypoint.Models.Dtos.Common; namespace SVSim.EmulatedEntrypoint.Models.Dtos.Requests.Admin; @@ -44,7 +45,11 @@ public class ImportDeck [JsonPropertyName("sleeve_id")] public long? SleeveId { get; set; } [JsonPropertyName("leader_skin_id")] public int? LeaderSkinId { get; set; } [JsonPropertyName("is_random_leader_skin")] public int? IsRandomLeaderSkin { get; set; } - [JsonPropertyName("my_rotation_id")] public string? MyRotationId { get; set; } + // Prod emits rotation_id as a numeric string ("10008") for real MyRotation decks but a bare + // number (0) for unset slots; FlexibleStringConverter accepts either (a plain string? 400s on + // the numeric form because AllowReadingFromString only covers string→number). + [JsonPropertyName("my_rotation_id")] [JsonConverter(typeof(FlexibleStringConverter))] + public string? MyRotationId { get; set; } } public class ImportCurrency diff --git a/SVSim.UnitTests/Controllers/AdminControllerTests.cs b/SVSim.UnitTests/Controllers/AdminControllerTests.cs index 37796b8..6c544fb 100644 --- a/SVSim.UnitTests/Controllers/AdminControllerTests.cs +++ b/SVSim.UnitTests/Controllers/AdminControllerTests.cs @@ -400,4 +400,54 @@ public class AdminControllerTests Assert.That(stored.Decks.Any(d => d.Name == "Wire Deck" && d.Format == Format.Rotation), Is.True, "decks snake_case keys must bind (deck_format/deck_no/class_id/card_id_array/...)."); } + + [Test] + public async Task ImportViewer_tolerates_numeric_my_rotation_id_and_skips_empty_decks() + { + using var factory = new SVSimTestFactory(); + const ulong steamId = 76_561_198_111_222_341UL; + long viewerId = await factory.SeedViewerAsync(steamId: steamId); + + int classId, leaderSkinId; long sleeveId; + using (var scope = factory.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + classId = (await db.Classes.FirstAsync()).Id; + sleeveId = (await db.Sleeves.FirstAsync()).Id; + leaderSkinId = (await db.LeaderSkins.FirstAsync()).Id; + } + + // Mirrors a real prod dump: empty MyRotation slots carry "my_rotation_id": 0 (a NUMBER, + // not a string), and dozens of empty slots accompany the few real decks. + string json = $$""" + { + "steam_id": {{steamId}}, + "decks": [ + { "deck_format": 2, "deck_no": 1, "deck_name": "Real", "class_id": {{classId}}, + "sleeve_id": {{sleeveId}}, "leader_skin_id": {{leaderSkinId}}, + "is_random_leader_skin": 0, "card_id_array": [10001001, 10001002] }, + { "deck_format": 5, "deck_no": 1, "deck_name": "", "class_id": 0, + "sleeve_id": 3000011, "leader_skin_id": 0, "is_random_leader_skin": 0, + "my_rotation_id": 0, "card_id_array": [] }, + { "deck_format": 1, "deck_no": 3, "deck_name": "", "class_id": 1, + "sleeve_id": 3000011, "leader_skin_id": 0, "is_random_leader_skin": 0, + "card_id_array": [] } + ] + } + """; + + using var client = factory.CreateClient(); + using var content = new StringContent(json, System.Text.Encoding.UTF8, "application/json"); + var response = await client.PostAsync("/admin/import_viewer", content); + + Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK), + await response.Content.ReadAsStringAsync()); + + using var scope2 = factory.Services.CreateScope(); + var db2 = scope2.ServiceProvider.GetRequiredService(); + var stored = await db2.Viewers.Include(v => v.Decks).FirstAsync(v => v.Id == viewerId); + Assert.That(stored.Decks.Count, Is.EqualTo(1), + "Empty deck slots must be skipped; only the real (non-empty) deck imports."); + Assert.That(stored.Decks.Single().Name, Is.EqualTo("Real")); + } }