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:
@@ -4,6 +4,7 @@ using Microsoft.AspNetCore.Mvc;
|
|||||||
using SVSim.BattleNode.Bridge;
|
using SVSim.BattleNode.Bridge;
|
||||||
using SVSim.Database;
|
using SVSim.Database;
|
||||||
using SVSim.Database.Repositories.Viewer;
|
using SVSim.Database.Repositories.Viewer;
|
||||||
|
using SVSim.Database.Services;
|
||||||
using SVSim.Database.Services.BattleXp;
|
using SVSim.Database.Services.BattleXp;
|
||||||
using SVSim.EmulatedEntrypoint.Constants;
|
using SVSim.EmulatedEntrypoint.Constants;
|
||||||
using SVSim.EmulatedEntrypoint.Matching;
|
using SVSim.EmulatedEntrypoint.Matching;
|
||||||
@@ -40,6 +41,7 @@ public sealed class ArenaColosseumBattleController : ControllerBase
|
|||||||
private readonly IColosseumProgressionService _progression;
|
private readonly IColosseumProgressionService _progression;
|
||||||
private readonly IViewerRepository _viewers;
|
private readonly IViewerRepository _viewers;
|
||||||
private readonly IBattleXpService _xp;
|
private readonly IBattleXpService _xp;
|
||||||
|
private readonly IMissionProgressService _missionProgress;
|
||||||
private readonly SVSimDbContext _db;
|
private readonly SVSimDbContext _db;
|
||||||
private readonly ILogger<ArenaColosseumBattleController> _log;
|
private readonly ILogger<ArenaColosseumBattleController> _log;
|
||||||
|
|
||||||
@@ -50,6 +52,7 @@ public sealed class ArenaColosseumBattleController : ControllerBase
|
|||||||
IColosseumProgressionService progression,
|
IColosseumProgressionService progression,
|
||||||
IViewerRepository viewers,
|
IViewerRepository viewers,
|
||||||
IBattleXpService xp,
|
IBattleXpService xp,
|
||||||
|
IMissionProgressService missionProgress,
|
||||||
SVSimDbContext db,
|
SVSimDbContext db,
|
||||||
ILogger<ArenaColosseumBattleController> log)
|
ILogger<ArenaColosseumBattleController> log)
|
||||||
{
|
{
|
||||||
@@ -59,6 +62,7 @@ public sealed class ArenaColosseumBattleController : ControllerBase
|
|||||||
_progression = progression;
|
_progression = progression;
|
||||||
_viewers = viewers;
|
_viewers = viewers;
|
||||||
_xp = xp;
|
_xp = xp;
|
||||||
|
_missionProgress = missionProgress;
|
||||||
_db = db;
|
_db = db;
|
||||||
_log = log;
|
_log = log;
|
||||||
}
|
}
|
||||||
@@ -174,6 +178,7 @@ public sealed class ArenaColosseumBattleController : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
int gainXp = 0, totalXp = 0, level = 1;
|
int gainXp = 0, totalXp = 0, level = 1;
|
||||||
|
bool leveledUp = false;
|
||||||
var viewer = await _viewers.LoadForBattleXpGrantAsync(vid, ct);
|
var viewer = await _viewers.LoadForBattleXpGrantAsync(vid, ct);
|
||||||
if (viewer is not null)
|
if (viewer is not null)
|
||||||
{
|
{
|
||||||
@@ -182,6 +187,13 @@ public sealed class ArenaColosseumBattleController : ControllerBase
|
|||||||
gainXp = xp.GetXp;
|
gainXp = xp.GetXp;
|
||||||
totalXp = xp.TotalXp;
|
totalXp = xp.TotalXp;
|
||||||
level = xp.Level == 0 ? 1 : xp.Level;
|
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
|
// result_code 3502 ("battle already finished") is the idempotent-retry tolerance per
|
||||||
|
|||||||
@@ -128,6 +128,11 @@ public class ArenaTwoPickBattleController : SVSimController
|
|||||||
vid,
|
vid,
|
||||||
isWin ? MissionEventKeys.Challenge.MatchWinAll() : MissionEventKeys.Challenge.MatchPlayAll(),
|
isWin ? MissionEventKeys.Challenge.MatchWinAll() : MissionEventKeys.Challenge.MatchPlayAll(),
|
||||||
ct: ct);
|
ct: ct);
|
||||||
|
if (result.LeveledUp)
|
||||||
|
{
|
||||||
|
await _missionProgress.RecordEventAsync(
|
||||||
|
vid, MissionEventKeys.ClassLevel.UpAll(result.ClassId), ct: ct);
|
||||||
|
}
|
||||||
|
|
||||||
return Ok(new BattleFinishResponseDto
|
return Ok(new BattleFinishResponseDto
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -114,6 +114,7 @@ public sealed class FreeBattleController : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
int gainXp = 0, totalXp = 0, level = 1;
|
int gainXp = 0, totalXp = 0, level = 1;
|
||||||
|
bool leveledUp = false;
|
||||||
var viewer = await _viewers.LoadForBattleXpGrantAsync(vid, ct);
|
var viewer = await _viewers.LoadForBattleXpGrantAsync(vid, ct);
|
||||||
if (viewer is not null)
|
if (viewer is not null)
|
||||||
{
|
{
|
||||||
@@ -122,6 +123,7 @@ public sealed class FreeBattleController : ControllerBase
|
|||||||
gainXp = xp.GetXp;
|
gainXp = xp.GetXp;
|
||||||
totalXp = xp.TotalXp;
|
totalXp = xp.TotalXp;
|
||||||
level = xp.Level == 0 ? 1 : xp.Level;
|
level = xp.Level == 0 ? 1 : xp.Level;
|
||||||
|
leveledUp = xp.LeveledUp;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mission counters — unranked wins feed ranked_or_arena_win + daily_match_win.
|
// Mission counters — unranked wins feed ranked_or_arena_win + daily_match_win.
|
||||||
@@ -130,6 +132,11 @@ public sealed class FreeBattleController : ControllerBase
|
|||||||
await _missionProgress.RecordEventAsync(
|
await _missionProgress.RecordEventAsync(
|
||||||
vid, MissionEventKeys.Free.WinAll(), ct: ct);
|
vid, MissionEventKeys.Free.WinAll(), ct: ct);
|
||||||
}
|
}
|
||||||
|
if (leveledUp)
|
||||||
|
{
|
||||||
|
await _missionProgress.RecordEventAsync(
|
||||||
|
vid, MissionEventKeys.ClassLevel.UpAll(req.ClassId), ct: ct);
|
||||||
|
}
|
||||||
|
|
||||||
return Ok(new FreeBattleFinishResponseDto
|
return Ok(new FreeBattleFinishResponseDto
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -122,6 +122,7 @@ public class PracticeController : SVSimController
|
|||||||
}
|
}
|
||||||
|
|
||||||
int gainXp = 0, totalXp = 0, level = 1;
|
int gainXp = 0, totalXp = 0, level = 1;
|
||||||
|
bool leveledUp = false;
|
||||||
var viewer = await _viewers.LoadForBattleXpGrantAsync(viewerId);
|
var viewer = await _viewers.LoadForBattleXpGrantAsync(viewerId);
|
||||||
if (viewer is not null)
|
if (viewer is not null)
|
||||||
{
|
{
|
||||||
@@ -130,6 +131,13 @@ public class PracticeController : SVSimController
|
|||||||
gainXp = xp.GetXp;
|
gainXp = xp.GetXp;
|
||||||
totalXp = xp.TotalXp;
|
totalXp = xp.TotalXp;
|
||||||
level = xp.Level == 0 ? 1 : xp.Level;
|
level = xp.Level == 0 ? 1 : xp.Level;
|
||||||
|
leveledUp = xp.LeveledUp;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (leveledUp)
|
||||||
|
{
|
||||||
|
await _missionProgress.RecordEventAsync(
|
||||||
|
viewerId, MissionEventKeys.ClassLevel.UpAll(request.ClassId));
|
||||||
}
|
}
|
||||||
|
|
||||||
return new PracticeFinishResponse
|
return new PracticeFinishResponse
|
||||||
|
|||||||
@@ -153,6 +153,7 @@ public sealed class RankBattleController : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
int gainXp = 0, totalXp = 0, level = 1;
|
int gainXp = 0, totalXp = 0, level = 1;
|
||||||
|
bool leveledUp = false;
|
||||||
var rankResult = new RankProgressResult(0, 0, 0, 0, 0, false, false);
|
var rankResult = new RankProgressResult(0, 0, 0, 0, 0, false, false);
|
||||||
var viewer = await _viewers.LoadForRankProgressAsync(vid, ct);
|
var viewer = await _viewers.LoadForRankProgressAsync(vid, ct);
|
||||||
if (viewer is not null)
|
if (viewer is not null)
|
||||||
@@ -163,6 +164,7 @@ public sealed class RankBattleController : ControllerBase
|
|||||||
gainXp = xp.GetXp;
|
gainXp = xp.GetXp;
|
||||||
totalXp = xp.TotalXp;
|
totalXp = xp.TotalXp;
|
||||||
level = xp.Level == 0 ? 1 : xp.Level;
|
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.
|
// 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(
|
await _missionProgress.RecordEventAsync(
|
||||||
vid, MissionEventKeys.Ranked.WinAll(req.ClassId), ct: ct);
|
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
|
return Ok(new RankBattleFinishResponseDto
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ public class StoryController : SVSimController
|
|||||||
public async Task<ActionResult<FinishResponse>> Finish(FinishRequest req)
|
public async Task<ActionResult<FinishResponse>> Finish(FinishRequest req)
|
||||||
{
|
{
|
||||||
if (!TryGetViewerId(out long vid)) return Unauthorized();
|
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.
|
// Emit story-chapter-finish events for mission/achievement progress.
|
||||||
var apiType = ResolveApiType();
|
var apiType = ResolveApiType();
|
||||||
@@ -87,8 +87,13 @@ public class StoryController : SVSimController
|
|||||||
await _missionProgress.RecordEventAsync(
|
await _missionProgress.RecordEventAsync(
|
||||||
vid, MissionEventKeys.Story.ChapterFinishAll(prefix, req.StoryId));
|
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")]
|
[HttpPost("/main_story/all_finish")]
|
||||||
|
|||||||
@@ -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
|
/// 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).
|
/// envelope (class XP delta + post-state, spot points before/add/after, battle_result echo).
|
||||||
/// Achieved-info is built by the controller from IMissionAssembler.
|
/// Achieved-info is built by the controller from IMissionAssembler.
|
||||||
|
/// <see cref="LeveledUp"/> and <see cref="ClassId"/> are controller-side signals for the
|
||||||
|
/// class_level_up mission emit and are not serialized to the wire.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class BattleFinishResultDto
|
public class BattleFinishResultDto
|
||||||
{
|
{
|
||||||
@@ -14,4 +16,6 @@ public class BattleFinishResultDto
|
|||||||
public int BeforeSpotPoint { get; set; }
|
public int BeforeSpotPoint { get; set; }
|
||||||
public int AddSpotPoint { get; set; }
|
public int AddSpotPoint { get; set; }
|
||||||
public int AfterSpotPoint { get; set; }
|
public int AfterSpotPoint { get; set; }
|
||||||
|
public bool LeveledUp { get; set; }
|
||||||
|
public int ClassId { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Story;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// What <c>IStoryService.FinishAsync</c> returns to <c>StoryController.Finish</c>.
|
||||||
|
/// <see cref="Response"/> is the wire payload; <see cref="LeveledUp"/> and
|
||||||
|
/// <see cref="ClassId"/> are controller-side signals for the class_level_up mission emit
|
||||||
|
/// and are never serialized. <see cref="ClassId"/> is nullable because skip-shape finishes
|
||||||
|
/// (no class chosen) don't grant XP and therefore can't level anything up.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record StoryFinishOutcome(FinishResponse Response, bool LeveledUp, int? ClassId);
|
||||||
@@ -410,6 +410,8 @@ public class ArenaTwoPickService : IArenaTwoPickService
|
|||||||
BeforeSpotPoint = before,
|
BeforeSpotPoint = before,
|
||||||
AddSpotPoint = aCfg.SpotPointsPerBattle,
|
AddSpotPoint = aCfg.SpotPointsPerBattle,
|
||||||
AfterSpotPoint = after,
|
AfterSpotPoint = after,
|
||||||
|
LeveledUp = xp.LeveledUp,
|
||||||
|
ClassId = run.ClassId,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,6 @@ public interface IStoryService
|
|||||||
Task<InfoResponse> GetInfoAsync(StoryApiType apiType, int sectionId, int? charaId, long viewerId);
|
Task<InfoResponse> GetInfoAsync(StoryApiType apiType, int sectionId, int? charaId, long viewerId);
|
||||||
Task<GetDeckListResponse> GetDeckListAsync(StoryApiType apiType, int storyId, long viewerId);
|
Task<GetDeckListResponse> GetDeckListAsync(StoryApiType apiType, int storyId, long viewerId);
|
||||||
Task<StartResponse> StartAsync(StoryApiType apiType, int[] storyIds, long viewerId);
|
Task<StartResponse> StartAsync(StoryApiType apiType, int[] storyIds, long viewerId);
|
||||||
Task<FinishResponse> FinishAsync(StoryApiType apiType, FinishRequest req, long viewerId);
|
Task<StoryFinishOutcome> FinishAsync(StoryApiType apiType, FinishRequest req, long viewerId);
|
||||||
Task<FinishResponse> AllFinishAsync(StoryApiType apiType, int[] storyIds, bool isFinish, long viewerId);
|
Task<FinishResponse> AllFinishAsync(StoryApiType apiType, int[] storyIds, bool isFinish, long viewerId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -486,7 +486,7 @@ public class StoryService : IStoryService
|
|||||||
resp["mission_parameter"] = Array.Empty<object>();
|
resp["mission_parameter"] = Array.Empty<object>();
|
||||||
return resp;
|
return resp;
|
||||||
}
|
}
|
||||||
public async Task<FinishResponse> FinishAsync(StoryApiType apiType, FinishRequest req, long viewerId)
|
public async Task<StoryFinishOutcome> FinishAsync(StoryApiType apiType, FinishRequest req, long viewerId)
|
||||||
{
|
{
|
||||||
var chapter = await _master.GetChapterByIdAsync(req.StoryId);
|
var chapter = await _master.GetChapterByIdAsync(req.StoryId);
|
||||||
if (chapter is null)
|
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
|
// 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).
|
// both true (sub-chapters are always narrative-only — no battle settings on the wire).
|
||||||
var sub = await _master.FindSubChapterByStoryIdAsync(req.StoryId);
|
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);
|
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 }))
|
var progress = (await _viewer.GetProgressForChaptersAsync(viewerId, new[] { req.StoryId }))
|
||||||
@@ -581,6 +581,7 @@ public class StoryService : IStoryService
|
|||||||
resp.StoryRewardList.AddRange(storyRewardDeltas);
|
resp.StoryRewardList.AddRange(storyRewardDeltas);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool leveledUp = false;
|
||||||
if (firstClear && isPlayShape)
|
if (firstClear && isPlayShape)
|
||||||
{
|
{
|
||||||
// XP grant requires a class_id (only sent on play-shape). No-battle chapters
|
// 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.GetClassExperience = xp.GetXp.ToString();
|
||||||
resp.ClassExperience = xp.TotalXp;
|
resp.ClassExperience = xp.TotalXp;
|
||||||
resp.ClassLevel = xp.Level.ToString();
|
resp.ClassLevel = xp.Level.ToString();
|
||||||
|
leveledUp = xp.LeveledUp;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return new StoryFinishOutcome(resp, LeveledUp: leveledUp, ClassId: req.ClassId);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -609,7 +613,7 @@ public class StoryService : IStoryService
|
|||||||
await _viewer.UpsertProgressAsync(viewerId, req.StoryId, isFinish: null, isSkipped: true);
|
await _viewer.UpsertProgressAsync(viewerId, req.StoryId, isFinish: null, isSkipped: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
return resp;
|
return new StoryFinishOutcome(resp, LeveledUp: false, ClassId: null);
|
||||||
}
|
}
|
||||||
public async Task<FinishResponse> AllFinishAsync(StoryApiType apiType, int[] storyIds, bool isFinish, long viewerId)
|
public async Task<FinishResponse> AllFinishAsync(StoryApiType apiType, int[] storyIds, bool isFinish, long viewerId)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -577,9 +577,9 @@ public class StoryServiceTests
|
|||||||
|
|
||||||
var resp = await _service.FinishAsync(StoryApiType.Main, req, viewerId: 7L);
|
var resp = await _service.FinishAsync(StoryApiType.Main, req, viewerId: 7L);
|
||||||
|
|
||||||
Assert.That(resp.RewardList, Is.Empty);
|
Assert.That(resp.Response.RewardList, Is.Empty);
|
||||||
Assert.That(resp.StoryRewardList, Is.Empty);
|
Assert.That(resp.Response.StoryRewardList, Is.Empty);
|
||||||
Assert.That(resp.GetClassExperience, Is.EqualTo("0"));
|
Assert.That(resp.Response.GetClassExperience, Is.EqualTo("0"));
|
||||||
_viewer.Verify(v => v.UpsertProgressAsync(7L, 100, null, true), Times.Once);
|
_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);
|
var resp = await svc.FinishAsync(StoryApiType.Main, req, viewerId: viewerId);
|
||||||
|
|
||||||
// Viewer started at RedEther=0; grant of 100 → post-state total = 100.
|
// Viewer started at RedEther=0; grant of 100 → post-state total = 100.
|
||||||
Assert.That(resp.RewardList, Has.Count.EqualTo(1));
|
Assert.That(resp.Response.RewardList, Has.Count.EqualTo(1));
|
||||||
Assert.That(resp.RewardList[0].RewardNum, Is.EqualTo("100"));
|
Assert.That(resp.Response.RewardList[0].RewardNum, Is.EqualTo("100"));
|
||||||
// Story XP resolves via DI-registered IBattleXpService → real IGameConfigService
|
// Story XP resolves via DI-registered IBattleXpService → real IGameConfigService
|
||||||
// (the local NewConfigService mock is passed to StoryService but the XP service
|
// (the local NewConfigService mock is passed to StoryService but the XP service
|
||||||
// pulls its own config from DI). BattleXpConfig.ShippedDefaults(): XpPerWin=200,
|
// pulls its own config from DI). BattleXpConfig.ShippedDefaults(): XpPerWin=200,
|
||||||
// StoryXpPerClear=null → falls back to XpPerWin=200. Curve L1=50, L2=150 → 200 XP
|
// StoryXpPerClear=null → falls back to XpPerWin=200. Curve L1=50, L2=150 → 200 XP
|
||||||
// crosses both thresholds: L3 with 0.
|
// crosses both thresholds: L3 with 0.
|
||||||
Assert.That(resp.GetClassExperience, Is.EqualTo("200"));
|
Assert.That(resp.Response.GetClassExperience, Is.EqualTo("200"));
|
||||||
Assert.That(resp.ClassExperience, Is.EqualTo(0));
|
Assert.That(resp.Response.ClassExperience, Is.EqualTo(0));
|
||||||
Assert.That(resp.ClassLevel, Is.EqualTo("3"));
|
Assert.That(resp.Response.ClassLevel, Is.EqualTo("3"));
|
||||||
_viewer.Verify(v => v.UpsertProgressAsync(viewerId, 100, true, null), Times.Once);
|
_viewer.Verify(v => v.UpsertProgressAsync(viewerId, 100, true, null), Times.Once);
|
||||||
|
|
||||||
// Confirm currency + class XP persisted: fetch fresh viewer from a new scope.
|
// 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 req = new FinishRequest { StoryId = 100, IsFinish = 1, ClassId = 2 };
|
||||||
var resp = await svc.FinishAsync(StoryApiType.Main, req, viewerId: viewerId);
|
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.
|
// Currency must not have changed from its seed value of 0.
|
||||||
using var verifyScope = factory.Services.CreateScope();
|
using var verifyScope = factory.Services.CreateScope();
|
||||||
@@ -698,8 +698,8 @@ public class StoryServiceTests
|
|||||||
var req = new FinishRequest { StoryId = 100, IsFinish = 1, ClassId = 2 };
|
var req = new FinishRequest { StoryId = 100, IsFinish = 1, ClassId = 2 };
|
||||||
var resp = await svc.FinishAsync(StoryApiType.Main, req, viewerId: viewerId);
|
var resp = await svc.FinishAsync(StoryApiType.Main, req, viewerId: viewerId);
|
||||||
|
|
||||||
Assert.That(resp.RewardList, Has.Count.EqualTo(1));
|
Assert.That(resp.Response.RewardList, Has.Count.EqualTo(1));
|
||||||
Assert.That(resp.RewardList[0].RewardNum, Is.EqualTo("100"));
|
Assert.That(resp.Response.RewardList[0].RewardNum, Is.EqualTo("100"));
|
||||||
|
|
||||||
using var verifyScope = factory.Services.CreateScope();
|
using var verifyScope = factory.Services.CreateScope();
|
||||||
var db2 = verifyScope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
var db2 = verifyScope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||||
@@ -750,14 +750,14 @@ public class StoryServiceTests
|
|||||||
var resp = await svc.FinishAsync(StoryApiType.Main, req, viewerId: viewerId);
|
var resp = await svc.FinishAsync(StoryApiType.Main, req, viewerId: viewerId);
|
||||||
|
|
||||||
// reward_list (post-state) gets BOTH the Card entry AND the cascaded Skin entry.
|
// 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");
|
"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");
|
"cascade skin should appear in reward_list");
|
||||||
|
|
||||||
// story_reward_list (deltas) only carries the top-level chapter reward.
|
// 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.Response.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.Any(r => r.RewardType == "10"), Is.False,
|
||||||
"cascade cosmetics should not appear in story_reward_list deltas");
|
"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);
|
var resp = await svc.FinishAsync(StoryApiType.Main, req, viewerId: viewerId);
|
||||||
|
|
||||||
// Post-state count on the wire should be 3 (2 owned + 1 granted).
|
// 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, 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");
|
Assert.That(cardEntry!.RewardNum, Is.EqualTo("3"), "post-state count should be incremented, not reset to 1");
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user