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