Puzzles
This commit is contained in:
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
292
SVSim.EmulatedEntrypoint/Controllers/PuzzleController.cs
Normal file
292
SVSim.EmulatedEntrypoint/Controllers/PuzzleController.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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")]
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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 ────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
53
SVSim.EmulatedEntrypoint/Services/PuzzleMissionEvaluator.cs
Normal file
53
SVSim.EmulatedEntrypoint/Services/PuzzleMissionEvaluator.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user