Files
SVSimServer/SVSim.EmulatedEntrypoint/Services/StoryService.cs
2026-05-25 16:34:24 -04:00

473 lines
22 KiB
C#

using System.Text.RegularExpressions;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using SVSim.Database;
using SVSim.Database.Entities.Story;
using SVSim.Database.Enums;
using SVSim.Database.Models.Config;
using SVSim.Database.Repositories.Deck;
using SVSim.Database.Services;
using SVSim.Database.Repositories.Story;
using SVSim.EmulatedEntrypoint.Models.Dtos.Story;
namespace SVSim.EmulatedEntrypoint.Services;
public class StoryService : IStoryService
{
private static readonly Regex BranchSuffixRx = new(@"^\d+[a-zA-Z]+", RegexOptions.Compiled);
private readonly IStoryMasterRepository _master;
private readonly IViewerStoryProgressRepository _viewer;
private readonly RewardGrantService _rewards;
private readonly SVSimDbContext _db;
private readonly IGameConfigService _configService;
private readonly IDeckRepository _deckRepository;
private readonly ILogger<StoryService> _logger;
public StoryService(
IStoryMasterRepository master,
IViewerStoryProgressRepository viewer,
RewardGrantService rewards,
SVSimDbContext db,
IGameConfigService configService,
IDeckRepository deckRepository,
ILogger<StoryService> logger)
{
_master = master;
_viewer = viewer;
_rewards = rewards;
_db = db;
_configService = configService;
_deckRepository = deckRepository;
_logger = logger;
}
public async Task<InfoResponse> GetInfoAsync(StoryApiType apiType, int sectionId, int? charaId, long viewerId)
{
var resolvedChara = charaId ?? 0;
var chapters = await _master.GetChaptersBySectionCharaAsync(sectionId, resolvedChara);
if (chapters.Count == 0)
return new InfoResponse();
var storyIds = chapters.Select(c => c.StoryId).ToList();
// Sequential awaits — both repos share the scoped DbContext, and EF Core forbids
// concurrent operations on a single context. Parallel Task.WhenAll throws
// InvalidOperationException ("A second operation was started on this context...").
var progress = await _viewer.GetProgressForChaptersAsync(viewerId, storyIds);
var unlocked = await _viewer.GetBranchUnlockedStoryIdsAsync(viewerId, storyIds);
var byChapterId = chapters.ToDictionary(c => c.ChapterId);
var resp = new InfoResponse();
foreach (var c in chapters.OrderBy(x => ChapterRowNum(x.ChapterId))
.ThenBy(x => x.ChapterId, StringComparer.Ordinal))
{
bool isBranchChild = BranchSuffixRx.IsMatch(c.ChapterId);
var parent = chapters.FirstOrDefault(p =>
!ReferenceEquals(p, c) &&
p.NextChapterId.Split(' ', StringSplitOptions.RemoveEmptyEntries).Contains(c.ChapterId));
bool released;
if (parent is null) released = true;
else if (isBranchChild) released = unlocked.Contains(c.StoryId);
else released = (progress.TryGetValue(parent.StoryId, out var pp))
&& (pp.IsFinish || pp.IsSkipped);
// Optional required_chapter_id gate
if (!string.IsNullOrEmpty(c.RequiredChapterId) &&
byChapterId.TryGetValue(c.RequiredChapterId, out var req))
{
bool reqDone = progress.TryGetValue(req.StoryId, out var rp)
&& (rp.IsFinish || rp.IsSkipped);
released = released && reqDone;
}
var pState = progress.GetValueOrDefault(c.StoryId);
resp.StoryMasterList.Add(new StoryMasterEntry
{
StoryId = c.StoryId.ToString(),
SectionId = c.SectionId.ToString(),
CharaId = c.CharaId.ToString(),
ChapterId = c.ChapterId,
IsLock = !released,
NextChapterId = c.NextChapterId,
RequiredChapterId = c.RequiredChapterId ?? "",
SelectionDisplayPosition = c.SelectionDisplayPosition ?? "",
SelectionTextId = c.SelectionTextId ?? "",
ShowCoordinate = c.ShowCoordinate.ToString(),
XCoordinate = c.XCoordinate.ToString("0.#####"),
YCoordinate = c.YCoordinate.ToString("0.#####"),
IsCameraMovable = c.IsCameraMovable.ToString(),
ShowSubtitles = c.ShowSubtitles.ToString(),
BattleExists = c.BattleExists,
EnemyCharaId = c.EnemyCharaId.ToString(),
EnemyClass = c.EnemyClass.ToString(),
EnemyAiId = c.EnemyAiId.ToString(),
BgFileName = c.BgFileName,
ChapterEffectPath = c.ChapterEffectPath ?? "",
ChapterClearTextId = c.ChapterClearTextId ?? "",
Battle3dFieldId = c.Battle3dFieldId.ToString(),
BgmId = c.BgmId,
SpecialBattleSettingId = c.SpecialBattleSettingId?.ToString() ?? "",
ReleasePoint = c.ReleasePoint.ToString(),
BattleSettings = c.BattleSettings.Select(b => new BattleSettingDto
{
DeckClassId = b.DeckClassId,
PlayerEmotionOverride = b.PlayerEmotionOverride,
EnemyEmotionOverride = b.EnemyEmotionOverride,
SkinIdOverride = b.SkinIdOverride,
Battle3dFieldIdOverride = b.Battle3dFieldIdOverride,
BgmIdOverride = b.BgmIdOverride,
DeckSkinIdOverride = b.DeckSkinIdOverride,
}).ToList(),
StoryReward = c.Rewards.Select(r => new RewardDto
{
RewardType = r.RewardType.ToString(),
RewardDetailId = r.RewardDetailId.ToString(),
RewardNumber = r.RewardNumber.ToString(),
}).ToList(),
IsMaintenanceChapter = c.IsMaintenanceChapter,
IsReleased = released,
IsSkipped = pState?.IsSkipped ?? false,
IsFinish = pState?.IsFinish ?? false,
IsPlayAnotherEndAppearanceAnimation = c.IsPlayAnotherEndAppearanceAnimation,
IsReleasedAnotherEnd = c.IsReleasedAnotherEnd,
// TODO: prod gates skip on tutorial chapters specifically — the first battle of
// each class's section-1 intro (the "class tutorial" chapters) only shows skip on
// REPLAY, not on first play. Other chapters honor the chapter-master flag as-is.
// Our captures are all post-clear so the exact gate is unconfirmed; cosmetic-only,
// viewer sees skip earlier than prod would allow on class-tutorial first plays.
IsSkipEnabled = c.IsSkipEnabled,
});
}
return resp;
}
public async Task<SectionResponse> GetSectionsAsync(StoryApiType apiType, long viewerId)
{
var sections = await _master.GetSectionsByFamilyAsync(apiType);
if (sections.Count == 0) return new SectionResponse();
var worldIds = sections.Where(s => s.WorldId.HasValue).Select(s => s.WorldId!.Value).Distinct().ToList();
// Four bulk loads total — no per-(section,chara) round-trips. For a full main-story sweep
// this is 4 queries instead of ~336. Sequential (not Task.WhenAll) because both repos
// share the scoped DbContext — EF Core forbids concurrent operations on a single context.
var worlds = await _master.GetWorldsForSectionsAsync(worldIds);
var sectionIds = sections.Select(s => s.Id).ToList();
var allChapters = await _master.GetChaptersBySectionsAsync(sectionIds);
var allProgress = await _viewer.GetProgressForChaptersAsync(
viewerId, allChapters.Select(c => c.StoryId));
// Index chapters by (sectionId, charaId) for O(1) lookup in the rollup loop.
var chaptersBySectionChara = allChapters
.GroupBy(c => (c.SectionId, c.CharaId))
.ToDictionary(g => g.Key, g => g.ToList());
var charaIds = new[] { 1, 2, 3, 4, 5, 6, 7, 8 };
var resp = new SectionResponse();
foreach (var w in worlds)
{
var sectionsInWorld = sections.Where(s => s.WorldId == w.Id).OrderBy(s => s.OrderId).ToList();
var worldDto = new SectionWorld
{
TitleTextId = w.TitleTextKey,
PanelImageName = w.PanelImageName,
RibbonText = w.RibbonText,
};
bool worldComplete = sectionsInWorld.Count > 0;
foreach (var s in sectionsInWorld)
{
int released = 0, finished = 0;
bool sectionFinished;
if (s.IsLeaderSelect)
{
// released_chara_count = charas with playable chapters in the catalog
// (chapter 1 is always unlocked, so a chara is "released" the moment it has
// any chapter row). Drives the "X/Y complete" label, which the client only
// renders when released > 0. Counter is per-catalog, NOT per-viewer-progress.
// finished_chara_count = charas where the viewer has cleared every chapter.
int charasWithChapters = 0;
foreach (var c in charaIds)
{
if (!chaptersBySectionChara.TryGetValue((s.Id, c), out var chapters) || chapters.Count == 0)
continue;
charasWithChapters++;
int doneCount = chapters.Count(x =>
allProgress.TryGetValue(x.StoryId, out var p) && (p.IsFinish || p.IsSkipped));
if (doneCount == chapters.Count) finished++;
}
released = charasWithChapters;
sectionFinished = released > 0 && finished == released;
}
else
{
// Non-leader-select sections (Limited / Event story) use chara_id=0 and don't
// expose chara counters — prod emits released=finished=0 regardless of progress.
// is_finished is derived from completion of the single chara=0 chapter set.
chaptersBySectionChara.TryGetValue((s.Id, 0), out var chapters);
if (chapters is { Count: > 0 })
{
int doneCount = chapters.Count(x =>
allProgress.TryGetValue(x.StoryId, out var p) && (p.IsFinish || p.IsSkipped));
sectionFinished = doneCount == chapters.Count;
}
else
{
sectionFinished = false;
}
}
if (!sectionFinished) worldComplete = false;
worldDto.SectionList.Add(new SectionEntry
{
SectionId = s.Id.ToString(),
OrderId = s.OrderId,
AllStoryOrderId = s.AllStoryOrderId.ToString(),
Name = s.NameTextKey,
ImageName = s.ImageName,
IsLeaderSelect = s.IsLeaderSelect,
BackGroundId = s.BackGroundId,
IsFinished = sectionFinished,
ReleasedCharaCount = released,
FinishedCharaCount = finished,
IsUnderMaintenance = s.IsUnderMaintenance,
ChapterSelectType = s.ChapterSelectType.ToString(),
StoryTypeOverwrite = s.StoryTypeOverwrite.ToString(),
IsNew = false,
IsPlayAnotherEndAppearanceAnimation = s.IsPlayAnotherEndAppearanceAnimation,
});
}
worldDto.IsComplete = worldComplete;
resp.WorldList[w.Id.ToString()] = worldDto;
}
return resp;
}
public async Task<LeaderSelectResponse> GetLeaderSelectAsync(StoryApiType apiType, int sectionId, long viewerId)
{
// For section's chara list we use a fixed 1-8 enumeration for leader-select sections.
// Non-leader-select sections are not expected to call this endpoint; returning leader_count=8
// matches the client's default sentinel.
var resp = new LeaderSelectResponse { LeaderCount = 8 };
var charaIds = new[] { 1, 2, 3, 4, 5, 6, 7, 8 };
// Pre-collect all story_ids across charas in this section to do one progress query.
var perCharaChapters = new Dictionary<int, List<StoryChapter>>();
foreach (var c in charaIds)
{
perCharaChapters[c] = await _master.GetChaptersBySectionCharaAsync(sectionId, c);
}
var allStoryIds = perCharaChapters.SelectMany(kv => kv.Value).Select(c => c.StoryId).ToList();
var progress = await _viewer.GetProgressForChaptersAsync(viewerId, allStoryIds);
foreach (var c in charaIds)
{
var chapters = perCharaChapters[c];
if (chapters.Count == 0)
{
resp.LeaderList.Add(new LeaderEntry { CharaId = c, CurrentChapter = 1 });
continue;
}
int highest = 0;
bool anySkipped = false;
int clearedCount = 0;
foreach (var ch in chapters)
{
if (progress.TryGetValue(ch.StoryId, out var p) && (p.IsFinish || p.IsSkipped))
{
int row = ChapterRowNum(ch.ChapterId);
if (row > highest) highest = row;
if (p.IsSkipped) anySkipped = true;
clearedCount++;
}
}
resp.LeaderList.Add(new LeaderEntry
{
CharaId = c,
IsSkipped = anySkipped,
IsFinished = clearedCount == chapters.Count,
CurrentChapter = (highest == 0) ? 1 : highest + 1,
});
}
return resp;
}
public async Task<GetDeckListResponse> GetDeckListAsync(StoryApiType apiType, int storyId, long viewerId)
{
var byFormat = await _deckRepository.GetDecksByFormats(
viewerId, new[] { SVSim.Database.Enums.Format.Rotation, SVSim.Database.Enums.Format.Unlimited });
return new GetDeckListResponse
{
UserDeckRotation = byFormat[SVSim.Database.Enums.Format.Rotation]
.Select(d => new SVSim.EmulatedEntrypoint.Models.Dtos.UserDeck(d)).ToList(),
UserDeckUnlimited = byFormat[SVSim.Database.Enums.Format.Unlimited]
.Select(d => new SVSim.EmulatedEntrypoint.Models.Dtos.UserDeck(d)).ToList(),
BuildDeckList = new List<BuildDeck>(), // v1: empty
MaintenanceCardList = new List<long>(),
};
}
public async Task<StartResponse> StartAsync(StoryApiType apiType, int[] storyIds, long viewerId)
{
var resp = new StartResponse();
for (int i = 0; i < storyIds.Length; i++)
{
var chapter = await _master.GetChapterByIdAsync(storyIds[i]);
if (chapter is null)
{
resp[i.ToString()] = Array.Empty<object>();
continue;
}
if (chapter.SpecialBattleSettingId is null)
{
resp[i.ToString()] = Array.Empty<object>();
}
else
{
var sbs = await _master.GetSbsByIdAsync(chapter.SpecialBattleSettingId.Value);
if (sbs is null) { resp[i.ToString()] = Array.Empty<object>(); continue; }
resp[i.ToString()] = new StartSlotWithSbs
{
SpecialBattleSetting = new SpecialBattleSettingDto
{
Id = sbs.Id.ToString(),
PlayerFirstTurn = sbs.PlayerFirstTurn.ToString(),
PlayerStartPp = sbs.PlayerStartPp.ToString(),
EnemyStartPp = sbs.EnemyStartPp.ToString(),
PlayerStartLife = sbs.PlayerStartLife.ToString(),
EnemyStartLife = sbs.EnemyStartLife.ToString(),
PlayerAttachSkill = sbs.PlayerAttachSkill,
EnemyAttachSkill = sbs.EnemyAttachSkill,
IdOverrideInBattleLog = sbs.IdOverrideInBattleLog,
BanishEffectOverride = sbs.BanishEffectOverride,
TokenDrawEffectOverride = sbs.TokenDrawEffectOverride,
SpecialTokenDrawEffectOverride = sbs.SpecialTokenDrawEffectOverride,
ResultSkip = sbs.ResultSkip.ToString(),
VsEffectOverride = sbs.VsEffectOverride.ToString(),
ClassDestroyEffectOverride = sbs.ClassDestroyEffectOverride.ToString(),
Note = sbs.Note ?? "",
}
};
}
}
resp["mission_parameter"] = Array.Empty<object>();
return resp;
}
public async Task<FinishResponse> FinishAsync(StoryApiType apiType, FinishRequest req, long viewerId)
{
var chapter = await _master.GetChapterByIdAsync(req.StoryId);
if (chapter is null) return new FinishResponse();
var progress = (await _viewer.GetProgressForChaptersAsync(viewerId, new[] { req.StoryId }))
.GetValueOrDefault(req.StoryId);
var resp = new FinishResponse();
if (req.IsPlayShape)
{
bool firstClear = progress is null || !progress.IsFinish;
await _viewer.UpsertProgressAsync(viewerId, req.StoryId, isFinish: true, isSkipped: null);
if (firstClear)
{
// Load viewer with all collections RewardGrantService might mutate. Split-query
// to avoid the cartesian-explode pitfall (CLAUDE.md "EF split query").
var viewer = await _db.Viewers
.Include(v => v.Cards).ThenInclude(c => c.Card)
.Include(v => v.Sleeves)
.Include(v => v.Emblems)
.Include(v => v.LeaderSkins)
.Include(v => v.Degrees)
.Include(v => v.MyPageBackgrounds)
.Include(v => v.Items).ThenInclude(i => i.Item)
.AsSplitQuery()
.FirstAsync(v => v.Id == viewerId);
foreach (var r in chapter.Rewards)
{
IReadOnlyList<GrantedReward> granted;
try
{
granted = await _rewards.ApplyAsync(
viewer, (UserGoodsType)r.RewardType, r.RewardDetailId, r.RewardNumber);
}
catch (NotSupportedException ex)
{
_logger.LogWarning(ex,
"StoryService: skipping unsupported reward_type={Type} detail={Detail} num={Num} for story={StoryId}",
r.RewardType, r.RewardDetailId, r.RewardNumber, req.StoryId);
continue;
}
// reward_list and story_reward_list have DIFFERENT semantics for reward_num:
// - reward_list: post-state totals. Client (PlayerStaticData
// .UpdateHaveUserGoodsNum) direct-assigns to in-memory
// balances (e.g. UserRupyCount = num).
// - story_reward_list: deltas. Client (ResultAnimationAgent
// .HandleStoryAndMissionRewards) feeds each entry to
// AddReward(item) which draws a "+N received" popup line.
// ApplyAsync may return 1+N entries (Card grants cascade into cosmetics). All
// post-state entries go into reward_list; story_reward_list only gets the
// top-level mission row's delta (cascade cosmetics have no corresponding row).
foreach (var g in granted)
{
resp.RewardList.Add(new RewardGrant
{
RewardType = g.RewardType.ToString(),
RewardId = g.RewardId.ToString(),
RewardNum = g.RewardNum.ToString(),
});
}
resp.StoryRewardList.Add(new RewardGrant
{
RewardType = ((int)r.RewardType).ToString(),
RewardId = r.RewardDetailId.ToString(),
RewardNum = r.RewardNumber.ToString(),
});
}
await _db.SaveChangesAsync();
var xp = _configService.Get<StoryConfig>().ClassXpPerClear;
resp.GetClassExperience = xp.ToString();
// class_experience / class_level updates would consult the viewer's per-class XP
// table — placeholder zeros; wire to viewer.Classes[class_id] when that path exists.
resp.ClassExperience = 0;
resp.ClassLevel = "0";
}
}
else
{
// Skip-shape: optionally unlock a branch child if selection_chapter_id is set.
if (!string.IsNullOrEmpty(req.SelectionChapterId))
{
var siblings = await _master.GetChaptersBySectionCharaAsync(chapter.SectionId, chapter.CharaId);
var child = siblings.FirstOrDefault(c => c.ChapterId == req.SelectionChapterId);
if (child is not null)
await _viewer.UpsertBranchUnlockAsync(viewerId, child.StoryId);
}
await _viewer.UpsertProgressAsync(viewerId, req.StoryId, isFinish: null, isSkipped: true);
}
return resp;
}
public async Task<FinishResponse> AllFinishAsync(StoryApiType apiType, int[] storyIds, bool isFinish, long viewerId)
{
foreach (var sid in storyIds)
await _viewer.UpsertProgressAsync(viewerId, sid, isFinish: null, isSkipped: true);
return new FinishResponse();
}
private static int ChapterRowNum(string chapterId)
{
// Extract leading numeric prefix; for "12a" returns 12.
int i = 0;
while (i < chapterId.Length && char.IsDigit(chapterId[i])) i++;
return int.TryParse(chapterId[..i], out int n) ? n : 0;
}
}