Story
This commit is contained in:
@@ -98,7 +98,7 @@ public class MyPageController : SVSimController
|
||||
{
|
||||
UserMyPageSetting = new MyPageBgSetting(),
|
||||
},
|
||||
BasicPuzzle = new BasicPuzzleBadge { IsDisplayBadge = false }, // TODO(mypage-stub): viewer practice-puzzle progress
|
||||
BasicPuzzle = new Models.Dtos.Common.BadgeFlag { IsDisplayBadge = false }, // TODO(mypage-stub): viewer practice-puzzle progress
|
||||
IsBattlePassPeriod = rotation.IsBattlePassPeriod,
|
||||
SpecialCrystalInfo = new(), // TODO(mypage-stub): same shape/source as /load/index
|
||||
// CompetitionInfo, ShopNotification, StoryNotification, GuildNotification, GatheringInfo,
|
||||
|
||||
88
SVSim.EmulatedEntrypoint/Controllers/StoryController.cs
Normal file
88
SVSim.EmulatedEntrypoint/Controllers/StoryController.cs
Normal file
@@ -0,0 +1,88 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using SVSim.Database.Entities.Story;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Story;
|
||||
using SVSim.EmulatedEntrypoint.Services;
|
||||
|
||||
namespace SVSim.EmulatedEntrypoint.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
public class StoryController : SVSimController
|
||||
{
|
||||
private readonly IStoryService _service;
|
||||
public StoryController(IStoryService service) { _service = service; }
|
||||
|
||||
[HttpPost("/story/section")]
|
||||
[HttpPost("/main_story/section")]
|
||||
[HttpPost("/limited_story/section")]
|
||||
[HttpPost("/event_story/section")]
|
||||
public async Task<ActionResult<SectionResponse>> Section(SectionRequest req)
|
||||
{
|
||||
if (!TryGetViewerId(out long vid)) return Unauthorized();
|
||||
return await _service.GetSectionsAsync(ResolveApiType(), vid);
|
||||
}
|
||||
|
||||
[HttpPost("/main_story/leader_select")]
|
||||
[HttpPost("/limited_story/leader_select")]
|
||||
[HttpPost("/event_story/leader_select")]
|
||||
public async Task<ActionResult<LeaderSelectResponse>> LeaderSelect(LeaderSelectRequest req)
|
||||
{
|
||||
if (!TryGetViewerId(out long vid)) return Unauthorized();
|
||||
return await _service.GetLeaderSelectAsync(ResolveApiType(), req.SectionId, vid);
|
||||
}
|
||||
|
||||
[HttpPost("/main_story/info")]
|
||||
[HttpPost("/limited_story/info")]
|
||||
[HttpPost("/event_story/info")]
|
||||
public async Task<ActionResult<InfoResponse>> Info(InfoRequest req)
|
||||
{
|
||||
if (!TryGetViewerId(out long vid)) return Unauthorized();
|
||||
int? chara = req.CharaId == 0 ? null : req.CharaId;
|
||||
return await _service.GetInfoAsync(ResolveApiType(), req.SectionId, chara, vid);
|
||||
}
|
||||
|
||||
[HttpPost("/main_story/get_deck_list")]
|
||||
[HttpPost("/event_story/get_deck_list")]
|
||||
public async Task<ActionResult<GetDeckListResponse>> GetDeckList(GetDeckListRequest req)
|
||||
{
|
||||
if (!TryGetViewerId(out long vid)) return Unauthorized();
|
||||
return await _service.GetDeckListAsync(ResolveApiType(), req.StoryId, vid);
|
||||
}
|
||||
|
||||
[HttpPost("/main_story/start")]
|
||||
[HttpPost("/limited_story/start")]
|
||||
[HttpPost("/event_story/start")]
|
||||
public async Task<ActionResult<StartResponse>> Start(StartRequest req)
|
||||
{
|
||||
if (!TryGetViewerId(out long vid)) return Unauthorized();
|
||||
return await _service.StartAsync(ResolveApiType(), req.StoryIds, vid);
|
||||
}
|
||||
|
||||
[HttpPost("/main_story/finish")]
|
||||
[HttpPost("/limited_story/finish")]
|
||||
[HttpPost("/event_story/finish")]
|
||||
public async Task<ActionResult<FinishResponse>> Finish(FinishRequest req)
|
||||
{
|
||||
if (!TryGetViewerId(out long vid)) return Unauthorized();
|
||||
return await _service.FinishAsync(ResolveApiType(), req, vid);
|
||||
}
|
||||
|
||||
[HttpPost("/main_story/all_finish")]
|
||||
[HttpPost("/limited_story/all_finish")]
|
||||
[HttpPost("/event_story/all_finish")]
|
||||
public async Task<ActionResult<FinishResponse>> AllFinish(AllFinishRequest req)
|
||||
{
|
||||
if (!TryGetViewerId(out long vid)) return Unauthorized();
|
||||
return await _service.AllFinishAsync(ResolveApiType(), req.StoryIds, req.IsFinish == 1, vid);
|
||||
}
|
||||
|
||||
private StoryApiType ResolveApiType()
|
||||
{
|
||||
var path = HttpContext.Request.Path.Value ?? "";
|
||||
if (path.StartsWith("/main_story")) return StoryApiType.Main;
|
||||
if (path.StartsWith("/limited_story")) return StoryApiType.Limited;
|
||||
if (path.StartsWith("/event_story")) return StoryApiType.Event;
|
||||
return StoryApiType.AllStory; // /story/section
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
using MessagePack;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace SVSim.EmulatedEntrypoint.Models.Dtos;
|
||||
|
||||
/// <summary>
|
||||
/// basic_puzzle.is_display_badge — drives the "practice puzzle" badge on the
|
||||
/// footer. Read by MyPageTask.cs:177.
|
||||
///
|
||||
/// Named with the "Badge" suffix to avoid colliding with the
|
||||
/// <c>Models.Dtos.{Common,Requests,Responses}.BasicPuzzle</c> sub-namespaces
|
||||
/// that hold the /basic_puzzle/* endpoint DTOs.
|
||||
/// </summary>
|
||||
[MessagePackObject]
|
||||
public class BasicPuzzleBadge
|
||||
{
|
||||
[JsonPropertyName("is_display_badge")]
|
||||
[Key("is_display_badge")]
|
||||
public bool IsDisplayBadge { get; set; }
|
||||
}
|
||||
24
SVSim.EmulatedEntrypoint/Models/Dtos/Common/BadgeFlag.cs
Normal file
24
SVSim.EmulatedEntrypoint/Models/Dtos/Common/BadgeFlag.cs
Normal file
@@ -0,0 +1,24 @@
|
||||
using MessagePack;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Common;
|
||||
|
||||
/// <summary>
|
||||
/// Single-field `{ "is_display_badge": bool }` wrapper. The badge-poll context
|
||||
/// of <c>MyPageNotifications.ParseBadgeInfos</c> (called from StoryFinishTask,
|
||||
/// QuestFinishTask, RecoveryTask, OpenRoomBattleGetRecoveryParamTask) reads
|
||||
/// only this one field from each of <c>quest</c>, <c>story_notification</c>,
|
||||
/// and <c>basic_puzzle</c>, so all three positions share this shape.
|
||||
///
|
||||
/// The mypage-index versions of <c>quest</c> and <c>story_notification</c> have
|
||||
/// richer shapes (<see cref="SVSim.EmulatedEntrypoint.Models.Dtos.Quest"/>,
|
||||
/// <see cref="SVSim.EmulatedEntrypoint.Models.Dtos.StoryNotification"/>) since
|
||||
/// the home-screen UI reads additional fields off them.
|
||||
/// </summary>
|
||||
[MessagePackObject]
|
||||
public class BadgeFlag
|
||||
{
|
||||
[JsonPropertyName("is_display_badge")]
|
||||
[Key("is_display_badge")]
|
||||
public bool IsDisplayBadge { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
using MessagePack;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Common;
|
||||
|
||||
/// <summary>
|
||||
/// Flat 4-bool form of <c>shop_notification</c> returned by the badge-poll
|
||||
/// endpoints (StoryFinish, QuestFinish, Recovery, OpenRoomBattleGetRecoveryParam).
|
||||
/// Each bool drives the corresponding shop tab's footer badge via
|
||||
/// <c>ShopNotification.SetShopBadgeEnable</c> (Wizard/ShopNotification.cs:63),
|
||||
/// which calls <c>.ToBoolean()</c> on each directly.
|
||||
///
|
||||
/// Distinct from <see cref="SVSim.EmulatedEntrypoint.Models.Dtos.ShopNotification"/>,
|
||||
/// which is the richer mypage-index shape (each sub-key holds a detail object
|
||||
/// instead of a bool, for the home-screen's animated shop appeals).
|
||||
/// </summary>
|
||||
[MessagePackObject]
|
||||
public class ShopNotificationBadges
|
||||
{
|
||||
[JsonPropertyName("card_pack")]
|
||||
[Key("card_pack")]
|
||||
public bool CardPack { get; set; }
|
||||
|
||||
[JsonPropertyName("build_deck")]
|
||||
[Key("build_deck")]
|
||||
public bool BuildDeck { get; set; }
|
||||
|
||||
[JsonPropertyName("sleeve")]
|
||||
[Key("sleeve")]
|
||||
public bool Sleeve { get; set; }
|
||||
|
||||
[JsonPropertyName("leader_skin")]
|
||||
[Key("leader_skin")]
|
||||
public bool LeaderSkin { get; set; }
|
||||
}
|
||||
@@ -222,7 +222,7 @@ public class MyPageIndexResponse
|
||||
|
||||
[JsonPropertyName("basic_puzzle")]
|
||||
[Key("basic_puzzle")]
|
||||
public BasicPuzzleBadge BasicPuzzle { get; set; } = new();
|
||||
public Common.BadgeFlag BasicPuzzle { get; set; } = new();
|
||||
|
||||
// ── Battle Pass period flag ────────────────────────────────────────────
|
||||
|
||||
|
||||
17
SVSim.EmulatedEntrypoint/Models/Dtos/Story/AllFinishDtos.cs
Normal file
17
SVSim.EmulatedEntrypoint/Models/Dtos/Story/AllFinishDtos.cs
Normal file
@@ -0,0 +1,17 @@
|
||||
using MessagePack;
|
||||
using System.Text.Json.Serialization;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests;
|
||||
|
||||
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Story;
|
||||
|
||||
[MessagePackObject]
|
||||
public class AllFinishRequest : BaseRequest
|
||||
{
|
||||
[JsonPropertyName("story_ids")]
|
||||
[Key("story_ids")]
|
||||
public int[] StoryIds { get; set; } = Array.Empty<int>();
|
||||
|
||||
[JsonPropertyName("is_finish")]
|
||||
[Key("is_finish")]
|
||||
public int IsFinish { get; set; }
|
||||
}
|
||||
158
SVSim.EmulatedEntrypoint/Models/Dtos/Story/FinishDtos.cs
Normal file
158
SVSim.EmulatedEntrypoint/Models/Dtos/Story/FinishDtos.cs
Normal file
@@ -0,0 +1,158 @@
|
||||
using MessagePack;
|
||||
using System.Text.Json.Serialization;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Common;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests;
|
||||
|
||||
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Story;
|
||||
|
||||
// GatheringInfo and CompetitionInfo resolve via the parent Models.Dtos namespace (C# walks outward).
|
||||
|
||||
[MessagePackObject]
|
||||
public class FinishRequest : BaseRequest
|
||||
{
|
||||
[JsonPropertyName("story_id")]
|
||||
[Key("story_id")]
|
||||
public int StoryId { get; set; }
|
||||
|
||||
[JsonPropertyName("is_finish")]
|
||||
[Key("is_finish")]
|
||||
public int IsFinish { get; set; }
|
||||
|
||||
// Battle-shape fields (present only on play-shape)
|
||||
[JsonPropertyName("evolve_count")]
|
||||
[Key("evolve_count")]
|
||||
public int? EvolveCount { get; set; }
|
||||
|
||||
[JsonPropertyName("total_turn")]
|
||||
[Key("total_turn")]
|
||||
public int? TotalTurn { get; set; }
|
||||
|
||||
[JsonPropertyName("deck_no")]
|
||||
[Key("deck_no")]
|
||||
public int? DeckNo { get; set; }
|
||||
|
||||
[JsonPropertyName("use_build_deck")]
|
||||
[Key("use_build_deck")]
|
||||
public int? UseBuildDeck { get; set; }
|
||||
|
||||
[JsonPropertyName("deck_format")]
|
||||
[Key("deck_format")]
|
||||
public int? DeckFormat { get; set; }
|
||||
|
||||
[JsonPropertyName("class_id")]
|
||||
[Key("class_id")]
|
||||
public int? ClassId { get; set; }
|
||||
|
||||
[JsonPropertyName("mission")]
|
||||
[Key("mission")]
|
||||
public Dictionary<string, int>? Mission { get; set; }
|
||||
|
||||
[JsonPropertyName("recovery_data")]
|
||||
[Key("recovery_data")]
|
||||
public string? RecoveryData { get; set; }
|
||||
|
||||
// Misspelled the same way in every solo finish endpoint — preserved on the wire.
|
||||
[JsonPropertyName("prosessing_time_data")]
|
||||
[Key("prosessing_time_data")]
|
||||
public string[]? ProsessingTimeData { get; set; }
|
||||
|
||||
// No-battle-shape fields
|
||||
[JsonPropertyName("selection_chapter_id")]
|
||||
[Key("selection_chapter_id")]
|
||||
public string? SelectionChapterId { get; set; }
|
||||
|
||||
[JsonPropertyName("is_select_another_end")]
|
||||
[Key("is_select_another_end")]
|
||||
public bool? IsSelectAnotherEnd { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Derived: true when the request carries battle-shape fields (ClassId present = play-shape).
|
||||
/// Kept off both serializations.
|
||||
/// </summary>
|
||||
[JsonIgnore]
|
||||
[IgnoreMember]
|
||||
public bool IsPlayShape => ClassId.HasValue;
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public class FinishResponse
|
||||
{
|
||||
[JsonPropertyName("get_class_experience")]
|
||||
[Key("get_class_experience")]
|
||||
public string GetClassExperience { get; set; } = "0";
|
||||
|
||||
[JsonPropertyName("class_experience")]
|
||||
[Key("class_experience")]
|
||||
public int ClassExperience { get; set; }
|
||||
|
||||
[JsonPropertyName("class_level")]
|
||||
[Key("class_level")]
|
||||
public string ClassLevel { get; set; } = "0";
|
||||
|
||||
[JsonPropertyName("achieved_info")]
|
||||
[Key("achieved_info")]
|
||||
public Dictionary<string, object> AchievedInfo { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("reward_list")]
|
||||
[Key("reward_list")]
|
||||
public List<RewardGrant> RewardList { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("story_reward_list")]
|
||||
[Key("story_reward_list")]
|
||||
public List<RewardGrant> StoryRewardList { get; set; } = new();
|
||||
|
||||
// ─── Post-action mypage badge cluster ───
|
||||
//
|
||||
// MyPageNotifications.ParseBadgeInfos (Wizard/MyPageNotifications.cs:9) reads every key below
|
||||
// unguardedly; omitting any one throws KeyNotFoundException in Cute.NetworkManager.Connect and
|
||||
// aborts the response. The same cluster ships from every endpoint that calls ParseBadgeInfos
|
||||
// (StoryFinishTask, QuestFinishTask, RecoveryTask, OpenRoomBattleGetRecoveryParamTask).
|
||||
|
||||
[JsonPropertyName("quest")]
|
||||
[Key("quest")]
|
||||
public BadgeFlag Quest { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("story_notification")]
|
||||
[Key("story_notification")]
|
||||
public BadgeFlag StoryNotification { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("basic_puzzle")]
|
||||
[Key("basic_puzzle")]
|
||||
public BadgeFlag BasicPuzzle { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("shop_notification")]
|
||||
[Key("shop_notification")]
|
||||
public ShopNotificationBadges ShopNotification { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("receive_friend_apply_count")]
|
||||
[Key("receive_friend_apply_count")]
|
||||
public int ReceiveFriendApplyCount { get; set; }
|
||||
|
||||
[JsonPropertyName("gathering_info")]
|
||||
[Key("gathering_info")]
|
||||
public GatheringInfo GatheringInfo { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("competition_info")]
|
||||
[Key("competition_info")]
|
||||
public CompetitionInfo CompetitionInfo { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("is_available_colosseum_free_entry")]
|
||||
[Key("is_available_colosseum_free_entry")]
|
||||
public bool IsAvailableColosseumFreeEntry { get; set; }
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public class RewardGrant
|
||||
{
|
||||
[JsonPropertyName("reward_type")]
|
||||
[Key("reward_type")]
|
||||
public string RewardType { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("reward_id")]
|
||||
[Key("reward_id")]
|
||||
public string RewardId { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("reward_num")]
|
||||
[Key("reward_num")]
|
||||
public string RewardNum { get; set; } = "";
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
using MessagePack;
|
||||
using System.Text.Json.Serialization;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests;
|
||||
|
||||
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Story;
|
||||
|
||||
[MessagePackObject]
|
||||
public class GetDeckListRequest : BaseRequest
|
||||
{
|
||||
[JsonPropertyName("story_id")]
|
||||
[Key("story_id")]
|
||||
public int StoryId { get; set; }
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public class GetDeckListResponse
|
||||
{
|
||||
[JsonPropertyName("user_deck_rotation")]
|
||||
[Key("user_deck_rotation")]
|
||||
public List<UserDeck> UserDeckRotation { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("user_deck_unlimited")]
|
||||
[Key("user_deck_unlimited")]
|
||||
public List<UserDeck> UserDeckUnlimited { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("maintenance_card_list")]
|
||||
[Key("maintenance_card_list")]
|
||||
public List<long> MaintenanceCardList { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("build_deck_list")]
|
||||
[Key("build_deck_list")]
|
||||
public List<BuildDeck> BuildDeckList { get; set; } = new();
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public class BuildDeck
|
||||
{
|
||||
// Placeholder — build decks return [] for v1 per spec.
|
||||
}
|
||||
222
SVSim.EmulatedEntrypoint/Models/Dtos/Story/InfoDtos.cs
Normal file
222
SVSim.EmulatedEntrypoint/Models/Dtos/Story/InfoDtos.cs
Normal file
@@ -0,0 +1,222 @@
|
||||
using MessagePack;
|
||||
using System.Text.Json.Serialization;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests;
|
||||
|
||||
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Story;
|
||||
|
||||
[MessagePackObject]
|
||||
public class InfoRequest : BaseRequest
|
||||
{
|
||||
[JsonPropertyName("section_id")]
|
||||
[Key("section_id")]
|
||||
public int SectionId { get; set; }
|
||||
|
||||
[JsonPropertyName("chara_id")]
|
||||
[Key("chara_id")]
|
||||
public int CharaId { get; set; } // 0 for non-leader-select
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public class InfoResponse
|
||||
{
|
||||
[JsonPropertyName("story_master_list")]
|
||||
[Key("story_master_list")]
|
||||
public List<StoryMasterEntry> StoryMasterList { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("maintenance_card_list")]
|
||||
[Key("maintenance_card_list")]
|
||||
public List<long> MaintenanceCardList { get; set; } = new();
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public class StoryMasterEntry
|
||||
{
|
||||
[JsonPropertyName("story_id")]
|
||||
[Key("story_id")]
|
||||
public string StoryId { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("section_id")]
|
||||
[Key("section_id")]
|
||||
public string SectionId { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("chara_id")]
|
||||
[Key("chara_id")]
|
||||
public string CharaId { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("chapter_id")]
|
||||
[Key("chapter_id")]
|
||||
public string ChapterId { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("is_lock")]
|
||||
[Key("is_lock")]
|
||||
public bool IsLock { get; set; }
|
||||
|
||||
[JsonPropertyName("next_chapter_id")]
|
||||
[Key("next_chapter_id")]
|
||||
public string NextChapterId { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("required_chapter_id")]
|
||||
[Key("required_chapter_id")]
|
||||
public string RequiredChapterId { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("selection_display_position")]
|
||||
[Key("selection_display_position")]
|
||||
public string SelectionDisplayPosition { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("selection_text_id")]
|
||||
[Key("selection_text_id")]
|
||||
public string SelectionTextId { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("show_coordinate")]
|
||||
[Key("show_coordinate")]
|
||||
public string ShowCoordinate { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("x_coordinate")]
|
||||
[Key("x_coordinate")]
|
||||
public string XCoordinate { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("y_coordinate")]
|
||||
[Key("y_coordinate")]
|
||||
public string YCoordinate { get; set; } = "";
|
||||
|
||||
// Wire typo preserved: note the space in "is_camera_ movable"
|
||||
[JsonPropertyName("is_camera_ movable")]
|
||||
[Key("is_camera_ movable")]
|
||||
public string IsCameraMovable { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("show_subtitles")]
|
||||
[Key("show_subtitles")]
|
||||
public string ShowSubtitles { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("battle_exists")]
|
||||
[Key("battle_exists")]
|
||||
public bool BattleExists { get; set; }
|
||||
|
||||
[JsonPropertyName("enemy_chara_id")]
|
||||
[Key("enemy_chara_id")]
|
||||
public string EnemyCharaId { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("enemy_class")]
|
||||
[Key("enemy_class")]
|
||||
public string EnemyClass { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("enemy_ai_id")]
|
||||
[Key("enemy_ai_id")]
|
||||
public string EnemyAiId { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("bg_file_name")]
|
||||
[Key("bg_file_name")]
|
||||
public string BgFileName { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("chapter_effect_path")]
|
||||
[Key("chapter_effect_path")]
|
||||
public string ChapterEffectPath { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("chapter_clear_text_id")]
|
||||
[Key("chapter_clear_text_id")]
|
||||
public string ChapterClearTextId { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("battle3dfield_id")]
|
||||
[Key("battle3dfield_id")]
|
||||
public string Battle3dFieldId { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("bgm_id")]
|
||||
[Key("bgm_id")]
|
||||
public string BgmId { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("special_battle_setting_id")]
|
||||
[Key("special_battle_setting_id")]
|
||||
public string SpecialBattleSettingId { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("release_point")]
|
||||
[Key("release_point")]
|
||||
public string ReleasePoint { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("battle_settings")]
|
||||
[Key("battle_settings")]
|
||||
public List<BattleSettingDto> BattleSettings { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("story_reward")]
|
||||
[Key("story_reward")]
|
||||
public List<RewardDto> StoryReward { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("is_maintenance_chapter")]
|
||||
[Key("is_maintenance_chapter")]
|
||||
public bool IsMaintenanceChapter { get; set; }
|
||||
|
||||
[JsonPropertyName("is_released")]
|
||||
[Key("is_released")]
|
||||
public bool IsReleased { get; set; }
|
||||
|
||||
[JsonPropertyName("is_skipped")]
|
||||
[Key("is_skipped")]
|
||||
public bool IsSkipped { get; set; }
|
||||
|
||||
[JsonPropertyName("is_finish")]
|
||||
[Key("is_finish")]
|
||||
public bool IsFinish { get; set; }
|
||||
|
||||
[JsonPropertyName("unlock_text")]
|
||||
[Key("unlock_text")]
|
||||
public string UnlockText { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("is_play_another_end_appearance_animation")]
|
||||
[Key("is_play_another_end_appearance_animation")]
|
||||
public bool IsPlayAnotherEndAppearanceAnimation { get; set; }
|
||||
|
||||
[JsonPropertyName("is_released_another_end")]
|
||||
[Key("is_released_another_end")]
|
||||
public bool IsReleasedAnotherEnd { get; set; }
|
||||
|
||||
[JsonPropertyName("is_skip_enabled")]
|
||||
[Key("is_skip_enabled")]
|
||||
public bool IsSkipEnabled { get; set; }
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public class BattleSettingDto
|
||||
{
|
||||
[JsonPropertyName("deck_class_id")]
|
||||
[Key("deck_class_id")]
|
||||
public int DeckClassId { get; set; }
|
||||
|
||||
[JsonPropertyName("player_emotion_override")]
|
||||
[Key("player_emotion_override")]
|
||||
public int PlayerEmotionOverride { get; set; }
|
||||
|
||||
[JsonPropertyName("enemy_emotion_override")]
|
||||
[Key("enemy_emotion_override")]
|
||||
public int EnemyEmotionOverride { get; set; }
|
||||
|
||||
[JsonPropertyName("skin_id_override")]
|
||||
[Key("skin_id_override")]
|
||||
public int SkinIdOverride { get; set; }
|
||||
|
||||
[JsonPropertyName("battle3dfield_id_override")]
|
||||
[Key("battle3dfield_id_override")]
|
||||
public int Battle3dFieldIdOverride { get; set; }
|
||||
|
||||
[JsonPropertyName("bgm_id_override")]
|
||||
[Key("bgm_id_override")]
|
||||
public int BgmIdOverride { get; set; }
|
||||
|
||||
[JsonPropertyName("deck_skin_id_override")]
|
||||
[Key("deck_skin_id_override")]
|
||||
public int DeckSkinIdOverride { get; set; }
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public class RewardDto
|
||||
{
|
||||
[JsonPropertyName("reward_type")]
|
||||
[Key("reward_type")]
|
||||
public string RewardType { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("reward_detail_id")]
|
||||
[Key("reward_detail_id")]
|
||||
public string RewardDetailId { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("reward_number")]
|
||||
[Key("reward_number")]
|
||||
public string RewardNumber { get; set; } = "";
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
using MessagePack;
|
||||
using System.Text.Json.Serialization;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests;
|
||||
|
||||
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Story;
|
||||
|
||||
[MessagePackObject]
|
||||
public class LeaderSelectRequest : BaseRequest
|
||||
{
|
||||
[JsonPropertyName("section_id")]
|
||||
[Key("section_id")]
|
||||
public int SectionId { get; set; }
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public class LeaderSelectResponse
|
||||
{
|
||||
[JsonPropertyName("leader_list")]
|
||||
[Key("leader_list")]
|
||||
public List<LeaderEntry> LeaderList { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("leader_count")]
|
||||
[Key("leader_count")]
|
||||
public int LeaderCount { get; set; } = 8;
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public class LeaderEntry
|
||||
{
|
||||
[JsonPropertyName("chara_id")]
|
||||
[Key("chara_id")]
|
||||
public int CharaId { get; set; }
|
||||
|
||||
[JsonPropertyName("is_skipped")]
|
||||
[Key("is_skipped")]
|
||||
public bool IsSkipped { get; set; }
|
||||
|
||||
[JsonPropertyName("is_finished")]
|
||||
[Key("is_finished")]
|
||||
public bool IsFinished { get; set; }
|
||||
|
||||
[JsonPropertyName("current_chapter")]
|
||||
[Key("current_chapter")]
|
||||
public int CurrentChapter { get; set; }
|
||||
}
|
||||
109
SVSim.EmulatedEntrypoint/Models/Dtos/Story/SectionDtos.cs
Normal file
109
SVSim.EmulatedEntrypoint/Models/Dtos/Story/SectionDtos.cs
Normal file
@@ -0,0 +1,109 @@
|
||||
using MessagePack;
|
||||
using System.Text.Json.Serialization;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests;
|
||||
|
||||
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Story;
|
||||
|
||||
[MessagePackObject]
|
||||
public class SectionRequest : BaseRequest
|
||||
{
|
||||
[JsonPropertyName("is_disp_first_tips")]
|
||||
[Key("is_disp_first_tips")]
|
||||
public bool IsDispFirstTips { get; set; }
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public class SectionResponse
|
||||
{
|
||||
[JsonPropertyName("world_list")]
|
||||
[Key("world_list")]
|
||||
public Dictionary<string, SectionWorld> WorldList { get; set; } = new();
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public class SectionWorld
|
||||
{
|
||||
[JsonPropertyName("title_text_id")]
|
||||
[Key("title_text_id")]
|
||||
public string TitleTextId { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("panel_image_name")]
|
||||
[Key("panel_image_name")]
|
||||
public string PanelImageName { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("ribbon_text")]
|
||||
[Key("ribbon_text")]
|
||||
public string RibbonText { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("is_complete")]
|
||||
[Key("is_complete")]
|
||||
public bool IsComplete { get; set; }
|
||||
|
||||
[JsonPropertyName("section_list")]
|
||||
[Key("section_list")]
|
||||
public List<SectionEntry> SectionList { get; set; } = new();
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public class SectionEntry
|
||||
{
|
||||
[JsonPropertyName("section_id")]
|
||||
[Key("section_id")]
|
||||
public string SectionId { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("order_id")]
|
||||
[Key("order_id")]
|
||||
public int OrderId { get; set; }
|
||||
|
||||
[JsonPropertyName("all_story_order_id")]
|
||||
[Key("all_story_order_id")]
|
||||
public string AllStoryOrderId { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("name")]
|
||||
[Key("name")]
|
||||
public string Name { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("image_name")]
|
||||
[Key("image_name")]
|
||||
public string ImageName { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("is_leader_select")]
|
||||
[Key("is_leader_select")]
|
||||
public bool IsLeaderSelect { get; set; }
|
||||
|
||||
[JsonPropertyName("back_ground_id")]
|
||||
[Key("back_ground_id")]
|
||||
public int BackGroundId { get; set; }
|
||||
|
||||
[JsonPropertyName("is_finished")]
|
||||
[Key("is_finished")]
|
||||
public bool IsFinished { get; set; }
|
||||
|
||||
[JsonPropertyName("released_chara_count")]
|
||||
[Key("released_chara_count")]
|
||||
public int ReleasedCharaCount { get; set; }
|
||||
|
||||
[JsonPropertyName("finished_chara_count")]
|
||||
[Key("finished_chara_count")]
|
||||
public int FinishedCharaCount { get; set; }
|
||||
|
||||
[JsonPropertyName("is_under_maintenance")]
|
||||
[Key("is_under_maintenance")]
|
||||
public bool IsUnderMaintenance { get; set; }
|
||||
|
||||
[JsonPropertyName("chapter_select_type")]
|
||||
[Key("chapter_select_type")]
|
||||
public string ChapterSelectType { get; set; } = "1";
|
||||
|
||||
[JsonPropertyName("story_type_overwrite")]
|
||||
[Key("story_type_overwrite")]
|
||||
public string StoryTypeOverwrite { get; set; } = "1";
|
||||
|
||||
[JsonPropertyName("is_new")]
|
||||
[Key("is_new")]
|
||||
public bool IsNew { get; set; }
|
||||
|
||||
[JsonPropertyName("is_play_another_end_appearance_animation")]
|
||||
[Key("is_play_another_end_appearance_animation")]
|
||||
public bool IsPlayAnotherEndAppearanceAnimation { get; set; }
|
||||
}
|
||||
97
SVSim.EmulatedEntrypoint/Models/Dtos/Story/StartDtos.cs
Normal file
97
SVSim.EmulatedEntrypoint/Models/Dtos/Story/StartDtos.cs
Normal file
@@ -0,0 +1,97 @@
|
||||
using MessagePack;
|
||||
using System.Text.Json.Serialization;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests;
|
||||
|
||||
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Story;
|
||||
|
||||
[MessagePackObject]
|
||||
public class StartRequest : BaseRequest
|
||||
{
|
||||
[JsonPropertyName("story_ids")]
|
||||
[Key("story_ids")]
|
||||
public int[] StoryIds { get; set; } = Array.Empty<int>();
|
||||
}
|
||||
|
||||
// The `start` response is dynamic — each numeric key corresponds to a request story_ids index.
|
||||
// We use a Dictionary<string, object> to support both the populated and empty slot shapes.
|
||||
// MessagePack handles Dictionary natively; no [MessagePackObject] needed here.
|
||||
public class StartResponse : Dictionary<string, object>
|
||||
{
|
||||
public void AddSlot(int index, object slotPayload) => this[index.ToString()] = slotPayload;
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public class StartSlotWithSbs
|
||||
{
|
||||
[JsonPropertyName("special_battle_setting")]
|
||||
[Key("special_battle_setting")]
|
||||
public SpecialBattleSettingDto SpecialBattleSetting { get; set; } = new();
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public class SpecialBattleSettingDto
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
[Key("id")]
|
||||
public string Id { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("player_first_turn")]
|
||||
[Key("player_first_turn")]
|
||||
public string PlayerFirstTurn { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("player_start_pp")]
|
||||
[Key("player_start_pp")]
|
||||
public string PlayerStartPp { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("enemy_start_pp")]
|
||||
[Key("enemy_start_pp")]
|
||||
public string EnemyStartPp { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("player_start_life")]
|
||||
[Key("player_start_life")]
|
||||
public string PlayerStartLife { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("enemy_start_life")]
|
||||
[Key("enemy_start_life")]
|
||||
public string EnemyStartLife { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("player_attach_skill")]
|
||||
[Key("player_attach_skill")]
|
||||
public string PlayerAttachSkill { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("enemy_attach_skill")]
|
||||
[Key("enemy_attach_skill")]
|
||||
public string EnemyAttachSkill { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("id_override_in_battle_log")]
|
||||
[Key("id_override_in_battle_log")]
|
||||
public string IdOverrideInBattleLog { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("banish_effect_override")]
|
||||
[Key("banish_effect_override")]
|
||||
public string BanishEffectOverride { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("token_draw_effect_override")]
|
||||
[Key("token_draw_effect_override")]
|
||||
public string TokenDrawEffectOverride { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("special_token_draw_effect_override")]
|
||||
[Key("special_token_draw_effect_override")]
|
||||
public string SpecialTokenDrawEffectOverride { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("result_skip")]
|
||||
[Key("result_skip")]
|
||||
public string ResultSkip { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("vs_effect_override")]
|
||||
[Key("vs_effect_override")]
|
||||
public string VsEffectOverride { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("class_destroy_effect_override")]
|
||||
[Key("class_destroy_effect_override")]
|
||||
public string ClassDestroyEffectOverride { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("note")]
|
||||
[Key("note")]
|
||||
public string Note { get; set; } = "";
|
||||
}
|
||||
@@ -7,6 +7,7 @@ using SVSim.Database.Repositories.Collectibles;
|
||||
using SVSim.Database.Repositories.Deck;
|
||||
using SVSim.Database.Repositories.Globals;
|
||||
using SVSim.Database.Repositories.Pack;
|
||||
using SVSim.Database.Repositories.Story;
|
||||
using SVSim.Database.Repositories.Viewer;
|
||||
using SVSim.Database.Services;
|
||||
using SVSim.EmulatedEntrypoint.Configuration;
|
||||
@@ -44,7 +45,12 @@ public class Program
|
||||
});
|
||||
// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle
|
||||
builder.Services.AddEndpointsApiExplorer();
|
||||
builder.Services.AddSwaggerGen();
|
||||
builder.Services.AddSwaggerGen(c =>
|
||||
{
|
||||
// Disambiguate same-named DTOs across families (e.g. Story.StartRequest vs
|
||||
// BasicPuzzle.StartRequest) by qualifying schema ids with the full type name.
|
||||
c.CustomSchemaIds(t => t.FullName?.Replace("+", "."));
|
||||
});
|
||||
builder.Services.AddHttpLogging(opt =>
|
||||
{
|
||||
|
||||
@@ -75,6 +81,9 @@ public class Program
|
||||
builder.Services.AddScoped<PackOpenService>();
|
||||
builder.Services.AddScoped<ICardAcquisitionService, CardAcquisitionService>();
|
||||
builder.Services.AddScoped<RewardGrantService>();
|
||||
builder.Services.AddScoped<IStoryMasterRepository, StoryMasterRepository>();
|
||||
builder.Services.AddScoped<IViewerStoryProgressRepository, ViewerStoryProgressRepository>();
|
||||
builder.Services.AddScoped<IStoryService, StoryService>();
|
||||
builder.Services.AddSingleton<IRandom, SystemRandom>();
|
||||
builder.Services.AddSingleton<PuzzleMissionEvaluator>();
|
||||
|
||||
|
||||
15
SVSim.EmulatedEntrypoint/Services/IStoryService.cs
Normal file
15
SVSim.EmulatedEntrypoint/Services/IStoryService.cs
Normal file
@@ -0,0 +1,15 @@
|
||||
using SVSim.Database.Entities.Story;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Story;
|
||||
|
||||
namespace SVSim.EmulatedEntrypoint.Services;
|
||||
|
||||
public interface IStoryService
|
||||
{
|
||||
Task<SectionResponse> GetSectionsAsync(StoryApiType apiType, long viewerId);
|
||||
Task<LeaderSelectResponse> GetLeaderSelectAsync(StoryApiType apiType, int sectionId, long viewerId);
|
||||
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<FinishResponse> AllFinishAsync(StoryApiType apiType, int[] storyIds, bool isFinish, long viewerId);
|
||||
}
|
||||
436
SVSim.EmulatedEntrypoint/Services/StoryService.cs
Normal file
436
SVSim.EmulatedEntrypoint/Services/StoryService.cs
Normal file
@@ -0,0 +1,436 @@
|
||||
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,
|
||||
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)
|
||||
{
|
||||
var charas = s.IsLeaderSelect ? charaIds : new[] { 0 };
|
||||
int released = 0, finished = 0, charasWithChapters = 0;
|
||||
foreach (var c in charas)
|
||||
{
|
||||
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 > 0) released++;
|
||||
if (doneCount == chapters.Count) finished++;
|
||||
}
|
||||
// Compare against charas that actually have chapters, not the canonical 1-8 list —
|
||||
// otherwise a section missing a class would never be `IsFinished`.
|
||||
bool sectionFinished = charasWithChapters > 0 && finished == charasWithChapters;
|
||||
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.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)
|
||||
{
|
||||
GrantedReward granted;
|
||||
try
|
||||
{
|
||||
granted = _rewards.Apply(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" line in
|
||||
// the rewards popup.
|
||||
// Same reward_id, different reward_num. For cosmetics (binary owned/not-owned)
|
||||
// both happen to be 1, so the bug only surfaces on currency rewards.
|
||||
resp.RewardList.Add(new RewardGrant
|
||||
{
|
||||
RewardType = granted.RewardType.ToString(),
|
||||
RewardId = granted.RewardId.ToString(),
|
||||
RewardNum = granted.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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user