diff --git a/SVSim.EmulatedEntrypoint/Models/Dtos/DailyLoginBonus.cs b/SVSim.EmulatedEntrypoint/Models/Dtos/DailyLoginBonus.cs
index b72e0eb4..5346c40b 100644
--- a/SVSim.EmulatedEntrypoint/Models/Dtos/DailyLoginBonus.cs
+++ b/SVSim.EmulatedEntrypoint/Models/Dtos/DailyLoginBonus.cs
@@ -3,16 +3,24 @@ using System.Text.Json.Serialization;
namespace SVSim.EmulatedEntrypoint.Models.Dtos;
+///
+/// Wire shape: { normal?, total?, campaign? (array) }. Client parser at
+/// LoadDetail.cs:553 reads exactly these three keys. normal presence drives
+/// IsExistLoginBonusData() / popup eligibility.
+///
[MessagePackObject]
public class DailyLoginBonus
{
- [JsonPropertyName("total")]
- [Key("total")]
- public LoginBonusCampaign? Total { get; set; }
- [JsonPropertyName("normal")]
- [Key("normal")]
+ [JsonPropertyName("normal")] [Key("normal")]
public LoginBonusCampaign? Normal { get; set; }
- [JsonPropertyName("campaign")]
- [Key("campaign")]
- public LoginBonusCampaign? Campaign { get; set; }
-}
\ No newline at end of file
+
+ [JsonPropertyName("total")] [Key("total")]
+ public LoginBonusCampaign? Total { get; set; }
+
+ ///
+ /// Array of campaign panels (LoadDetail.cs:583 iterates jsonData3[i]). Empty list
+ /// when no specials are active — prod sends [] not null in that case.
+ ///
+ [JsonPropertyName("campaign")] [Key("campaign")]
+ public List Campaign { get; set; } = new();
+}
diff --git a/SVSim.EmulatedEntrypoint/Models/Dtos/LoginBonusCampaign.cs b/SVSim.EmulatedEntrypoint/Models/Dtos/LoginBonusCampaign.cs
index 7a4aedeb..65eab5b7 100644
--- a/SVSim.EmulatedEntrypoint/Models/Dtos/LoginBonusCampaign.cs
+++ b/SVSim.EmulatedEntrypoint/Models/Dtos/LoginBonusCampaign.cs
@@ -3,25 +3,38 @@ using System.Text.Json.Serialization;
namespace SVSim.EmulatedEntrypoint.Models.Dtos;
+///
+/// One login-bonus campaign panel. Used for normal, each campaign array entry, and (when
+/// populated) total. campaign_id and img ship as strings on the wire. Client
+/// consumers: NormalData (reads name, now_count, is_next_reward, reward[]),
+/// SpecialData (also reads start_date, end_date, img, is_one_day_multi_rewards),
+/// ContinuousData (reads only reward[]).
+///
[MessagePackObject]
public class LoginBonusCampaign
{
- [JsonPropertyName("name")]
- [Key("name")]
+ [JsonPropertyName("name")] [Key("name")]
public string Name { get; set; } = string.Empty;
- [JsonPropertyName("campaign_id")]
- [Key("campaign_id")]
- public int CampaignId { get; set; }
- [JsonPropertyName("img")]
- [Key("img")]
- public int Image { get; set; }
- [JsonPropertyName("now_count")]
- [Key("now_count")]
+
+ [JsonPropertyName("campaign_id")] [Key("campaign_id")]
+ public string CampaignId { get; set; } = "0";
+
+ [JsonPropertyName("img")] [Key("img")]
+ public string Img { get; set; } = "0";
+
+ [JsonPropertyName("now_count")] [Key("now_count")]
public int NowCount { get; set; }
- [JsonPropertyName("is_next_reward")]
- [Key("is_next_reward")]
+
+ [JsonPropertyName("is_next_reward")] [Key("is_next_reward")]
public bool IsNextReward { get; set; }
- [JsonPropertyName("reward")]
- [Key("reward")]
- public List Rewards { get; set; } = new List();
-}
\ No newline at end of file
+
+ [JsonPropertyName("reward")] [Key("reward")]
+ public List Rewards { get; set; } = new();
+
+ ///
+ /// SpecialData-only on the client (SpecialData.cs:50). Server emits it on every panel
+ /// for byte-faithful replay; client ignores it on Normal/Total paths.
+ ///
+ [JsonPropertyName("is_one_day_multi_rewards")] [Key("is_one_day_multi_rewards")]
+ public bool IsOneDayMultiRewards { get; set; }
+}
diff --git a/SVSim.EmulatedEntrypoint/Models/Dtos/LoginBonusReward.cs b/SVSim.EmulatedEntrypoint/Models/Dtos/LoginBonusReward.cs
index 5e3c04da..28cd4788 100644
--- a/SVSim.EmulatedEntrypoint/Models/Dtos/LoginBonusReward.cs
+++ b/SVSim.EmulatedEntrypoint/Models/Dtos/LoginBonusReward.cs
@@ -3,34 +3,23 @@ using System.Text.Json.Serialization;
namespace SVSim.EmulatedEntrypoint.Models.Dtos;
+///
+/// One day's reward in a login-bonus cycle. All numeric fields ship as quoted strings on
+/// the wire — prod-confirmed shape from traffic_prod_tutorial.ndjson. Client uses
+/// JsonData.ToInt() so it tolerates either form, but we match prod exactly.
+///
[MessagePackObject]
public class LoginBonusReward
{
- ///
- /// The effect shown ingame.
- ///
- [JsonPropertyName("effect_id")]
- [Key("effect_id")]
- public int EffectId { get; set; }
-
- ///
- /// The type of reward.
- ///
- [JsonPropertyName("reward_type")]
- [Key("reward_type")]
- public int RewardType { get; set; }
-
- ///
- /// A more specified reward type (ie if a pack, which pack).
- ///
- [JsonPropertyName("reward_detail_id")]
- [Key("reward_detail_id")]
- public int RewardDetailId { get; set; }
-
- ///
- /// The count of the reward.
- ///
- [JsonPropertyName("reward_number")]
- [Key("reward_number")]
- public int RewardNumber { get; set; }
-}
\ No newline at end of file
+ [JsonPropertyName("effect_id")] [Key("effect_id")]
+ public string EffectId { get; set; } = "0";
+
+ [JsonPropertyName("reward_type")] [Key("reward_type")]
+ public string RewardType { get; set; } = "0";
+
+ [JsonPropertyName("reward_detail_id")] [Key("reward_detail_id")]
+ public string RewardDetailId { get; set; } = "0";
+
+ [JsonPropertyName("reward_number")] [Key("reward_number")]
+ public string RewardNumber { get; set; } = "0";
+}
diff --git a/SVSim.EmulatedEntrypoint/Models/Dtos/Responses/IndexResponse.cs b/SVSim.EmulatedEntrypoint/Models/Dtos/Responses/IndexResponse.cs
index 29eb205b..85aee91f 100644
--- a/SVSim.EmulatedEntrypoint/Models/Dtos/Responses/IndexResponse.cs
+++ b/SVSim.EmulatedEntrypoint/Models/Dtos/Responses/IndexResponse.cs
@@ -149,8 +149,10 @@ public class IndexResponse
public List UserRankedMatches { get; set; } = new();
///
- /// Spec: optional. Shape is {normal?, total?, campaign?[]} per common/types.ts.md DailyLoginBonus.
- /// Populated by ILoginBonusService when the viewer has an active bonus period; null otherwise.
+ /// Spec: optional. Shape {normal?, total?, campaign?[]} — client parses three keys
+ /// in LoadDetail.cs:553. normal presence triggers the login-bonus popup
+ /// (NextSceneSwitcher.cs:103). Populated by ILoginBonusService at /load/index time;
+ /// null when the viewer has already claimed today's bonus.
///
[JsonPropertyName("daily_login_bonus")]
[Key("daily_login_bonus")]
diff --git a/SVSim.UnitTests/Dtos/DailyLoginBonusWireShapeTests.cs b/SVSim.UnitTests/Dtos/DailyLoginBonusWireShapeTests.cs
new file mode 100644
index 00000000..8513a6e4
--- /dev/null
+++ b/SVSim.UnitTests/Dtos/DailyLoginBonusWireShapeTests.cs
@@ -0,0 +1,59 @@
+using System.Text.Json;
+using NUnit.Framework;
+using SVSim.EmulatedEntrypoint.Models.Dtos;
+
+namespace SVSim.UnitTests.Dtos;
+
+public class DailyLoginBonusWireShapeTests
+{
+ private static readonly JsonSerializerOptions Opts = new()
+ {
+ PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
+ DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull,
+ };
+
+ [Test]
+ public void Reward_numeric_fields_serialize_as_strings()
+ {
+ var r = new LoginBonusReward
+ {
+ EffectId = "1", RewardType = "9", RewardDetailId = "0", RewardNumber = "20"
+ };
+
+ var json = JsonSerializer.Serialize(r, Opts);
+ using var doc = JsonDocument.Parse(json);
+ Assert.That(doc.RootElement.GetProperty("effect_id").ValueKind, Is.EqualTo(JsonValueKind.String));
+ Assert.That(doc.RootElement.GetProperty("reward_type").ValueKind, Is.EqualTo(JsonValueKind.String));
+ Assert.That(doc.RootElement.GetProperty("reward_detail_id").ValueKind, Is.EqualTo(JsonValueKind.String));
+ Assert.That(doc.RootElement.GetProperty("reward_number").ValueKind, Is.EqualTo(JsonValueKind.String));
+ }
+
+ [Test]
+ public void Campaign_id_and_img_serialize_as_strings()
+ {
+ var c = new LoginBonusCampaign { CampaignId = "3", Img = "0", Name = "Daily Bonus" };
+ var json = JsonSerializer.Serialize(c, Opts);
+ using var doc = JsonDocument.Parse(json);
+ Assert.That(doc.RootElement.GetProperty("campaign_id").ValueKind, Is.EqualTo(JsonValueKind.String));
+ Assert.That(doc.RootElement.GetProperty("img").ValueKind, Is.EqualTo(JsonValueKind.String));
+ }
+
+ [Test]
+ public void Campaign_supports_is_one_day_multi_rewards_flag()
+ {
+ var c = new LoginBonusCampaign { IsOneDayMultiRewards = false };
+ var json = JsonSerializer.Serialize(c, Opts);
+ using var doc = JsonDocument.Parse(json);
+ Assert.That(doc.RootElement.TryGetProperty("is_one_day_multi_rewards", out var v), Is.True);
+ Assert.That(v.ValueKind, Is.EqualTo(JsonValueKind.False));
+ }
+
+ [Test]
+ public void DailyLoginBonus_campaign_is_an_array()
+ {
+ var d = new DailyLoginBonus { Normal = new LoginBonusCampaign(), Campaign = new List() };
+ var json = JsonSerializer.Serialize(d, Opts);
+ using var doc = JsonDocument.Parse(json);
+ Assert.That(doc.RootElement.GetProperty("campaign").ValueKind, Is.EqualTo(JsonValueKind.Array));
+ }
+}