feat(mission): wire class_level_up emits from every XP-granting controller

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 <noreply@anthropic.com>
This commit is contained in:
gamer147
2026-07-04 13:31:04 -04:00
parent a2048a4d54
commit 8e41739bde
12 changed files with 87 additions and 23 deletions

View File

@@ -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<SVSimDbContext>();
@@ -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");
}