This commit is contained in:
gamer147
2026-05-25 12:03:47 -04:00
parent d067f8a64a
commit 558e8288eb
44 changed files with 6512 additions and 3 deletions

View File

@@ -198,6 +198,16 @@ public class AdminController : SVSimController
.ToList();
var cards = await _dbContext.Cards.Where(c => allCardIds.Contains(c.Id)).ToDictionaryAsync(c => c.Id);
// Seeded MyRotation placeholder decks need a real rotation_id, otherwise the client's
// DeckData.GetMyRotationClassName NREs on `info.LastPackText` when the user clicks one
// (info is null because Data.MyRotationAllInfo.Get(null) returns null). Pick the highest
// rotation id available — it includes the most recent pack and therefore covers every
// class (including class_id=8 Nemesis, which requires last_pack >= 10007).
var latestMyRotationId = (await _dbContext.MyRotationSettings.AsNoTracking()
.Select(s => (int?)s.Id)
.OrderByDescending(id => id)
.FirstOrDefaultAsync())?.ToString();
foreach (var format in SeededDeckFormats)
{
int slot = 1;
@@ -224,6 +234,7 @@ public class AdminController : SVSimController
LeaderSkin = leaderSkin,
RandomLeaderSkin = false,
Cards = deckCards,
MyRotationId = format == Format.MyRotation ? latestMyRotationId : null,
});
}
}

View File

@@ -181,6 +181,9 @@ public class DeckController : SVSimController
if (skin is not null) deck.LeaderSkin = skin;
deck.RandomLeaderSkin = request.IsRandomLeaderSkin;
deck.Cards = cards;
// Clear stale rotation_id if the deck moved to a non-MyRotation format;
// otherwise persist the chosen period so it survives the next /load/index.
deck.MyRotationId = format == Format.MyRotation ? request.RotationId : null;
});
}

View File

@@ -98,7 +98,7 @@ public class MyPageController : SVSimController
{
UserMyPageSetting = new MyPageBgSetting(),
},
BasicPuzzle = new BasicPuzzle { IsDisplayBadge = false }, // TODO(mypage-stub): viewer practice-puzzle progress
BasicPuzzle = new BasicPuzzleBadge { 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,

View File

@@ -0,0 +1,292 @@
using System.Text.Json;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using SVSim.Database;
using SVSim.Database.Repositories.Globals;
using SVSim.Database.Repositories.Viewer;
using SVSim.Database.Services;
using SVSim.EmulatedEntrypoint.Models.Dtos.Common.BasicPuzzle;
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests;
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.BasicPuzzle;
using SVSim.EmulatedEntrypoint.Models.Dtos.Responses.BasicPuzzle;
using SVSim.EmulatedEntrypoint.Services;
namespace SVSim.EmulatedEntrypoint.Controllers;
/// <summary>
/// /basic_puzzle/* — solo puzzle subsystem (the "Practice Match" puzzle catalog visible from
/// the home screen). Explicit [Route] override because the base SVSimController's [controller]
/// token would resolve to /puzzle.
/// </summary>
[Route("basic_puzzle")]
public class PuzzleController : SVSimController
{
private readonly IPuzzleCatalogRepository _catalog;
private readonly IPuzzleClearRepository _clears;
private readonly PuzzleMissionEvaluator _evaluator;
private readonly RewardGrantService _rewards;
public PuzzleController(
IPuzzleCatalogRepository catalog,
IPuzzleClearRepository clears,
PuzzleMissionEvaluator evaluator,
RewardGrantService rewards)
{
_catalog = catalog;
_clears = clears;
_evaluator = evaluator;
_rewards = rewards;
}
/// <summary>/basic_puzzle/info — full catalog of groups + per-viewer clear flags.</summary>
[HttpPost("info")]
public async Task<List<PuzzleGroupResponse>> Info(BaseRequest _)
{
if (!TryGetViewerId(out long viewerId)) viewerId = 0;
var groups = await _catalog.GetAllGroupsWithPuzzles();
var missions = await _catalog.GetAllMissionsOrdered();
var clearedByGroup = await _clears.GetClearedPuzzleIdsByGroup(viewerId);
return ProjectGroups(groups, missions, clearedByGroup);
}
/// <summary>/basic_puzzle/open_puzzle_dialog — per-group detail. Unknown puzzle_master_id
/// returns 200 with an empty puzzle_quest array (matches client PuzzleQuestInfo fallback).</summary>
[HttpPost("open_puzzle_dialog")]
public async Task<OpenPuzzleDialogResponse> OpenPuzzleDialog(OpenPuzzleDialogRequest req)
{
if (!TryGetViewerId(out long viewerId)) viewerId = 0;
var group = await _catalog.GetGroupWithPuzzles(req.PuzzleMasterId);
if (group is null) return new OpenPuzzleDialogResponse();
var cleared = await _clears.GetClearedPuzzleIds(viewerId);
return new OpenPuzzleDialogResponse
{
PuzzleQuest = group.Puzzles
.OrderBy(p => p.Id)
.Select(p => new PuzzleEntryResponse
{
PuzzleId = p.Id,
PuzzleDifficulty = p.PuzzleDifficulty,
IsCleared = cleared.Contains(p.Id),
IsAdditional = p.IsAdditional,
IsPlayable = p.IsPlayable,
ReleaseConditionTextId = p.ReleaseConditionTextId,
})
.ToList(),
PuzzleQuestCharaId = group.PuzzleCharaId,
PuzzleDifficultyNameList = JsonSerializer.Deserialize<Dictionary<string, string>>(group.DifficultyNameListJson) ?? new(),
IsDisplayBadge = false,
IsDisplayPuzzleNew = false,
};
}
/// <summary>/basic_puzzle/start — server is essentially a no-op. Wire data is the literal empty array `[]`.</summary>
[HttpPost("start")]
public Task<object[]> Start(StartRequest _) => Task.FromResult(Array.Empty<object>());
/// <summary>/basic_puzzle/mission — catalog + per-viewer progress on each mission.
/// Special-Round missions always surface with total_count=0 (Phase 1 deferral).</summary>
[HttpPost("mission")]
public async Task<List<PuzzleMissionResponse>> Mission(BaseRequest _)
{
if (!TryGetViewerId(out long viewerId)) viewerId = 0;
var missions = await _catalog.GetAllMissionsOrdered();
var clearedByGroup = await _clears.GetClearedPuzzleIdsByGroup(viewerId);
var statuses = _evaluator.Evaluate(missions, clearedByGroup);
return statuses.Select(s => new PuzzleMissionResponse
{
MissionName = s.Mission.MissionName,
RequireNumber = s.Mission.RequireNumber,
CampaignCommenceTime = s.Mission.CampaignCommenceTime,
RewardList = new List<PuzzleMissionRewardResponse>
{
new() {
RewardType = s.Mission.RewardType,
RewardDetailId = s.Mission.RewardDetailId,
RewardNumber = s.Mission.RewardNumber,
},
},
OrderId = s.Mission.OrderId,
TotalCount = s.TotalCount,
IsAchieved = s.IsAchieved,
}).ToList();
}
/// <summary>
/// /basic_puzzle/finish — record a puzzle attempt outcome. Wins persist a ViewerPuzzleClear
/// row and may grant a mission reward; losses are fully stateless (the client only sends
/// is_win=false on user-initiated retire, not on in-battle resets).
///
/// CONCURRENCY: this controller does not serialize concurrent finishes for the same viewer.
/// The ViewerPuzzleClear PK protects per-row idempotency but two simultaneous finishes for
/// different puzzles in the same group could both observe "this is the last clear" and
/// double-grant the mission reward. The same race exists across many viewer-mutating
/// endpoints in this codebase — address with a holistic audit, not a puzzle-specific fix.
/// </summary>
[HttpPost("finish")]
public async Task<FinishResponse> Finish(FinishRequest req)
{
if (!TryGetViewerId(out long viewerId)) viewerId = 0;
var response = new FinishResponse();
var groups = await _catalog.GetAllGroupsWithPuzzles();
var missions = await _catalog.GetAllMissionsOrdered();
if (!req.IsWin)
{
// Loss: no DB writes. Loss-specific wire quirks: win_count is the NUMBER 0
// (not string "1"), and mission_start_data is empty.
response.WinCount = 0;
response.AchievedInfo.MissionStartData = new();
response.PuzzleList = ProjectGroups(groups, missions, await _clears.GetClearedPuzzleIdsByGroup(viewerId));
return response;
}
// ---- Win path ----
var beforeByGroup = await _clears.GetClearedPuzzleIdsByGroup(viewerId);
await _clears.UpsertClearAsync(viewerId, req.PuzzleId, req.RetryCount);
// Recompute clearedByGroup by adding the freshly cleared puzzle to its group.
var puzzleLocation = groups
.SelectMany(g => g.Puzzles.Select(p => (GroupId: g.Id, PuzzleId: p.Id)))
.FirstOrDefault(x => x.PuzzleId == req.PuzzleId);
var afterByGroup = beforeByGroup.ToDictionary(k => k.Key, v => new HashSet<int>(v.Value));
if (puzzleLocation.PuzzleId != 0)
{
if (!afterByGroup.TryGetValue(puzzleLocation.GroupId, out var groupSet))
{
groupSet = new HashSet<int>();
afterByGroup[puzzleLocation.GroupId] = groupSet;
}
groupSet.Add(req.PuzzleId);
}
var fresh = _evaluator.FreshlyCompleted(missions, beforeByGroup, afterByGroup);
var freshlyAchievedIds = new HashSet<int>(fresh.Select(s => s.Mission.Id));
if (fresh.Count > 0)
{
// Load viewer with all the collections RewardGrantService might mutate. Split-query
// to avoid the cartesian-explode pitfall (CLAUDE.md "EF split query").
var ctx = HttpContext.RequestServices.GetRequiredService<SVSimDbContext>();
var viewer = await ctx.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 status in fresh)
{
var granted = _rewards.Apply(
viewer,
(SVSim.Database.Enums.UserGoodsType)status.Mission.RewardType,
status.Mission.RewardDetailId,
status.Mission.RewardNumber);
response.AchievedInfo.AchievedMissionList.Add(new PuzzleAchievedMissionEntry
{
AchievedMessage = status.Mission.AchievedMessage,
});
response.AchievedInfo.AchievedMissionRewardList.Add(new PuzzleAchievedMissionReward
{
MissionRewardType = status.Mission.RewardType,
MissionRewardDetailId = status.Mission.RewardDetailId,
MissionRewardNumber = status.Mission.RewardNumber,
});
response.RewardList.Add(new TreasureRewardResponse
{
RewardType = granted.RewardType,
RewardId = granted.RewardId,
RewardNum = granted.RewardNum,
});
}
await ctx.SaveChangesAsync();
}
response.WinCount = "1";
response.AchievedInfo.MissionStartData = BuildMissionStartData(missions, afterByGroup, freshlyAchievedIds);
response.PuzzleList = ProjectGroups(groups, missions, afterByGroup);
return response;
}
private List<MissionStartEntry> BuildMissionStartData(
IEnumerable<SVSim.Database.Models.PuzzleMissionEntry> missions,
IReadOnlyDictionary<int, HashSet<int>> clearedByGroup,
ISet<int> freshlyAchieved)
{
var statuses = _evaluator.Evaluate(missions, clearedByGroup);
return statuses
.Where(s => !s.IsAchieved && !freshlyAchieved.Contains(s.Mission.Id))
.Select(s => new MissionStartEntry
{
MissionName = s.Mission.MissionName,
StartTime = s.Mission.CampaignCommenceTime,
LotType = "3", // puzzle-group-clear; Phase 1 only emits puzzle missions
})
.ToList();
}
/// <summary>Shared projection used by /info and /finish.puzzle_list. Applies per-viewer clear
/// flags, computes is_all_cleared, and toggles is_mission_target based on mission progress.</summary>
internal List<PuzzleGroupResponse> ProjectGroups(
IEnumerable<SVSim.Database.Models.PuzzleGroupEntry> groups,
IEnumerable<SVSim.Database.Models.PuzzleMissionEntry> missions,
IReadOnlyDictionary<int, HashSet<int>> clearedByGroup)
{
var statuses = _evaluator.Evaluate(missions, clearedByGroup);
var achievedGroupIds = statuses
.Where(s => s.IsAchieved && s.Mission.TargetPuzzleGroupId is int)
.Select(s => s.Mission.TargetPuzzleGroupId!.Value)
.ToHashSet();
var mappedGroupIds = missions
.Where(m => m.TargetPuzzleGroupId is int)
.Select(m => m.TargetPuzzleGroupId!.Value)
.ToHashSet();
var result = new List<PuzzleGroupResponse>();
foreach (var g in groups)
{
var cleared = clearedByGroup.TryGetValue(g.Id, out var c) ? c : new HashSet<int>();
var puzzleEntries = g.Puzzles
.OrderBy(p => p.Id)
.Select(p => new PuzzleEntryResponse
{
PuzzleId = p.Id,
PuzzleDifficulty = p.PuzzleDifficulty,
IsCleared = cleared.Contains(p.Id),
IsAdditional = p.IsAdditional,
IsPlayable = p.IsPlayable,
ReleaseConditionTextId = p.ReleaseConditionTextId,
})
.ToList();
bool isAllCleared = puzzleEntries.All(p => p.IsCleared) && puzzleEntries.Count > 0;
bool isMissionTarget = mappedGroupIds.Contains(g.Id) && !achievedGroupIds.Contains(g.Id);
result.Add(new PuzzleGroupResponse
{
PuzzleMasterId = g.Id,
PuzzleData = puzzleEntries,
PuzzleCharaId = g.PuzzleCharaId,
PuzzleDifficultyNameList = JsonSerializer.Deserialize<Dictionary<string, string>>(g.DifficultyNameListJson) ?? new(),
IsAllCleared = isAllCleared,
CharaId = g.CharaId,
SortType = g.SortType,
BasicTitleTextId = g.BasicTitleTextId,
IsMissionTarget = isMissionTarget,
});
}
// Captured order in prod is descending by puzzle_master_id; mirror that for the wire.
return result.OrderByDescending(r => r.PuzzleMasterId).ToList();
}
}

View File

@@ -6,9 +6,13 @@ 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 BasicPuzzle
public class BasicPuzzleBadge
{
[JsonPropertyName("is_display_badge")]
[Key("is_display_badge")]

View File

@@ -0,0 +1,27 @@
using System.Text.Json.Serialization;
using MessagePack;
using SVSim.EmulatedEntrypoint.Models.Dtos.Common;
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Common.BasicPuzzle;
[MessagePackObject]
public class PuzzleEntryResponse
{
[JsonPropertyName("puzzle_id")] [JsonConverter(typeof(StringifiedIntConverter))] [Key("puzzle_id")]
public int PuzzleId { get; set; }
[JsonPropertyName("puzzle_difficulty")] [JsonConverter(typeof(StringifiedIntConverter))] [Key("puzzle_difficulty")]
public int PuzzleDifficulty { get; set; }
[JsonPropertyName("is_cleared")] [Key("is_cleared")]
public bool IsCleared { get; set; }
[JsonPropertyName("is_additional")] [Key("is_additional")]
public bool IsAdditional { get; set; }
[JsonPropertyName("is_playable")] [Key("is_playable")]
public bool IsPlayable { get; set; } = true;
[JsonPropertyName("release_condition_text_id")] [Key("release_condition_text_id")]
public string ReleaseConditionTextId { get; set; } = string.Empty;
}

View File

@@ -0,0 +1,36 @@
using System.Text.Json.Serialization;
using MessagePack;
using SVSim.EmulatedEntrypoint.Models.Dtos.Common;
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Common.BasicPuzzle;
[MessagePackObject]
public class PuzzleGroupResponse
{
[JsonPropertyName("puzzle_master_id")] [JsonConverter(typeof(StringifiedIntConverter))] [Key("puzzle_master_id")]
public int PuzzleMasterId { get; set; }
[JsonPropertyName("puzzle_data")] [Key("puzzle_data")]
public List<PuzzleEntryResponse> PuzzleData { get; set; } = new();
[JsonPropertyName("puzzle_chara_id")] [JsonConverter(typeof(StringifiedIntConverter))] [Key("puzzle_chara_id")]
public int PuzzleCharaId { get; set; }
[JsonPropertyName("puzzle_difficulty_name_list")] [Key("puzzle_difficulty_name_list")]
public Dictionary<string, string> PuzzleDifficultyNameList { get; set; } = new();
[JsonPropertyName("is_all_cleared")] [Key("is_all_cleared")]
public bool IsAllCleared { get; set; }
[JsonPropertyName("chara_id")] [JsonConverter(typeof(StringifiedIntConverter))] [Key("chara_id")]
public int CharaId { get; set; }
[JsonPropertyName("sort_type")] [JsonConverter(typeof(StringifiedIntConverter))] [Key("sort_type")]
public int SortType { get; set; }
[JsonPropertyName("basic_title_text_id")] [Key("basic_title_text_id")]
public string BasicTitleTextId { get; set; } = string.Empty;
[JsonPropertyName("is_mission_target")] [Key("is_mission_target")]
public bool IsMissionTarget { get; set; }
}

View File

@@ -0,0 +1,33 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Common;
/// <summary>Serializes int as a JSON string ("123"), deserializes from either form. Several
/// /basic_puzzle/* fields use this on the wire (puzzle_master_id, total_count, reward_type, etc.).</summary>
public sealed class StringifiedIntConverter : JsonConverter<int>
{
public override int Read(ref Utf8JsonReader r, Type _, JsonSerializerOptions __) =>
r.TokenType switch
{
JsonTokenType.String when int.TryParse(r.GetString(), out var v) => v,
JsonTokenType.Number => r.GetInt32(),
_ => 0
};
public override void Write(Utf8JsonWriter w, int v, JsonSerializerOptions _) =>
w.WriteStringValue(v.ToString());
}
/// <summary>Same for long. Reward ids fit in int but the client uses long internally.</summary>
public sealed class StringifiedLongConverter : JsonConverter<long>
{
public override long Read(ref Utf8JsonReader r, Type _, JsonSerializerOptions __) =>
r.TokenType switch
{
JsonTokenType.String when long.TryParse(r.GetString(), out var v) => v,
JsonTokenType.Number => r.GetInt64(),
_ => 0
};
public override void Write(Utf8JsonWriter w, long v, JsonSerializerOptions _) =>
w.WriteStringValue(v.ToString());
}

View File

@@ -0,0 +1,20 @@
using MessagePack;
using System.Text.Json.Serialization;
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Requests.BasicPuzzle;
[MessagePackObject]
public class FinishRequest : BaseRequest
{
[JsonPropertyName("puzzle_id")]
[Key("puzzle_id")]
public int PuzzleId { get; set; }
[JsonPropertyName("retry_count")]
[Key("retry_count")]
public int RetryCount { get; set; }
[JsonPropertyName("is_win")]
[Key("is_win")]
public bool IsWin { get; set; }
}

View File

@@ -0,0 +1,12 @@
using MessagePack;
using System.Text.Json.Serialization;
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Requests.BasicPuzzle;
[MessagePackObject]
public class OpenPuzzleDialogRequest : BaseRequest
{
[JsonPropertyName("puzzle_master_id")]
[Key("puzzle_master_id")]
public int PuzzleMasterId { get; set; }
}

View File

@@ -0,0 +1,12 @@
using MessagePack;
using System.Text.Json.Serialization;
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Requests.BasicPuzzle;
[MessagePackObject]
public class StartRequest : BaseRequest
{
[JsonPropertyName("puzzle_id")]
[Key("puzzle_id")]
public int PuzzleId { get; set; }
}

View File

@@ -0,0 +1,140 @@
using System.Text.Json.Serialization;
using MessagePack;
using SVSim.EmulatedEntrypoint.Models.Dtos.Common;
using SVSim.EmulatedEntrypoint.Models.Dtos.Common.BasicPuzzle;
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Responses.BasicPuzzle;
[MessagePackObject]
public class FinishResponse
{
[JsonPropertyName("add_point")] [Key("add_point")]
public int? AddPoint { get; set; } = null;
/// <summary>STRING "1" on wins, NUMBER 0 on losses — both observed in prod. Per-call wire type
/// quirk; controller writes the right one based on is_win.</summary>
[JsonPropertyName("win_count")] [Key("win_count")]
public object WinCount { get; set; } = 0;
[JsonPropertyName("class_experience")] [JsonConverter(typeof(StringifiedIntConverter))] [Key("class_experience")]
public int ClassExperience { get; set; } = 0;
[JsonPropertyName("class_level")] [JsonConverter(typeof(StringifiedIntConverter))] [Key("class_level")]
public int ClassLevel { get; set; } = 1;
[JsonPropertyName("achieved_info")] [Key("achieved_info")]
public AchievedInfoResponse AchievedInfo { get; set; } = new();
[JsonPropertyName("reward_list")] [Key("reward_list")]
public List<TreasureRewardResponse> RewardList { get; set; } = new();
[JsonPropertyName("class_bonus_point")] [Key("class_bonus_point")]
public int ClassBonusPoint { get; set; } = 0;
[JsonPropertyName("format_bonus_point")] [Key("format_bonus_point")]
public int FormatBonusPoint { get; set; } = 0;
[JsonPropertyName("required_win_count_for_win_bonus_point")] [Key("required_win_count_for_win_bonus_point")]
public int RequiredWinCountForWinBonusPoint { get; set; } = 0;
[JsonPropertyName("win_bonus_point")] [Key("win_bonus_point")]
public int WinBonusPoint { get; set; } = 0;
[JsonPropertyName("win_bonus_point_status")] [Key("win_bonus_point_status")]
public int WinBonusPointStatus { get; set; } = 0;
[JsonPropertyName("get_class_experience")] [Key("get_class_experience")]
public int GetClassExperience { get; set; } = 0;
[JsonPropertyName("clear_mission_list")] [Key("clear_mission_list")]
public ClearMissionListResponse ClearMissionList { get; set; } = new();
[JsonPropertyName("spot_point_data")] [Key("spot_point_data")]
public SpotPointDataResponse SpotPointData { get; set; } = new();
[JsonPropertyName("puzzle_list")] [Key("puzzle_list")]
public List<PuzzleGroupResponse> PuzzleList { get; set; } = new();
}
[MessagePackObject]
public class AchievedInfoResponse
{
[JsonPropertyName("achieved_mission_list")] [Key("achieved_mission_list")]
public List<PuzzleAchievedMissionEntry> AchievedMissionList { get; set; } = new();
[JsonPropertyName("achieved_mission_reward_list")] [Key("achieved_mission_reward_list")]
public List<PuzzleAchievedMissionReward> AchievedMissionRewardList { get; set; } = new();
[JsonPropertyName("mission_start_data")] [Key("mission_start_data")]
public List<MissionStartEntry> MissionStartData { get; set; } = new();
[JsonPropertyName("battle_pass_reward_list")] [Key("battle_pass_reward_list")]
public List<object> BattlePassRewardList { get; set; } = new();
[JsonPropertyName("battle_pass_message_list")] [Key("battle_pass_message_list")]
public List<object> BattlePassMessageList { get; set; } = new();
}
[MessagePackObject]
public class PuzzleAchievedMissionEntry
{
[JsonPropertyName("achieved_message")] [Key("achieved_message")]
public string AchievedMessage { get; set; } = string.Empty;
}
[MessagePackObject]
public class PuzzleAchievedMissionReward
{
[JsonPropertyName("mission_reward_type")] [JsonConverter(typeof(StringifiedIntConverter))] [Key("mission_reward_type")]
public int MissionRewardType { get; set; }
[JsonPropertyName("mission_reward_detail_id")] [JsonConverter(typeof(StringifiedLongConverter))] [Key("mission_reward_detail_id")]
public long MissionRewardDetailId { get; set; }
[JsonPropertyName("mission_reward_number")] [JsonConverter(typeof(StringifiedIntConverter))] [Key("mission_reward_number")]
public int MissionRewardNumber { get; set; }
}
[MessagePackObject]
public class MissionStartEntry
{
[JsonPropertyName("mission_name")] [Key("mission_name")]
public string MissionName { get; set; } = string.Empty;
[JsonPropertyName("start_time")] [Key("start_time")]
public long StartTime { get; set; }
[JsonPropertyName("lot_type")] [Key("lot_type")]
public string LotType { get; set; } = "3"; // Phase 1 only emits puzzle-mission lot_type
}
[MessagePackObject]
public class TreasureRewardResponse
{
[JsonPropertyName("reward_type")] [JsonConverter(typeof(StringifiedIntConverter))] [Key("reward_type")]
public int RewardType { get; set; }
[JsonPropertyName("reward_id")] [JsonConverter(typeof(StringifiedLongConverter))] [Key("reward_id")]
public long RewardId { get; set; }
[JsonPropertyName("reward_num")] [JsonConverter(typeof(StringifiedIntConverter))] [Key("reward_num")]
public int RewardNum { get; set; }
}
[MessagePackObject]
public class ClearMissionListResponse
{
[JsonPropertyName("common_mission")] [Key("common_mission")]
public List<object> CommonMission { get; set; } = new();
[JsonPropertyName("character_mission")] [Key("character_mission")]
public List<object> CharacterMission { get; set; } = new();
}
[MessagePackObject]
public class SpotPointDataResponse
{
[JsonPropertyName("before_spot_point")] [Key("before_spot_point")] public int BeforeSpotPoint { get; set; }
[JsonPropertyName("add_spot_point")] [Key("add_spot_point")] public int AddSpotPoint { get; set; }
[JsonPropertyName("after_spot_point")] [Key("after_spot_point")] public int AfterSpotPoint { get; set; }
}

View File

@@ -0,0 +1,25 @@
using System.Text.Json.Serialization;
using MessagePack;
using SVSim.EmulatedEntrypoint.Models.Dtos.Common;
using SVSim.EmulatedEntrypoint.Models.Dtos.Common.BasicPuzzle;
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Responses.BasicPuzzle;
[MessagePackObject]
public class OpenPuzzleDialogResponse
{
[JsonPropertyName("puzzle_quest")] [Key("puzzle_quest")]
public List<PuzzleEntryResponse> PuzzleQuest { get; set; } = new();
[JsonPropertyName("puzzle_quest_chara_id")] [JsonConverter(typeof(StringifiedIntConverter))] [Key("puzzle_quest_chara_id")]
public int PuzzleQuestCharaId { get; set; }
[JsonPropertyName("puzzle_difficulty_name_list")] [Key("puzzle_difficulty_name_list")]
public Dictionary<string, string> PuzzleDifficultyNameList { get; set; } = new();
[JsonPropertyName("is_display_badge")] [Key("is_display_badge")]
public bool IsDisplayBadge { get; set; } = false;
[JsonPropertyName("is_display_puzzle_new")] [Key("is_display_puzzle_new")]
public bool IsDisplayPuzzleNew { get; set; } = false;
}

View File

@@ -0,0 +1,43 @@
using System.Text.Json.Serialization;
using MessagePack;
using SVSim.EmulatedEntrypoint.Models.Dtos.Common;
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Responses.BasicPuzzle;
[MessagePackObject]
public class PuzzleMissionResponse
{
[JsonPropertyName("mission_name")] [Key("mission_name")]
public string MissionName { get; set; } = string.Empty;
[JsonPropertyName("require_number")] [JsonConverter(typeof(StringifiedIntConverter))] [Key("require_number")]
public int RequireNumber { get; set; }
[JsonPropertyName("campaign_commence_time")] [Key("campaign_commence_time")]
public long CampaignCommenceTime { get; set; }
[JsonPropertyName("reward_list")] [Key("reward_list")]
public List<PuzzleMissionRewardResponse> RewardList { get; set; } = new();
[JsonPropertyName("order_id")] [JsonConverter(typeof(StringifiedIntConverter))] [Key("order_id")]
public int OrderId { get; set; }
[JsonPropertyName("total_count")] [JsonConverter(typeof(StringifiedIntConverter))] [Key("total_count")]
public int TotalCount { get; set; }
[JsonPropertyName("is_achieved")] [Key("is_achieved")]
public bool IsAchieved { get; set; }
}
[MessagePackObject]
public class PuzzleMissionRewardResponse
{
[JsonPropertyName("reward_type")] [JsonConverter(typeof(StringifiedIntConverter))] [Key("reward_type")]
public int RewardType { get; set; }
[JsonPropertyName("reward_detail_id")] [JsonConverter(typeof(StringifiedLongConverter))] [Key("reward_detail_id")]
public long RewardDetailId { get; set; }
[JsonPropertyName("reward_number")] [JsonConverter(typeof(StringifiedIntConverter))] [Key("reward_number")]
public int RewardNumber { get; set; }
}

View File

@@ -222,7 +222,7 @@ public class MyPageIndexResponse
[JsonPropertyName("basic_puzzle")]
[Key("basic_puzzle")]
public BasicPuzzle BasicPuzzle { get; set; } = new();
public BasicPuzzleBadge BasicPuzzle { get; set; } = new();
// ── Battle Pass period flag ────────────────────────────────────────────

View File

@@ -56,6 +56,16 @@ public class UserDeck
[Key("create_deck_time")]
public DateTime? DeckCreateTime { get; set; }
/// <summary>
/// MyRotation period id. Emitted only for Format.MyRotation decks; the client's
/// DeckData.Initialize reads it via GetValueOrDefault("rotation_id", null) and resolves
/// against Data.MyRotationAllInfo. A MyRotation deck without this field crashes the
/// deck-detail dialog inside DeckData.CreateMyRotationClassName (info.LastPackText on null).
/// </summary>
[JsonPropertyName("rotation_id")]
[Key("rotation_id")]
public string? RotationId { get; set; }
/// <summary>
/// Empty placeholder matching the wire shape prod uses to pad deck-list responses up to the
/// per-format cap. The client's <c>DeckUI.DeckViewData.CreateDeckViewList</c> converts the
@@ -93,6 +103,7 @@ public class UserDeck
this.IsRandomLeaderSkin = deck.RandomLeaderSkin ? 1 : 0;
this.Order = deck.Number;
this.DeckCreateTime = deck.DateCreated;
this.RotationId = deck.MyRotationId;
//TODO probably want to calc some of these on demand
this.IsCompleteDeck = 1;

View File

@@ -8,6 +8,7 @@ using SVSim.Database.Repositories.Deck;
using SVSim.Database.Repositories.Globals;
using SVSim.Database.Repositories.Pack;
using SVSim.Database.Repositories.Viewer;
using SVSim.Database.Services;
using SVSim.EmulatedEntrypoint.Configuration;
using SVSim.EmulatedEntrypoint.Extensions;
using SVSim.EmulatedEntrypoint.Middlewares;
@@ -58,10 +59,12 @@ public class Program
opt.UseNpgsql(builder.Configuration.GetConnectionString("ApplicationDb"));
});
builder.Services.AddTransient<IViewerRepository, ViewerRepository>();
builder.Services.AddTransient<IPuzzleClearRepository, PuzzleClearRepository>();
builder.Services.AddTransient<ICardRepository, CardRepository>();
builder.Services.AddTransient<ICardInventoryRepository, CardInventoryRepository>();
builder.Services.AddTransient<ICollectionRepository, CollectionRepository>();
builder.Services.AddTransient<IGlobalsRepository, GlobalsRepository>();
builder.Services.AddTransient<IPuzzleCatalogRepository, PuzzleCatalogRepository>();
builder.Services.AddTransient<IDeckRepository, DeckRepository>();
builder.Services.AddTransient<IPackRepository, PackRepository>();
// Scoped (not Singleton) to avoid the singleton-depends-on-scoped-DbContext lifecycle
@@ -71,7 +74,9 @@ public class Program
builder.Services.AddScoped<ICardPoolProvider, DbCardPoolProvider>();
builder.Services.AddScoped<PackOpenService>();
builder.Services.AddScoped<ICardAcquisitionService, CardAcquisitionService>();
builder.Services.AddScoped<RewardGrantService>();
builder.Services.AddSingleton<IRandom, SystemRandom>();
builder.Services.AddSingleton<PuzzleMissionEvaluator>();
#endregion

View File

@@ -0,0 +1,53 @@
using SVSim.Database.Models;
namespace SVSim.EmulatedEntrypoint.Services;
/// <summary>
/// Pure service — maps the puzzle mission catalog against a viewer's cleared-puzzle set and
/// produces per-mission (total_count, is_achieved) statuses. Used by both /basic_puzzle/mission
/// (snapshot) and /basic_puzzle/finish (post-clear delta detection).
/// </summary>
public sealed class PuzzleMissionEvaluator
{
public sealed record MissionStatus(PuzzleMissionEntry Mission, int TotalCount, bool IsAchieved);
public IReadOnlyList<MissionStatus> Evaluate(
IEnumerable<PuzzleMissionEntry> catalog,
IReadOnlyDictionary<int, HashSet<int>> clearedByGroup)
{
var result = new List<MissionStatus>();
foreach (var mission in catalog)
{
int count = ComputeTotalCount(mission, clearedByGroup);
result.Add(new MissionStatus(mission, count, count >= mission.RequireNumber));
}
return result;
}
/// <summary>Returns ONLY the missions whose status flipped from not-achieved to achieved
/// between before and after. Other missions (already-achieved, still-incomplete) are omitted.</summary>
public IReadOnlyList<MissionStatus> FreshlyCompleted(
IEnumerable<PuzzleMissionEntry> catalog,
IReadOnlyDictionary<int, HashSet<int>> clearedByGroupBefore,
IReadOnlyDictionary<int, HashSet<int>> clearedByGroupAfter)
{
var result = new List<MissionStatus>();
foreach (var mission in catalog)
{
int before = ComputeTotalCount(mission, clearedByGroupBefore);
int after = ComputeTotalCount(mission, clearedByGroupAfter);
bool wasAchieved = before >= mission.RequireNumber;
bool isAchieved = after >= mission.RequireNumber;
if (!wasAchieved && isAchieved)
result.Add(new MissionStatus(mission, after, true));
}
return result;
}
private static int ComputeTotalCount(PuzzleMissionEntry mission, IReadOnlyDictionary<int, HashSet<int>> clearedByGroup)
{
if (mission.TargetPuzzleGroupId is not int groupId) return 0;
if (!clearedByGroup.TryGetValue(groupId, out var cleared)) return 0;
return Math.Min(cleared.Count, mission.RequireNumber);
}
}