diff --git a/SVSim.EmulatedEntrypoint/Controllers/AchievementController.cs b/SVSim.EmulatedEntrypoint/Controllers/AchievementController.cs
new file mode 100644
index 0000000..857b4c9
--- /dev/null
+++ b/SVSim.EmulatedEntrypoint/Controllers/AchievementController.cs
@@ -0,0 +1,133 @@
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.EntityFrameworkCore;
+using SVSim.Database;
+using SVSim.Database.Enums;
+using SVSim.Database.Models;
+using SVSim.Database.Repositories.Mission;
+using SVSim.Database.Services;
+using SVSim.EmulatedEntrypoint.Models.Dtos.Achievement;
+using SVSim.EmulatedEntrypoint.Services;
+
+namespace SVSim.EmulatedEntrypoint.Controllers;
+
+///
+/// /achievement/* — claim achievement rewards. Wire shape mirrors AchievementReceiveRewardTask.cs.
+///
+[Route("achievement")]
+public class AchievementController : SVSimController
+{
+ private const int FailureResultCode = 2;
+
+ private readonly SVSimDbContext _db;
+ private readonly IMissionCatalogRepository _catalog;
+ private readonly IViewerMissionStateService _state;
+ private readonly IMissionAssembler _assembler;
+ private readonly RewardGrantService _grantService;
+
+ public AchievementController(
+ SVSimDbContext db,
+ IMissionCatalogRepository catalog,
+ IViewerMissionStateService state,
+ IMissionAssembler assembler,
+ RewardGrantService grantService)
+ {
+ _db = db;
+ _catalog = catalog;
+ _state = state;
+ _assembler = assembler;
+ _grantService = grantService;
+ }
+
+ [HttpPost("receive_reward")]
+ public async Task ReceiveReward(
+ AchievementReceiveRewardRequest request, CancellationToken ct)
+ {
+ if (!TryGetViewerId(out long viewerId)) return Unauthorized();
+
+ // Load viewer with all the collections RewardGrantService may need to mutate.
+ var viewer = await _db.Viewers
+ .Include(v => v.MissionData)
+ .Include(v => v.Currency)
+ .Include(v => v.Cards)
+ .Include(v => v.Items)
+ .Include(v => v.Sleeves)
+ .Include(v => v.Emblems)
+ .Include(v => v.Degrees)
+ .Include(v => v.LeaderSkins)
+ .Include(v => v.MyPageBackgrounds)
+ .AsSplitQuery()
+ .FirstAsync(v => v.Id == viewerId, ct);
+
+ await _state.EnsureCurrentAsync(viewer, ct);
+ await _db.SaveChangesAsync(ct);
+
+ // Re-read viewer's achievement for this type after state-service materialization.
+ var ach = await _db.ViewerAchievements
+ .FirstOrDefaultAsync(a => a.ViewerId == viewerId && a.AchievementType == request.AchievementType, ct);
+ if (ach is null || ach.Level != request.Level)
+ {
+ return Ok(new { result_code = FailureResultCode });
+ }
+
+ var catalogRow = await _catalog.GetAchievementAsync(request.AchievementType, request.Level, ct);
+ if (catalogRow is null)
+ {
+ return Ok(new { result_code = FailureResultCode });
+ }
+
+ // Grant via the canonical RewardGrantService primitive.
+ var granted = await _grantService.ApplyAsync(
+ viewer,
+ (UserGoodsType)catalogRow.RewardType,
+ catalogRow.RewardDetailId,
+ catalogRow.RewardNumber,
+ ct);
+
+ // Advance viewer's level by 1. If no catalog row exists at the new level (i.e. just
+ // claimed the highest captured tier), max_level on the wire stays the same and the
+ // UI shows "claimed at max" until catalog grows.
+ ach.Level += 1;
+ var maxLevelByType = await _catalog.GetMaxLevelByAchievementTypeAsync(ct);
+ if (maxLevelByType.TryGetValue(request.AchievementType, out int maxLevel)
+ && ach.Level > maxLevel)
+ {
+ ach.AchievementStatus = 2;
+ }
+ else
+ {
+ ach.AchievementStatus = 0;
+ }
+ ach.NowAchievedLevel = request.Level;
+
+ await _db.SaveChangesAsync(ct);
+
+ var dto = await _assembler.BuildAsync(viewer, ct);
+ var resp = new AchievementReceiveRewardResponse
+ {
+ UserMissionList = dto.UserMissionList,
+ UserAchievementList = dto.UserAchievementList,
+ BattlePassMonthlyMission = dto.BattlePassMonthlyMission,
+ IsChangeMission = dto.IsChangeMission,
+ CanChangeMissionTime = dto.CanChangeMissionTime,
+ IsChangeReceiveType = dto.IsChangeReceiveType,
+ CanChangeReceiveTypeTime = dto.CanChangeReceiveTypeTime,
+ MissionReceiveType = dto.MissionReceiveType,
+ RewardList = granted.Select(g => new RewardGrantDto
+ {
+ RewardType = g.RewardType,
+ RewardId = g.RewardId,
+ RewardNum = g.RewardNum,
+ }).ToList(),
+ TotalReceiveCountList = granted.Select(g => new TotalReceiveCountDto
+ {
+ RewardType = g.RewardType,
+ RewardDetailId = g.RewardId,
+ RewardCount = g.RewardNum,
+ ItemType = 0,
+ IsUsable = true,
+ }).ToList(),
+ };
+
+ return Ok(resp);
+ }
+}
diff --git a/SVSim.EmulatedEntrypoint/Models/Dtos/Achievement/AchievementReceiveRewardRequest.cs b/SVSim.EmulatedEntrypoint/Models/Dtos/Achievement/AchievementReceiveRewardRequest.cs
new file mode 100644
index 0000000..d83035b
--- /dev/null
+++ b/SVSim.EmulatedEntrypoint/Models/Dtos/Achievement/AchievementReceiveRewardRequest.cs
@@ -0,0 +1,17 @@
+using System.Text.Json.Serialization;
+using MessagePack;
+using SVSim.EmulatedEntrypoint.Models.Dtos.Requests;
+
+namespace SVSim.EmulatedEntrypoint.Models.Dtos.Achievement;
+
+[MessagePackObject]
+public class AchievementReceiveRewardRequest : BaseRequest
+{
+ [Key("achievement_type")]
+ [JsonPropertyName("achievement_type")]
+ public int AchievementType { get; set; }
+
+ [Key("level")]
+ [JsonPropertyName("level")]
+ public int Level { get; set; }
+}
diff --git a/SVSim.EmulatedEntrypoint/Models/Dtos/Achievement/AchievementReceiveRewardResponse.cs b/SVSim.EmulatedEntrypoint/Models/Dtos/Achievement/AchievementReceiveRewardResponse.cs
new file mode 100644
index 0000000..2c6e79f
--- /dev/null
+++ b/SVSim.EmulatedEntrypoint/Models/Dtos/Achievement/AchievementReceiveRewardResponse.cs
@@ -0,0 +1,33 @@
+using System.Text.Json.Serialization;
+using MessagePack;
+using SVSim.EmulatedEntrypoint.Models.Dtos.Common.Mission;
+
+namespace SVSim.EmulatedEntrypoint.Models.Dtos.Achievement;
+
+[MessagePackObject(keyAsPropertyName: true)]
+public class TotalReceiveCountDto
+{
+ [Key(0)][JsonPropertyName("reward_type")] public int RewardType { get; set; }
+ [Key(1)][JsonPropertyName("reward_detail_id")] public long RewardDetailId { get; set; }
+ [Key(2)][JsonPropertyName("reward_count")] public int RewardCount { get; set; }
+ [Key(3)][JsonPropertyName("item_type")] public int ItemType { get; set; }
+ [Key(4)][JsonPropertyName("is_usable")] public bool IsUsable { get; set; } = true;
+}
+
+[MessagePackObject(keyAsPropertyName: true)]
+public class RewardGrantDto
+{
+ [Key(0)][JsonPropertyName("reward_type")] public int RewardType { get; set; }
+ [Key(1)][JsonPropertyName("reward_id")] public long RewardId { get; set; }
+ [Key(2)][JsonPropertyName("reward_num")] public int RewardNum { get; set; }
+}
+
+///
+/// /achievement/receive_reward response — MissionInfoDataDto + two extras consumed by
+/// PlayerStaticData.UpdateHaveUserGoodsNumByJsonData per AchievementReceiveRewardTask.cs:33.
+///
+public sealed class AchievementReceiveRewardResponse : MissionInfoDataDto
+{
+ [JsonPropertyName("total_receive_count_list")] public List TotalReceiveCountList { get; set; } = new();
+ [JsonPropertyName("reward_list")] public List RewardList { get; set; } = new();
+}
diff --git a/SVSim.UnitTests/Controllers/AchievementControllerTests.cs b/SVSim.UnitTests/Controllers/AchievementControllerTests.cs
new file mode 100644
index 0000000..d86086f
--- /dev/null
+++ b/SVSim.UnitTests/Controllers/AchievementControllerTests.cs
@@ -0,0 +1,128 @@
+using System.Net;
+using System.Net.Http.Json;
+using System.Text;
+using System.Text.Json;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.DependencyInjection;
+using SVSim.Bootstrap.Importers;
+using SVSim.Database;
+using SVSim.UnitTests.Infrastructure;
+
+namespace SVSim.UnitTests.Controllers;
+
+public class AchievementControllerTests
+{
+ private static string SeedDir => Path.Combine(AppContext.BaseDirectory, "Data", "seeds");
+ private static StringContent JsonBody(string json) => new(json, Encoding.UTF8, "application/json");
+
+ private static async Task ImportCatalogs(SVSimTestFactory f)
+ {
+ using var scope = f.Services.CreateScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+ await new MissionCatalogImporter().ImportAsync(db, SeedDir);
+ await new AchievementCatalogImporter().ImportAsync(db, SeedDir);
+ await new BattlePassMonthlyMissionImporter().ImportAsync(db, SeedDir);
+ }
+
+ private static async Task RupeesBalance(SVSimTestFactory f, long viewerId)
+ {
+ await using var scope = f.Services.CreateAsyncScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+ var v = await db.Viewers.Include(x => x.Currency).FirstAsync(x => x.Id == viewerId);
+ return v.Currency.Rupees;
+ }
+
+ [Test]
+ public async Task Claim_advances_level_by_one()
+ {
+ using var factory = new SVSimTestFactory();
+ long viewerId = await factory.SeedViewerAsync();
+ await ImportCatalogs(factory);
+
+ using var client = factory.CreateAuthenticatedClient(viewerId);
+ // Materialize achievements at MIN(Level) per type
+ await client.PostAsync("/mission/info", JsonBody("""{"viewer_id":"0","steam_id":0,"steam_session_ticket":""}"""));
+
+ // Find an achievement that the viewer is at and claim it.
+ // Type 50 ("Achieve Beginner 3 rank") has captured tier at level 3 only — viewer starts at 3.
+ var claimBody = """{"achievement_type":50,"level":3,"viewer_id":"0","steam_id":0,"steam_session_ticket":""}""";
+ var resp = await client.PostAsync("/achievement/receive_reward", JsonBody(claimBody));
+ var body = await resp.Content.ReadAsStringAsync();
+ Assert.That(resp.StatusCode, Is.EqualTo(HttpStatusCode.OK), body);
+
+ await using var scope = factory.Services.CreateAsyncScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+ var refreshed = await db.ViewerAchievements
+ .FirstAsync(a => a.ViewerId == viewerId && a.AchievementType == 50);
+ Assert.That(refreshed.Level, Is.EqualTo(4));
+ }
+
+ [Test]
+ public async Task Claim_grants_rupees_via_RewardGrantService()
+ {
+ using var factory = new SVSimTestFactory();
+ long viewerId = await factory.SeedViewerAsync();
+ await ImportCatalogs(factory);
+
+ using var client = factory.CreateAuthenticatedClient(viewerId);
+ await client.PostAsync("/mission/info", JsonBody("""{"viewer_id":"0","steam_id":0,"steam_session_ticket":""}"""));
+
+ ulong rupeesBefore = await RupeesBalance(factory, viewerId);
+
+ // Type 50 reward in capture: reward_type=9 (Rupy in UserGoodsType enum), reward_number=100.
+ var claimBody = """{"achievement_type":50,"level":3,"viewer_id":"0","steam_id":0,"steam_session_ticket":""}""";
+ var resp = await client.PostAsync("/achievement/receive_reward", JsonBody(claimBody));
+ resp.EnsureSuccessStatusCode();
+
+ ulong rupeesAfter = await RupeesBalance(factory, viewerId);
+ Assert.That(rupeesAfter, Is.EqualTo(rupeesBefore + 100UL),
+ "claiming type 50 / level 3 should grant 100 rupees");
+ }
+
+ [Test]
+ public async Task Claim_response_contains_reward_list()
+ {
+ using var factory = new SVSimTestFactory();
+ long viewerId = await factory.SeedViewerAsync();
+ await ImportCatalogs(factory);
+
+ using var client = factory.CreateAuthenticatedClient(viewerId);
+ await client.PostAsync("/mission/info", JsonBody("""{"viewer_id":"0","steam_id":0,"steam_session_ticket":""}"""));
+
+ var claimBody = """{"achievement_type":50,"level":3,"viewer_id":"0","steam_id":0,"steam_session_ticket":""}""";
+ var resp = await client.PostAsync("/achievement/receive_reward", JsonBody(claimBody));
+ resp.EnsureSuccessStatusCode();
+ var body = await resp.Content.ReadAsStringAsync();
+
+ using var doc = JsonDocument.Parse(body);
+ var rewardList = doc.RootElement.GetProperty("reward_list");
+ Assert.That(rewardList.GetArrayLength(), Is.GreaterThanOrEqualTo(1));
+ var grant = rewardList[0];
+ Assert.That(grant.GetProperty("reward_type").GetInt32(), Is.EqualTo(9));
+ // For currency grants, reward_num is the POST-STATE TOTAL (per project convention,
+ // matches /pack/open behavior). Default-seeded viewer has 50000 rupees → 50100 after grant.
+ Assert.That(grant.GetProperty("reward_num").GetInt32(), Is.GreaterThanOrEqualTo(100),
+ "reward_num is post-state total for currencies, must be at least the granted amount");
+ }
+
+ [Test]
+ public async Task Subsequent_claim_of_same_level_fails()
+ {
+ using var factory = new SVSimTestFactory();
+ long viewerId = await factory.SeedViewerAsync();
+ await ImportCatalogs(factory);
+
+ using var client = factory.CreateAuthenticatedClient(viewerId);
+ await client.PostAsync("/mission/info", JsonBody("""{"viewer_id":"0","steam_id":0,"steam_session_ticket":""}"""));
+
+ var claimBody = """{"achievement_type":50,"level":3,"viewer_id":"0","steam_id":0,"steam_session_ticket":""}""";
+
+ var first = await client.PostAsync("/achievement/receive_reward", JsonBody(claimBody));
+ first.EnsureSuccessStatusCode();
+
+ var second = await client.PostAsync("/achievement/receive_reward", JsonBody(claimBody));
+ var body = await second.Content.ReadAsStringAsync();
+ Assert.That(body, Does.Contain("\"result_code\":2"),
+ "second claim at the same level must fail with result_code=2");
+ }
+}