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

@@ -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<ArenaColosseumBattleController> _log;
@@ -50,6 +52,7 @@ public sealed class ArenaColosseumBattleController : ControllerBase
IColosseumProgressionService progression,
IViewerRepository viewers,
IBattleXpService xp,
IMissionProgressService missionProgress,
SVSimDbContext db,
ILogger<ArenaColosseumBattleController> 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

View File

@@ -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
{

View File

@@ -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
{

View File

@@ -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

View File

@@ -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
{

View File

@@ -71,7 +71,7 @@ public class StoryController : SVSimController
public async Task<ActionResult<FinishResponse>> 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")]

View File

@@ -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.
/// <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>
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; }
}

View File

@@ -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);

View File

@@ -410,6 +410,8 @@ public class ArenaTwoPickService : IArenaTwoPickService
BeforeSpotPoint = before,
AddSpotPoint = aCfg.SpotPointsPerBattle,
AfterSpotPoint = after,
LeveledUp = xp.LeveledUp,
ClassId = run.ClassId,
};
}

View File

@@ -10,6 +10,6 @@ public interface IStoryService
Task<InfoResponse> GetInfoAsync(StoryApiType apiType, int sectionId, int? charaId, long viewerId);
Task<GetDeckListResponse> GetDeckListAsync(StoryApiType apiType, int storyId, 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);
}

View File

@@ -486,7 +486,7 @@ public class StoryService : IStoryService
resp["mission_parameter"] = Array.Empty<object>();
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);
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<FinishResponse> AllFinishAsync(StoryApiType apiType, int[] storyIds, bool isFinish, long viewerId)
{