From 8e41739bdebf80006dda7844e6da27ead561b2cd Mon Sep 17 00:00:00 2001 From: gamer147 Date: Sat, 4 Jul 2026 13:31:04 -0400 Subject: [PATCH] feat(mission): wire class_level_up emits from every XP-granting controller MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every _xp.GrantAsync callsite now conditionally emits class_level_up:{class} when the returned BattleXpGrantResult.LeveledUp is true. Six callsites total: - PracticeController.Finish - RankBattleController.FinishInternal - FreeBattleController.Finish - ArenaTwoPickBattleController.Finish (LeveledUp + ClassId routed via BattleFinishResultDto — controller-side signals, not on wire) - ArenaColosseumBattleController (missed in the original wire-up survey; colosseum-battle XP now also fires the level-up event when applicable) - StoryController.Finish (LeveledUp + ClassId routed via new StoryFinishOutcome wrapper, mirroring the TK2 RunFinishOutcome pattern) Story's IStoryService.FinishAsync now returns StoryFinishOutcome instead of FinishResponse directly. Test callsites updated to unwrap .Response. Co-Authored-By: Claude Opus 4.7 --- .../ArenaColosseumBattleController.cs | 12 +++++++ .../ArenaTwoPickBattleController.cs | 5 +++ .../Controllers/FreeBattleController.cs | 7 ++++ .../Controllers/PracticeController.cs | 8 +++++ .../Controllers/RankBattleController.cs | 7 ++++ .../Controllers/StoryController.cs | 9 ++++-- .../ArenaTwoPick/BattleFinishResultDto.cs | 4 +++ .../Models/Dtos/Story/StoryFinishOutcome.cs | 10 ++++++ .../Services/ArenaTwoPickService.cs | 2 ++ .../Services/IStoryService.cs | 2 +- .../Services/StoryService.cs | 12 ++++--- SVSim.UnitTests/Story/StoryServiceTests.cs | 32 +++++++++---------- 12 files changed, 87 insertions(+), 23 deletions(-) create mode 100644 SVSim.EmulatedEntrypoint/Models/Dtos/Story/StoryFinishOutcome.cs diff --git a/SVSim.EmulatedEntrypoint/Controllers/ArenaColosseumBattleController.cs b/SVSim.EmulatedEntrypoint/Controllers/ArenaColosseumBattleController.cs index 799d9643..6a71ceff 100644 --- a/SVSim.EmulatedEntrypoint/Controllers/ArenaColosseumBattleController.cs +++ b/SVSim.EmulatedEntrypoint/Controllers/ArenaColosseumBattleController.cs @@ -4,6 +4,7 @@ using Microsoft.AspNetCore.Mvc; using SVSim.BattleNode.Bridge; using SVSim.Database; using SVSim.Database.Repositories.Viewer; +using SVSim.Database.Services; using SVSim.Database.Services.BattleXp; using SVSim.EmulatedEntrypoint.Constants; using SVSim.EmulatedEntrypoint.Matching; @@ -40,6 +41,7 @@ public sealed class ArenaColosseumBattleController : ControllerBase private readonly IColosseumProgressionService _progression; private readonly IViewerRepository _viewers; private readonly IBattleXpService _xp; + private readonly IMissionProgressService _missionProgress; private readonly SVSimDbContext _db; private readonly ILogger _log; @@ -50,6 +52,7 @@ public sealed class ArenaColosseumBattleController : ControllerBase IColosseumProgressionService progression, IViewerRepository viewers, IBattleXpService xp, + IMissionProgressService missionProgress, SVSimDbContext db, ILogger log) { @@ -59,6 +62,7 @@ public sealed class ArenaColosseumBattleController : ControllerBase _progression = progression; _viewers = viewers; _xp = xp; + _missionProgress = missionProgress; _db = db; _log = log; } @@ -174,6 +178,7 @@ public sealed class ArenaColosseumBattleController : ControllerBase } int gainXp = 0, totalXp = 0, level = 1; + bool leveledUp = false; var viewer = await _viewers.LoadForBattleXpGrantAsync(vid, ct); if (viewer is not null) { @@ -182,6 +187,13 @@ public sealed class ArenaColosseumBattleController : ControllerBase gainXp = xp.GetXp; totalXp = xp.TotalXp; level = xp.Level == 0 ? 1 : xp.Level; + leveledUp = xp.LeveledUp; + } + + if (leveledUp) + { + await _missionProgress.RecordEventAsync( + vid, MissionEventKeys.ClassLevel.UpAll(req.ClassId), ct: ct); } // result_code 3502 ("battle already finished") is the idempotent-retry tolerance per diff --git a/SVSim.EmulatedEntrypoint/Controllers/ArenaTwoPickBattleController.cs b/SVSim.EmulatedEntrypoint/Controllers/ArenaTwoPickBattleController.cs index 5f4dddab..2d4e6511 100644 --- a/SVSim.EmulatedEntrypoint/Controllers/ArenaTwoPickBattleController.cs +++ b/SVSim.EmulatedEntrypoint/Controllers/ArenaTwoPickBattleController.cs @@ -128,6 +128,11 @@ public class ArenaTwoPickBattleController : SVSimController vid, isWin ? MissionEventKeys.Challenge.MatchWinAll() : MissionEventKeys.Challenge.MatchPlayAll(), ct: ct); + if (result.LeveledUp) + { + await _missionProgress.RecordEventAsync( + vid, MissionEventKeys.ClassLevel.UpAll(result.ClassId), ct: ct); + } return Ok(new BattleFinishResponseDto { diff --git a/SVSim.EmulatedEntrypoint/Controllers/FreeBattleController.cs b/SVSim.EmulatedEntrypoint/Controllers/FreeBattleController.cs index 2bc95d1d..e8a782f3 100644 --- a/SVSim.EmulatedEntrypoint/Controllers/FreeBattleController.cs +++ b/SVSim.EmulatedEntrypoint/Controllers/FreeBattleController.cs @@ -114,6 +114,7 @@ public sealed class FreeBattleController : ControllerBase } int gainXp = 0, totalXp = 0, level = 1; + bool leveledUp = false; var viewer = await _viewers.LoadForBattleXpGrantAsync(vid, ct); if (viewer is not null) { @@ -122,6 +123,7 @@ public sealed class FreeBattleController : ControllerBase gainXp = xp.GetXp; totalXp = xp.TotalXp; level = xp.Level == 0 ? 1 : xp.Level; + leveledUp = xp.LeveledUp; } // Mission counters — unranked wins feed ranked_or_arena_win + daily_match_win. @@ -130,6 +132,11 @@ public sealed class FreeBattleController : ControllerBase await _missionProgress.RecordEventAsync( vid, MissionEventKeys.Free.WinAll(), ct: ct); } + if (leveledUp) + { + await _missionProgress.RecordEventAsync( + vid, MissionEventKeys.ClassLevel.UpAll(req.ClassId), ct: ct); + } return Ok(new FreeBattleFinishResponseDto { diff --git a/SVSim.EmulatedEntrypoint/Controllers/PracticeController.cs b/SVSim.EmulatedEntrypoint/Controllers/PracticeController.cs index 60ab3556..9a7bd008 100644 --- a/SVSim.EmulatedEntrypoint/Controllers/PracticeController.cs +++ b/SVSim.EmulatedEntrypoint/Controllers/PracticeController.cs @@ -122,6 +122,7 @@ public class PracticeController : SVSimController } int gainXp = 0, totalXp = 0, level = 1; + bool leveledUp = false; var viewer = await _viewers.LoadForBattleXpGrantAsync(viewerId); if (viewer is not null) { @@ -130,6 +131,13 @@ public class PracticeController : SVSimController gainXp = xp.GetXp; totalXp = xp.TotalXp; level = xp.Level == 0 ? 1 : xp.Level; + leveledUp = xp.LeveledUp; + } + + if (leveledUp) + { + await _missionProgress.RecordEventAsync( + viewerId, MissionEventKeys.ClassLevel.UpAll(request.ClassId)); } return new PracticeFinishResponse diff --git a/SVSim.EmulatedEntrypoint/Controllers/RankBattleController.cs b/SVSim.EmulatedEntrypoint/Controllers/RankBattleController.cs index d7231e31..a8682d5d 100644 --- a/SVSim.EmulatedEntrypoint/Controllers/RankBattleController.cs +++ b/SVSim.EmulatedEntrypoint/Controllers/RankBattleController.cs @@ -153,6 +153,7 @@ public sealed class RankBattleController : ControllerBase } int gainXp = 0, totalXp = 0, level = 1; + bool leveledUp = false; var rankResult = new RankProgressResult(0, 0, 0, 0, 0, false, false); var viewer = await _viewers.LoadForRankProgressAsync(vid, ct); if (viewer is not null) @@ -163,6 +164,7 @@ public sealed class RankBattleController : ControllerBase gainXp = xp.GetXp; totalXp = xp.TotalXp; level = xp.Level == 0 ? 1 : xp.Level; + leveledUp = xp.LeveledUp; } // Mission counters — AI-rank routes emit the same keys as human-rank on a private server. @@ -171,6 +173,11 @@ public sealed class RankBattleController : ControllerBase await _missionProgress.RecordEventAsync( vid, MissionEventKeys.Ranked.WinAll(req.ClassId), ct: ct); } + if (leveledUp) + { + await _missionProgress.RecordEventAsync( + vid, MissionEventKeys.ClassLevel.UpAll(req.ClassId), ct: ct); + } return Ok(new RankBattleFinishResponseDto { diff --git a/SVSim.EmulatedEntrypoint/Controllers/StoryController.cs b/SVSim.EmulatedEntrypoint/Controllers/StoryController.cs index 15bd1479..17a9d753 100644 --- a/SVSim.EmulatedEntrypoint/Controllers/StoryController.cs +++ b/SVSim.EmulatedEntrypoint/Controllers/StoryController.cs @@ -71,7 +71,7 @@ public class StoryController : SVSimController public async Task> Finish(FinishRequest req) { if (!TryGetViewerId(out long vid)) return Unauthorized(); - var result = await _service.FinishAsync(ResolveApiType(), req, vid); + var outcome = await _service.FinishAsync(ResolveApiType(), req, vid); // Emit story-chapter-finish events for mission/achievement progress. var apiType = ResolveApiType(); @@ -87,8 +87,13 @@ public class StoryController : SVSimController await _missionProgress.RecordEventAsync( vid, MissionEventKeys.Story.ChapterFinishAll(prefix, req.StoryId)); } + if (outcome.LeveledUp && outcome.ClassId.HasValue) + { + await _missionProgress.RecordEventAsync( + vid, MissionEventKeys.ClassLevel.UpAll(outcome.ClassId.Value)); + } - return result; + return outcome.Response; } [HttpPost("/main_story/all_finish")] diff --git a/SVSim.EmulatedEntrypoint/Models/Dtos/Responses/ArenaTwoPick/BattleFinishResultDto.cs b/SVSim.EmulatedEntrypoint/Models/Dtos/Responses/ArenaTwoPick/BattleFinishResultDto.cs index 7712eac2..ccccbaaa 100644 --- a/SVSim.EmulatedEntrypoint/Models/Dtos/Responses/ArenaTwoPick/BattleFinishResultDto.cs +++ b/SVSim.EmulatedEntrypoint/Models/Dtos/Responses/ArenaTwoPick/BattleFinishResultDto.cs @@ -4,6 +4,8 @@ namespace SVSim.EmulatedEntrypoint.Models.Dtos.Responses.ArenaTwoPick; /// What the battle controller needs from the service to build the standard battle-finish /// envelope (class XP delta + post-state, spot points before/add/after, battle_result echo). /// Achieved-info is built by the controller from IMissionAssembler. +/// and are controller-side signals for the +/// class_level_up mission emit and are not serialized to the wire. /// public class BattleFinishResultDto { @@ -14,4 +16,6 @@ public class BattleFinishResultDto public int BeforeSpotPoint { get; set; } public int AddSpotPoint { get; set; } public int AfterSpotPoint { get; set; } + public bool LeveledUp { get; set; } + public int ClassId { get; set; } } diff --git a/SVSim.EmulatedEntrypoint/Models/Dtos/Story/StoryFinishOutcome.cs b/SVSim.EmulatedEntrypoint/Models/Dtos/Story/StoryFinishOutcome.cs new file mode 100644 index 00000000..353f04dd --- /dev/null +++ b/SVSim.EmulatedEntrypoint/Models/Dtos/Story/StoryFinishOutcome.cs @@ -0,0 +1,10 @@ +namespace SVSim.EmulatedEntrypoint.Models.Dtos.Story; + +/// +/// What IStoryService.FinishAsync returns to StoryController.Finish. +/// is the wire payload; and +/// are controller-side signals for the class_level_up mission emit +/// and are never serialized. is nullable because skip-shape finishes +/// (no class chosen) don't grant XP and therefore can't level anything up. +/// +public sealed record StoryFinishOutcome(FinishResponse Response, bool LeveledUp, int? ClassId); diff --git a/SVSim.EmulatedEntrypoint/Services/ArenaTwoPickService.cs b/SVSim.EmulatedEntrypoint/Services/ArenaTwoPickService.cs index d782330b..b481e91b 100644 --- a/SVSim.EmulatedEntrypoint/Services/ArenaTwoPickService.cs +++ b/SVSim.EmulatedEntrypoint/Services/ArenaTwoPickService.cs @@ -410,6 +410,8 @@ public class ArenaTwoPickService : IArenaTwoPickService BeforeSpotPoint = before, AddSpotPoint = aCfg.SpotPointsPerBattle, AfterSpotPoint = after, + LeveledUp = xp.LeveledUp, + ClassId = run.ClassId, }; } diff --git a/SVSim.EmulatedEntrypoint/Services/IStoryService.cs b/SVSim.EmulatedEntrypoint/Services/IStoryService.cs index d53eae52..da4e7114 100644 --- a/SVSim.EmulatedEntrypoint/Services/IStoryService.cs +++ b/SVSim.EmulatedEntrypoint/Services/IStoryService.cs @@ -10,6 +10,6 @@ public interface IStoryService Task GetInfoAsync(StoryApiType apiType, int sectionId, int? charaId, long viewerId); Task GetDeckListAsync(StoryApiType apiType, int storyId, long viewerId); Task StartAsync(StoryApiType apiType, int[] storyIds, long viewerId); - Task FinishAsync(StoryApiType apiType, FinishRequest req, long viewerId); + Task FinishAsync(StoryApiType apiType, FinishRequest req, long viewerId); Task AllFinishAsync(StoryApiType apiType, int[] storyIds, bool isFinish, long viewerId); } diff --git a/SVSim.EmulatedEntrypoint/Services/StoryService.cs b/SVSim.EmulatedEntrypoint/Services/StoryService.cs index 31159a07..25c947a1 100644 --- a/SVSim.EmulatedEntrypoint/Services/StoryService.cs +++ b/SVSim.EmulatedEntrypoint/Services/StoryService.cs @@ -486,7 +486,7 @@ public class StoryService : IStoryService resp["mission_parameter"] = Array.Empty(); return resp; } - public async Task FinishAsync(StoryApiType apiType, FinishRequest req, long viewerId) + public async Task FinishAsync(StoryApiType apiType, FinishRequest req, long viewerId) { var chapter = await _master.GetChapterByIdAsync(req.StoryId); if (chapter is null) @@ -497,9 +497,9 @@ public class StoryService : IStoryService // StorySubChapter lookup and record progress at the sub's id with isFinish+isSkipped // both true (sub-chapters are always narrative-only — no battle settings on the wire). var sub = await _master.FindSubChapterByStoryIdAsync(req.StoryId); - if (sub is null) return new FinishResponse(); + if (sub is null) return new StoryFinishOutcome(new FinishResponse(), LeveledUp: false, ClassId: null); await _viewer.UpsertProgressAsync(viewerId, req.StoryId, isFinish: true, isSkipped: true); - return new FinishResponse(); + return new StoryFinishOutcome(new FinishResponse(), LeveledUp: false, ClassId: null); } var progress = (await _viewer.GetProgressForChaptersAsync(viewerId, new[] { req.StoryId })) @@ -581,6 +581,7 @@ public class StoryService : IStoryService resp.StoryRewardList.AddRange(storyRewardDeltas); } + bool leveledUp = false; if (firstClear && isPlayShape) { // XP grant requires a class_id (only sent on play-shape). No-battle chapters @@ -593,8 +594,11 @@ public class StoryService : IStoryService resp.GetClassExperience = xp.GetXp.ToString(); resp.ClassExperience = xp.TotalXp; resp.ClassLevel = xp.Level.ToString(); + leveledUp = xp.LeveledUp; } } + + return new StoryFinishOutcome(resp, LeveledUp: leveledUp, ClassId: req.ClassId); } else { @@ -609,7 +613,7 @@ public class StoryService : IStoryService await _viewer.UpsertProgressAsync(viewerId, req.StoryId, isFinish: null, isSkipped: true); } - return resp; + return new StoryFinishOutcome(resp, LeveledUp: false, ClassId: null); } public async Task AllFinishAsync(StoryApiType apiType, int[] storyIds, bool isFinish, long viewerId) { diff --git a/SVSim.UnitTests/Story/StoryServiceTests.cs b/SVSim.UnitTests/Story/StoryServiceTests.cs index dd9f1727..0ab1edcc 100644 --- a/SVSim.UnitTests/Story/StoryServiceTests.cs +++ b/SVSim.UnitTests/Story/StoryServiceTests.cs @@ -577,9 +577,9 @@ public class StoryServiceTests var resp = await _service.FinishAsync(StoryApiType.Main, req, viewerId: 7L); - Assert.That(resp.RewardList, Is.Empty); - Assert.That(resp.StoryRewardList, Is.Empty); - Assert.That(resp.GetClassExperience, Is.EqualTo("0")); + Assert.That(resp.Response.RewardList, Is.Empty); + Assert.That(resp.Response.StoryRewardList, Is.Empty); + Assert.That(resp.Response.GetClassExperience, Is.EqualTo("0")); _viewer.Verify(v => v.UpsertProgressAsync(7L, 100, null, true), Times.Once); } @@ -628,16 +628,16 @@ public class StoryServiceTests var resp = await svc.FinishAsync(StoryApiType.Main, req, viewerId: viewerId); // Viewer started at RedEther=0; grant of 100 → post-state total = 100. - Assert.That(resp.RewardList, Has.Count.EqualTo(1)); - Assert.That(resp.RewardList[0].RewardNum, Is.EqualTo("100")); + Assert.That(resp.Response.RewardList, Has.Count.EqualTo(1)); + Assert.That(resp.Response.RewardList[0].RewardNum, Is.EqualTo("100")); // Story XP resolves via DI-registered IBattleXpService → real IGameConfigService // (the local NewConfigService mock is passed to StoryService but the XP service // pulls its own config from DI). BattleXpConfig.ShippedDefaults(): XpPerWin=200, // StoryXpPerClear=null → falls back to XpPerWin=200. Curve L1=50, L2=150 → 200 XP // crosses both thresholds: L3 with 0. - Assert.That(resp.GetClassExperience, Is.EqualTo("200")); - Assert.That(resp.ClassExperience, Is.EqualTo(0)); - Assert.That(resp.ClassLevel, Is.EqualTo("3")); + Assert.That(resp.Response.GetClassExperience, Is.EqualTo("200")); + Assert.That(resp.Response.ClassExperience, Is.EqualTo(0)); + Assert.That(resp.Response.ClassLevel, Is.EqualTo("3")); _viewer.Verify(v => v.UpsertProgressAsync(viewerId, 100, true, null), Times.Once); // Confirm currency + class XP persisted: fetch fresh viewer from a new scope. @@ -670,7 +670,7 @@ public class StoryServiceTests var req = new FinishRequest { StoryId = 100, IsFinish = 1, ClassId = 2 }; var resp = await svc.FinishAsync(StoryApiType.Main, req, viewerId: viewerId); - Assert.That(resp.RewardList, Is.Empty); + Assert.That(resp.Response.RewardList, Is.Empty); // Currency must not have changed from its seed value of 0. using var verifyScope = factory.Services.CreateScope(); @@ -698,8 +698,8 @@ public class StoryServiceTests var req = new FinishRequest { StoryId = 100, IsFinish = 1, ClassId = 2 }; var resp = await svc.FinishAsync(StoryApiType.Main, req, viewerId: viewerId); - Assert.That(resp.RewardList, Has.Count.EqualTo(1)); - Assert.That(resp.RewardList[0].RewardNum, Is.EqualTo("100")); + Assert.That(resp.Response.RewardList, Has.Count.EqualTo(1)); + Assert.That(resp.Response.RewardList[0].RewardNum, Is.EqualTo("100")); using var verifyScope = factory.Services.CreateScope(); var db2 = verifyScope.ServiceProvider.GetRequiredService(); @@ -750,14 +750,14 @@ public class StoryServiceTests var resp = await svc.FinishAsync(StoryApiType.Main, req, viewerId: viewerId); // reward_list (post-state) gets BOTH the Card entry AND the cascaded Skin entry. - Assert.That(resp.RewardList.Any(r => r.RewardType == "5" && r.RewardId == testCardId.ToString()), Is.True, + Assert.That(resp.Response.RewardList.Any(r => r.RewardType == "5" && r.RewardId == testCardId.ToString()), Is.True, "card reward should appear in reward_list"); - Assert.That(resp.RewardList.Any(r => r.RewardType == "10" && r.RewardId == testSkinId.ToString()), Is.True, + Assert.That(resp.Response.RewardList.Any(r => r.RewardType == "10" && r.RewardId == testSkinId.ToString()), Is.True, "cascade skin should appear in reward_list"); // story_reward_list (deltas) only carries the top-level chapter reward. - Assert.That(resp.StoryRewardList.Count(r => r.RewardType == "5"), Is.EqualTo(1)); - Assert.That(resp.StoryRewardList.Any(r => r.RewardType == "10"), Is.False, + Assert.That(resp.Response.StoryRewardList.Count(r => r.RewardType == "5"), Is.EqualTo(1)); + Assert.That(resp.Response.StoryRewardList.Any(r => r.RewardType == "10"), Is.False, "cascade cosmetics should not appear in story_reward_list deltas"); } @@ -822,7 +822,7 @@ public class StoryServiceTests var resp = await svc.FinishAsync(StoryApiType.Main, req, viewerId: viewerId); // Post-state count on the wire should be 3 (2 owned + 1 granted). - var cardEntry = resp.RewardList.SingleOrDefault(r => r.RewardType == "5" && r.RewardId == testCardId.ToString()); + var cardEntry = resp.Response.RewardList.SingleOrDefault(r => r.RewardType == "5" && r.RewardId == testCardId.ToString()); Assert.That(cardEntry, Is.Not.Null, "card reward should appear in reward_list"); Assert.That(cardEntry!.RewardNum, Is.EqualTo("3"), "post-state count should be incremented, not reset to 1"); }