diff --git a/SVSim.EmulatedEntrypoint/Controllers/CampaignController.cs b/SVSim.EmulatedEntrypoint/Controllers/CampaignController.cs
new file mode 100644
index 0000000..bd19353
--- /dev/null
+++ b/SVSim.EmulatedEntrypoint/Controllers/CampaignController.cs
@@ -0,0 +1,93 @@
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.EntityFrameworkCore;
+using SVSim.Database;
+using SVSim.Database.Enums;
+using SVSim.Database.Models;
+using SVSim.EmulatedEntrypoint.Models.Dtos.Campaign;
+
+namespace SVSim.EmulatedEntrypoint.Controllers;
+
+///
+/// /campaign/* — promotional surfaces. Currently just regist_serial_code.
+///
+[Route("campaign")]
+public sealed class CampaignController : SVSimController
+{
+ private const int FailureResultCode = 4202;
+
+ private readonly SVSimDbContext _db;
+
+ public CampaignController(SVSimDbContext db) => _db = db;
+
+ [HttpPost("regist_serial_code")]
+ public async Task RegisterSerialCode(
+ [FromBody] RegisterSerialCodeRequest request,
+ CancellationToken ct)
+ {
+ if (!TryGetViewerId(out var viewerId)) return Unauthorized();
+
+ var now = DateTime.UtcNow;
+
+ var code = await _db.SerialCodes
+ .Include(c => c.Rewards)
+ .FirstOrDefaultAsync(c => c.Code == request.SerialCode, ct);
+
+ if (code is null) return Fail();
+ if (!code.IsEnabled) return Fail();
+ if (code.StartAt is { } start && start > now) return Fail();
+ if (code.EndAt is { } end && end < now) return Fail();
+
+ bool alreadyRedeemed = await _db.ViewerSerialCodeRedemptions
+ .AnyAsync(r => r.ViewerId == viewerId && r.SerialCodeId == code.Id, ct);
+ if (alreadyRedeemed) return Fail();
+
+ if (code.Rewards.Any(r => !IsSupportedGiftRewardType(r.RewardType))) return Fail();
+
+ try
+ {
+ _db.ViewerSerialCodeRedemptions.Add(new ViewerSerialCodeRedemption
+ {
+ ViewerId = viewerId,
+ SerialCodeId = code.Id,
+ RedeemedAt = now,
+ });
+
+ foreach (var reward in code.Rewards.OrderBy(r => r.Slot))
+ {
+ _db.ViewerPresents.Add(new ViewerPresent
+ {
+ ViewerId = viewerId,
+ PresentId = Guid.NewGuid().ToString("N").Substring(0, 16),
+ Status = PresentStatus.Unclaimed,
+ RewardType = reward.RewardType,
+ RewardDetailId = reward.RewardDetailId,
+ RewardCount = reward.RewardCount,
+ Message = code.Message,
+ CreatedAt = now,
+ Source = $"serial_code:{code.Id}",
+ });
+ }
+
+ await _db.SaveChangesAsync(ct);
+ }
+ catch (DbUpdateException)
+ {
+ // Race: two concurrent redeems for the same (viewer, code). The composite PK
+ // on ViewerSerialCodeRedemption rejects the second one; treat as already-redeemed.
+ return Fail();
+ }
+
+ return Ok(new RegisterSerialCodeResponse { IsComplete = true });
+ }
+
+ private IActionResult Fail() => Ok(new { result_code = FailureResultCode });
+
+ ///
+ /// Gift wire types per GiftController.WireRewardTypeToUserGoodsType:
+ /// 1=Crystal, 4=Item, 9=Rupy. Codes with unsupported types fail-fast at redemption.
+ /// Note: wire "1" means Crystal (not RedEther), following the gift wire convention
+ /// rather than the enum order.
+ ///
+ private static bool IsSupportedGiftRewardType(int rewardType) =>
+ rewardType is 1 or 4 or 9;
+}
diff --git a/SVSim.EmulatedEntrypoint/Models/Dtos/Campaign/RegisterSerialCodeRequest.cs b/SVSim.EmulatedEntrypoint/Models/Dtos/Campaign/RegisterSerialCodeRequest.cs
new file mode 100644
index 0000000..3ef5abb
--- /dev/null
+++ b/SVSim.EmulatedEntrypoint/Models/Dtos/Campaign/RegisterSerialCodeRequest.cs
@@ -0,0 +1,17 @@
+using System.Text.Json.Serialization;
+using MessagePack;
+
+namespace SVSim.EmulatedEntrypoint.Models.Dtos.Campaign;
+
+///
+/// Body of POST /campaign/regist_serial_code. Client task:
+/// MyPageCodeInputTask (Shadowverse_Code_2026-05-23/Wizard/MyPageCodeInputTask.cs).
+///
+[MessagePackObject]
+public sealed class RegisterSerialCodeRequest
+{
+ /// User-typed serial code. Case-sensitive on the server.
+ [JsonPropertyName("serial_code")]
+ [Key("serial_code")]
+ public string SerialCode { get; set; } = string.Empty;
+}
diff --git a/SVSim.EmulatedEntrypoint/Models/Dtos/Campaign/RegisterSerialCodeResponse.cs b/SVSim.EmulatedEntrypoint/Models/Dtos/Campaign/RegisterSerialCodeResponse.cs
new file mode 100644
index 0000000..3c57ba0
--- /dev/null
+++ b/SVSim.EmulatedEntrypoint/Models/Dtos/Campaign/RegisterSerialCodeResponse.cs
@@ -0,0 +1,16 @@
+using System.Text.Json.Serialization;
+using MessagePack;
+
+namespace SVSim.EmulatedEntrypoint.Models.Dtos.Campaign;
+
+///
+/// Success response shape. Failure path uses an anonymous { result_code = 4202 }
+/// (mirroring AchievementController/MissionController) and bypasses this DTO.
+///
+[MessagePackObject]
+public sealed class RegisterSerialCodeResponse
+{
+ [JsonPropertyName("is_complete")]
+ [Key("is_complete")]
+ public bool IsComplete { get; set; }
+}
diff --git a/SVSim.UnitTests/Controllers/CampaignControllerTests.cs b/SVSim.UnitTests/Controllers/CampaignControllerTests.cs
new file mode 100644
index 0000000..3d2cbf0
--- /dev/null
+++ b/SVSim.UnitTests/Controllers/CampaignControllerTests.cs
@@ -0,0 +1,230 @@
+using System.Net;
+using System.Net.Http.Json;
+using System.Text;
+using System.Text.Json;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.DependencyInjection;
+using SVSim.Database;
+using SVSim.Database.Models;
+using SVSim.UnitTests.Infrastructure;
+
+namespace SVSim.UnitTests.Controllers;
+
+public class CampaignControllerTests
+{
+ private static StringContent JsonBody(string json) => new(json, Encoding.UTF8, "application/json");
+
+ private static async Task SeedCodeAsync(
+ SVSimTestFactory factory, string code, string message = "test message",
+ bool enabled = true, DateTime? startAt = null, DateTime? endAt = null,
+ params (int Type, long DetailId, int Count)[] rewards)
+ {
+ using var scope = factory.Services.CreateScope();
+ var ctx = scope.ServiceProvider.GetRequiredService();
+ var entity = new SerialCodeEntry
+ {
+ Code = code,
+ Message = message,
+ IsEnabled = enabled,
+ StartAt = startAt,
+ EndAt = endAt,
+ };
+ for (int i = 0; i < rewards.Length; i++)
+ {
+ entity.Rewards.Add(new SerialCodeRewardEntry
+ {
+ Slot = i,
+ RewardType = rewards[i].Type,
+ RewardDetailId = rewards[i].DetailId,
+ RewardCount = rewards[i].Count,
+ });
+ }
+ ctx.SerialCodes.Add(entity);
+ await ctx.SaveChangesAsync();
+ return entity;
+ }
+
+ [Test]
+ public async Task Register_with_valid_unredeemed_code_returns_success_and_creates_presents()
+ {
+ using var factory = new SVSimTestFactory();
+ long viewerId = await factory.SeedViewerAsync();
+ var code = await SeedCodeAsync(factory, "VALID1", "Welcome reward",
+ rewards: new[] { (1, 0L, 100), (9, 0L, 500) });
+
+ using var client = factory.CreateAuthenticatedClient(viewerId);
+ var response = await client.PostAsync("/campaign/regist_serial_code",
+ JsonBody("""{"serial_code":"VALID1"}"""));
+ var raw = await response.Content.ReadAsStringAsync();
+ Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK), raw);
+
+ using var doc = JsonDocument.Parse(raw);
+ Assert.That(doc.RootElement.GetProperty("is_complete").GetBoolean(), Is.True);
+
+ using var verifyScope = factory.Services.CreateScope();
+ var ctx = verifyScope.ServiceProvider.GetRequiredService();
+ var presents = await ctx.ViewerPresents.AsNoTracking()
+ .Where(p => p.ViewerId == viewerId).OrderBy(p => p.RewardType).ToListAsync();
+ Assert.That(presents, Has.Count.EqualTo(2));
+ Assert.That(presents.All(p => p.Message == "Welcome reward"), Is.True);
+ Assert.That(presents.All(p => p.Source == $"serial_code:{code.Id}"), Is.True);
+ Assert.That(presents.All(p => p.Status == PresentStatus.Unclaimed), Is.True);
+
+ var redemptions = await ctx.ViewerSerialCodeRedemptions.AsNoTracking()
+ .Where(r => r.ViewerId == viewerId && r.SerialCodeId == code.Id).ToListAsync();
+ Assert.That(redemptions, Has.Count.EqualTo(1));
+ }
+
+ [Test]
+ public async Task Register_with_unknown_code_returns_4202()
+ {
+ using var factory = new SVSimTestFactory();
+ long viewerId = await factory.SeedViewerAsync();
+ using var client = factory.CreateAuthenticatedClient(viewerId);
+
+ var response = await client.PostAsync("/campaign/regist_serial_code",
+ JsonBody("""{"serial_code":"NOSUCHCODE"}"""));
+ var raw = await response.Content.ReadAsStringAsync();
+ Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK), raw);
+
+ using var doc = JsonDocument.Parse(raw);
+ Assert.That(doc.RootElement.GetProperty("result_code").GetInt32(), Is.EqualTo(4202));
+ }
+
+ [Test]
+ public async Task Register_with_disabled_code_returns_4202()
+ {
+ using var factory = new SVSimTestFactory();
+ long viewerId = await factory.SeedViewerAsync();
+ await SeedCodeAsync(factory, "DISABLED", enabled: false,
+ rewards: new[] { (1, 0L, 100) });
+
+ using var client = factory.CreateAuthenticatedClient(viewerId);
+ var response = await client.PostAsync("/campaign/regist_serial_code",
+ JsonBody("""{"serial_code":"DISABLED"}"""));
+ var raw = await response.Content.ReadAsStringAsync();
+ using var doc = JsonDocument.Parse(raw);
+ Assert.That(doc.RootElement.GetProperty("result_code").GetInt32(), Is.EqualTo(4202));
+ }
+
+ [Test]
+ public async Task Register_with_pre_start_code_returns_4202()
+ {
+ using var factory = new SVSimTestFactory();
+ long viewerId = await factory.SeedViewerAsync();
+ await SeedCodeAsync(factory, "FUTURE",
+ startAt: DateTime.UtcNow.AddDays(1),
+ rewards: new[] { (1, 0L, 100) });
+
+ using var client = factory.CreateAuthenticatedClient(viewerId);
+ var response = await client.PostAsync("/campaign/regist_serial_code",
+ JsonBody("""{"serial_code":"FUTURE"}"""));
+ var raw = await response.Content.ReadAsStringAsync();
+ using var doc = JsonDocument.Parse(raw);
+ Assert.That(doc.RootElement.GetProperty("result_code").GetInt32(), Is.EqualTo(4202));
+ }
+
+ [Test]
+ public async Task Register_with_expired_code_returns_4202()
+ {
+ using var factory = new SVSimTestFactory();
+ long viewerId = await factory.SeedViewerAsync();
+ await SeedCodeAsync(factory, "EXPIRED",
+ endAt: DateTime.UtcNow.AddDays(-1),
+ rewards: new[] { (1, 0L, 100) });
+
+ using var client = factory.CreateAuthenticatedClient(viewerId);
+ var response = await client.PostAsync("/campaign/regist_serial_code",
+ JsonBody("""{"serial_code":"EXPIRED"}"""));
+ var raw = await response.Content.ReadAsStringAsync();
+ using var doc = JsonDocument.Parse(raw);
+ Assert.That(doc.RootElement.GetProperty("result_code").GetInt32(), Is.EqualTo(4202));
+ }
+
+ [Test]
+ public async Task Register_with_already_redeemed_code_returns_4202()
+ {
+ using var factory = new SVSimTestFactory();
+ long viewerId = await factory.SeedViewerAsync();
+ var code = await SeedCodeAsync(factory, "ONCE",
+ rewards: new[] { (1, 0L, 100) });
+
+ // Pre-seed a redemption record.
+ using (var seedScope = factory.Services.CreateScope())
+ {
+ var ctx = seedScope.ServiceProvider.GetRequiredService();
+ ctx.ViewerSerialCodeRedemptions.Add(new ViewerSerialCodeRedemption
+ {
+ ViewerId = viewerId,
+ SerialCodeId = code.Id,
+ RedeemedAt = DateTime.UtcNow.AddMinutes(-5),
+ });
+ await ctx.SaveChangesAsync();
+ }
+
+ using var client = factory.CreateAuthenticatedClient(viewerId);
+ var response = await client.PostAsync("/campaign/regist_serial_code",
+ JsonBody("""{"serial_code":"ONCE"}"""));
+ var raw = await response.Content.ReadAsStringAsync();
+ using var doc = JsonDocument.Parse(raw);
+ Assert.That(doc.RootElement.GetProperty("result_code").GetInt32(), Is.EqualTo(4202));
+
+ // Sanity: no new presents created.
+ using var verifyScope = factory.Services.CreateScope();
+ var ctx2 = verifyScope.ServiceProvider.GetRequiredService();
+ var presents = await ctx2.ViewerPresents.AsNoTracking()
+ .Where(p => p.ViewerId == viewerId).CountAsync();
+ Assert.That(presents, Is.EqualTo(0));
+ }
+
+ [Test]
+ public async Task Register_with_unsupported_reward_type_returns_4202_and_no_redemption_row()
+ {
+ using var factory = new SVSimTestFactory();
+ long viewerId = await factory.SeedViewerAsync();
+ // RewardType 5 = Card; gift mapper supports only 1=Crystal, 4=Item, 9=Rupy.
+ var code = await SeedCodeAsync(factory, "BADTYPE",
+ rewards: new[] { (5, 100L, 1) });
+
+ using var client = factory.CreateAuthenticatedClient(viewerId);
+ var response = await client.PostAsync("/campaign/regist_serial_code",
+ JsonBody("""{"serial_code":"BADTYPE"}"""));
+ var raw = await response.Content.ReadAsStringAsync();
+ using var doc = JsonDocument.Parse(raw);
+ Assert.That(doc.RootElement.GetProperty("result_code").GetInt32(), Is.EqualTo(4202));
+
+ // Critical: no redemption row created → admin can fix and player can retry.
+ using var verifyScope = factory.Services.CreateScope();
+ var ctx = verifyScope.ServiceProvider.GetRequiredService();
+ var redemptions = await ctx.ViewerSerialCodeRedemptions.AsNoTracking()
+ .Where(r => r.ViewerId == viewerId && r.SerialCodeId == code.Id).CountAsync();
+ Assert.That(redemptions, Is.EqualTo(0));
+ }
+
+ [Test]
+ public async Task Register_without_auth_returns_401()
+ {
+ using var factory = new SVSimTestFactory();
+ var client = factory.CreateClient(); // no X-Test-Viewer-Id header
+
+ var response = await client.PostAsync("/campaign/regist_serial_code",
+ JsonBody("""{"serial_code":"WHATEVER"}"""));
+ Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized));
+ }
+
+ [Test]
+ public async Task Register_is_case_sensitive_for_code_match()
+ {
+ using var factory = new SVSimTestFactory();
+ long viewerId = await factory.SeedViewerAsync();
+ await SeedCodeAsync(factory, "MixedCase",
+ rewards: new[] { (1, 0L, 100) });
+
+ using var client = factory.CreateAuthenticatedClient(viewerId);
+ var response = await client.PostAsync("/campaign/regist_serial_code",
+ JsonBody("""{"serial_code":"mixedcase"}"""));
+ var raw = await response.Content.ReadAsStringAsync();
+ using var doc = JsonDocument.Parse(raw);
+ Assert.That(doc.RootElement.GetProperty("result_code").GetInt32(), Is.EqualTo(4202));
+ }
+}