Puzzles
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
|||||||
|
{"data_headers":{"sid":"079f239bb83de281ebc6b2f68dbb2cd11779683743","short_udid":411054851,"viewer_id":906243102,"servertime":1779683743,"result_code":1},"data":[{"mission_name":"Clear all Dragoncraft and Portalcraft puzzles puzzles in the Special Round","require_number":"2","campaign_commence_time":1725670800,"reward_list":[{"reward_type":"4","reward_detail_id":"90001","reward_number":"1"}],"order_id":"5","total_count":"0","is_achieved":false},{"mission_name":"Clear all Forestcraft, Shadowcraft and Bloodcraft puzzles in the Special Round","require_number":"3","campaign_commence_time":1722646800,"reward_list":[{"reward_type":"4","reward_detail_id":"90001","reward_number":"1"}],"order_id":"4","total_count":"0","is_achieved":false},{"mission_name":"Clear all Swordcraft, Runecraft and Havencraft puzzles in the Special Round","require_number":"3","campaign_commence_time":1720227600,"reward_list":[{"reward_type":"4","reward_detail_id":"90001","reward_number":"1"}],"order_id":"3","total_count":"0","is_achieved":false},{"mission_name":"Clear all Special Round puzzles","require_number":"8","campaign_commence_time":1720227600,"reward_list":[{"reward_type":"7","reward_detail_id":"400004315","reward_number":"1"}],"order_id":"2","total_count":"0","is_achieved":false},{"mission_name":"Clear all Round 15 puzzles","require_number":"3","campaign_commence_time":1716598800,"reward_list":[{"reward_type":"7","reward_detail_id":"400004314","reward_number":"1"}],"order_id":"1","total_count":"0","is_achieved":false},{"mission_name":"Clear all Round 14 puzzles","require_number":"3","campaign_commence_time":1711760400,"reward_list":[{"reward_type":"6","reward_detail_id":"3065004","reward_number":"1"}],"order_id":"1","total_count":"0","is_achieved":false},{"mission_name":"Clear all Round 13 puzzles","require_number":"3","campaign_commence_time":1708736400,"reward_list":[{"reward_type":"7","reward_detail_id":"400004313","reward_number":"1"}],"order_id":"1","total_count":"0","is_achieved":false},{"mission_name":"Clear all Round 12 puzzles","require_number":"3","campaign_commence_time":1703898000,"reward_list":[{"reward_type":"6","reward_detail_id":"3074009","reward_number":"1"}],"order_id":"1","total_count":"0","is_achieved":false},{"mission_name":"Clear all Round 11 puzzles","require_number":"3","campaign_commence_time":1700269200,"reward_list":[{"reward_type":"6","reward_detail_id":"3074008","reward_number":"1"}],"order_id":"1","total_count":"0","is_achieved":false},{"mission_name":"Clear all Round 10 puzzles","require_number":"3","campaign_commence_time":1692406800,"reward_list":[{"reward_type":"6","reward_detail_id":"3074007","reward_number":"1"}],"order_id":"1","total_count":"0","is_achieved":false},{"mission_name":"Clear all Round 9 puzzles","require_number":"3","campaign_commence_time":1688173200,"reward_list":[{"reward_type":"6","reward_detail_id":"3074006","reward_number":"1"}],"order_id":"1","total_count":"0","is_achieved":false},{"mission_name":"Clear all Round 8 puzzles","require_number":"3","campaign_commence_time":1684544400,"reward_list":[{"reward_type":"6","reward_detail_id":"3074005","reward_number":"1"}],"order_id":"1","total_count":"0","is_achieved":false},{"mission_name":"Clear all Round 7 puzzles","require_number":"3","campaign_commence_time":1677286800,"reward_list":[{"reward_type":"6","reward_detail_id":"3074004","reward_number":"1"}],"order_id":"1","total_count":"0","is_achieved":false},{"mission_name":"Clear all Round 6 puzzles","require_number":"3","campaign_commence_time":1672448400,"reward_list":[{"reward_type":"6","reward_detail_id":"3074003","reward_number":"1"}],"order_id":"1","total_count":"0","is_achieved":false},{"mission_name":"Clear all Round 5 puzzles","require_number":"3","campaign_commence_time":1669424400,"reward_list":[{"reward_type":"6","reward_detail_id":"3074002","reward_number":"1"}],"order_id":"1","total_count":"0","is_achieved":false},{"mission_name":"Clear all Round 4 puzzles","require_number":"3","campaign_commence_time":1660959000,"reward_list":[{"reward_type":"6","reward_detail_id":"3074001","reward_number":"1"}],"order_id":"1","total_count":"0","is_achieved":false},{"mission_name":"Clear all Round 3 puzzles","require_number":"3","campaign_commence_time":1656725400,"reward_list":[{"reward_type":"7","reward_detail_id":"400004105","reward_number":"1"}],"order_id":"1","total_count":"0","is_achieved":false},{"mission_name":"Clear all Round 2 puzzles","require_number":"3","campaign_commence_time":1653096600,"reward_list":[{"reward_type":"7","reward_detail_id":"400004104","reward_number":"1"}],"order_id":"1","total_count":"0","is_achieved":false},{"mission_name":"Clear all Round 1 puzzles","require_number":"3","campaign_commence_time":1651282200,"reward_list":[{"reward_type":"10","reward_detail_id":"3704","reward_number":"1"}],"order_id":"1","total_count":"3","is_achieved":true}]}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using SVSim.Database;
|
using SVSim.Database;
|
||||||
using SVSim.Database.Enums;
|
using SVSim.Database.Enums;
|
||||||
@@ -32,6 +33,8 @@ public class GlobalsImporter
|
|||||||
JsonElement? paymentItemList = LoadCapture(capturesDir, "payment-item-list");
|
JsonElement? paymentItemList = LoadCapture(capturesDir, "payment-item-list");
|
||||||
JsonElement? practiceInfo = LoadCapture(capturesDir, "practice-info");
|
JsonElement? practiceInfo = LoadCapture(capturesDir, "practice-info");
|
||||||
JsonElement? packInfo = LoadCapture(capturesDir, "pack-info");
|
JsonElement? packInfo = LoadCapture(capturesDir, "pack-info");
|
||||||
|
JsonElement? basicPuzzleInfo = LoadCapture(capturesDir, "basic-puzzle-info");
|
||||||
|
JsonElement? basicPuzzleMission = LoadCapture(capturesDir, "basic-puzzle-mission");
|
||||||
|
|
||||||
int total = 0;
|
int total = 0;
|
||||||
|
|
||||||
@@ -83,6 +86,17 @@ public class GlobalsImporter
|
|||||||
total += await ImportPacks(context, packInfo.Value);
|
total += await ImportPacks(context, packInfo.Value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (basicPuzzleInfo.HasValue)
|
||||||
|
{
|
||||||
|
total += await ImportPuzzleGroups(context, basicPuzzleInfo.Value);
|
||||||
|
total += await ImportPuzzles(context, basicPuzzleInfo.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (basicPuzzleMission.HasValue)
|
||||||
|
{
|
||||||
|
total += await ImportPuzzleMissions(context, basicPuzzleMission.Value);
|
||||||
|
}
|
||||||
|
|
||||||
await context.SaveChangesAsync();
|
await context.SaveChangesAsync();
|
||||||
Console.WriteLine($"[GlobalsImporter] Done: {total} total rows changed.");
|
Console.WriteLine($"[GlobalsImporter] Done: {total} total rows changed.");
|
||||||
return total;
|
return total;
|
||||||
@@ -929,6 +943,140 @@ public class GlobalsImporter
|
|||||||
return created + updated;
|
return created + updated;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------- Basic Puzzle Groups + Puzzles ----------
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// /basic_puzzle/info capture is an array of group objects keyed on puzzle_master_id.
|
||||||
|
/// Numeric wire fields come through as strings — GetInt tolerates both. Idempotent upsert
|
||||||
|
/// by puzzle_master_id; rows missing from a partial capture are left intact.
|
||||||
|
/// </summary>
|
||||||
|
private async Task<int> ImportPuzzleGroups(SVSimDbContext context, JsonElement infoData)
|
||||||
|
{
|
||||||
|
if (infoData.ValueKind != JsonValueKind.Array) return 0;
|
||||||
|
|
||||||
|
var existing = await context.PuzzleGroups.ToDictionaryAsync(e => e.Id);
|
||||||
|
int created = 0, updated = 0;
|
||||||
|
|
||||||
|
foreach (var row in infoData.EnumerateArray())
|
||||||
|
{
|
||||||
|
int masterId = GetInt(row, "puzzle_master_id");
|
||||||
|
if (masterId == 0) continue;
|
||||||
|
|
||||||
|
var entry = existing.TryGetValue(masterId, out var ex) ? ex : new PuzzleGroupEntry { Id = masterId };
|
||||||
|
entry.BasicTitleTextId = GetString(row, "basic_title_text_id");
|
||||||
|
entry.PuzzleCharaId = GetInt(row, "puzzle_chara_id");
|
||||||
|
entry.CharaId = GetInt(row, "chara_id");
|
||||||
|
entry.SortType = GetInt(row, "sort_type");
|
||||||
|
entry.DifficultyNameListJson = row.TryGetProperty("puzzle_difficulty_name_list", out var d)
|
||||||
|
? Serialize(d)
|
||||||
|
: "{}";
|
||||||
|
|
||||||
|
if (ex is null) { context.PuzzleGroups.Add(entry); created++; }
|
||||||
|
else updated++;
|
||||||
|
}
|
||||||
|
|
||||||
|
Console.WriteLine($"[GlobalsImporter] PuzzleGroups: +{created}/~{updated}");
|
||||||
|
return created + updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Walks each group's puzzle_data array and upserts PuzzleEntry rows keyed on puzzle_id.
|
||||||
|
/// Groups must have been imported first (FK PuzzleEntry.GroupId → PuzzleGroupEntry.Id).
|
||||||
|
/// </summary>
|
||||||
|
private async Task<int> ImportPuzzles(SVSimDbContext context, JsonElement infoData)
|
||||||
|
{
|
||||||
|
if (infoData.ValueKind != JsonValueKind.Array) return 0;
|
||||||
|
|
||||||
|
var existing = await context.Puzzles.ToDictionaryAsync(e => e.Id);
|
||||||
|
int created = 0, updated = 0;
|
||||||
|
|
||||||
|
foreach (var group in infoData.EnumerateArray())
|
||||||
|
{
|
||||||
|
int masterId = GetInt(group, "puzzle_master_id");
|
||||||
|
if (masterId == 0 || !group.TryGetProperty("puzzle_data", out var puzzleArray)) continue;
|
||||||
|
if (puzzleArray.ValueKind != JsonValueKind.Array) continue;
|
||||||
|
|
||||||
|
foreach (var p in puzzleArray.EnumerateArray())
|
||||||
|
{
|
||||||
|
int puzzleId = GetInt(p, "puzzle_id");
|
||||||
|
if (puzzleId == 0) continue;
|
||||||
|
|
||||||
|
var entry = existing.TryGetValue(puzzleId, out var ex) ? ex : new PuzzleEntry { Id = puzzleId };
|
||||||
|
entry.GroupId = masterId;
|
||||||
|
entry.PuzzleDifficulty = GetInt(p, "puzzle_difficulty");
|
||||||
|
entry.IsAdditional = GetBool(p, "is_additional");
|
||||||
|
entry.IsPlayable = GetBool(p, "is_playable");
|
||||||
|
entry.ReleaseConditionTextId = GetString(p, "release_condition_text_id");
|
||||||
|
|
||||||
|
if (ex is null) { context.Puzzles.Add(entry); created++; }
|
||||||
|
else updated++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Console.WriteLine($"[GlobalsImporter] Puzzles: +{created}/~{updated}");
|
||||||
|
return created + updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Basic Puzzle Missions ----------
|
||||||
|
|
||||||
|
private static readonly Regex RoundMissionPattern =
|
||||||
|
new(@"^Clear all Round (\d+) puzzles$", RegexOptions.Compiled);
|
||||||
|
|
||||||
|
/// <summary>Maps the captured mission_name to its target puzzle_master_id. Returns null for
|
||||||
|
/// Special-Round entries — Phase 1 surfaces them with total_count=0 (see design § Out of Scope).</summary>
|
||||||
|
internal static int? DeriveTargetPuzzleGroupId(string missionName)
|
||||||
|
{
|
||||||
|
var m = RoundMissionPattern.Match(missionName);
|
||||||
|
return m.Success ? 300 + int.Parse(m.Groups[1].Value) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<int> ImportPuzzleMissions(SVSimDbContext context, JsonElement missionData)
|
||||||
|
{
|
||||||
|
if (missionData.ValueKind != JsonValueKind.Array) return 0;
|
||||||
|
|
||||||
|
// Key by 1-based sequence (the wire has no stable id); first run inserts, re-runs match by index.
|
||||||
|
var existing = await context.PuzzleMissions.ToDictionaryAsync(e => e.Id);
|
||||||
|
int created = 0, updated = 0, unmapped = 0;
|
||||||
|
|
||||||
|
int seq = 1;
|
||||||
|
foreach (var row in missionData.EnumerateArray())
|
||||||
|
{
|
||||||
|
string name = GetString(row, "mission_name");
|
||||||
|
if (string.IsNullOrEmpty(name)) { seq++; continue; }
|
||||||
|
|
||||||
|
var entry = existing.TryGetValue(seq, out var ex) ? ex : new PuzzleMissionEntry { Id = seq };
|
||||||
|
entry.MissionName = name;
|
||||||
|
entry.AchievedMessage = RoundMissionPattern.IsMatch(name)
|
||||||
|
? RoundMissionPattern.Replace(name, m => $"Cleared all Round {m.Groups[1].Value} puzzles")
|
||||||
|
: "Mission achieved"; // Special-Round fallback; only surfaces if a Special mission ever flips, which won't in Phase 1.
|
||||||
|
entry.RequireNumber = GetInt(row, "require_number");
|
||||||
|
entry.CampaignCommenceTime = GetLong(row, "campaign_commence_time");
|
||||||
|
entry.OrderId = GetInt(row, "order_id");
|
||||||
|
|
||||||
|
// reward_list[0] — single reward per mission. Skip if missing/empty.
|
||||||
|
if (row.TryGetProperty("reward_list", out var rl) && rl.ValueKind == JsonValueKind.Array && rl.GetArrayLength() > 0)
|
||||||
|
{
|
||||||
|
var r = rl[0];
|
||||||
|
entry.RewardType = GetInt(r, "reward_type");
|
||||||
|
entry.RewardDetailId = GetLong(r, "reward_detail_id");
|
||||||
|
entry.RewardNumber = GetInt(r, "reward_number");
|
||||||
|
}
|
||||||
|
|
||||||
|
entry.TargetPuzzleGroupId = DeriveTargetPuzzleGroupId(name);
|
||||||
|
if (entry.TargetPuzzleGroupId is null) unmapped++;
|
||||||
|
|
||||||
|
if (ex is null) { context.PuzzleMissions.Add(entry); created++; }
|
||||||
|
else updated++;
|
||||||
|
|
||||||
|
seq++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (unmapped > 0)
|
||||||
|
Console.WriteLine($"[GlobalsImporter] PuzzleMissions: {unmapped} Special-Round missions left unmapped (Phase 1 deferral).");
|
||||||
|
Console.WriteLine($"[GlobalsImporter] PuzzleMissions: +{created}/~{updated}");
|
||||||
|
return created + updated;
|
||||||
|
}
|
||||||
|
|
||||||
// ---------- Helpers ----------
|
// ---------- Helpers ----------
|
||||||
|
|
||||||
private static void WarnOrphans(string label, int count)
|
private static void WarnOrphans(string label, int count)
|
||||||
|
|||||||
26
SVSim.Database/Enums/UserGoodsType.cs
Normal file
26
SVSim.Database/Enums/UserGoodsType.cs
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
namespace SVSim.Database.Enums;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Mirrors the client's <c>Wizard.UserGoods.Type</c> enum (Shadowverse_Code/Wizard/UserGoods.cs).
|
||||||
|
/// These integers travel on the wire as <c>reward_type</c> on <c>reward_list</c> entries; the
|
||||||
|
/// client uses them in <c>PlayerStaticData.UpdateHaveUserGoodsNumByJsonData</c> to route the
|
||||||
|
/// grant into the right collection / currency total.
|
||||||
|
/// </summary>
|
||||||
|
public enum UserGoodsType
|
||||||
|
{
|
||||||
|
RedEther = 1,
|
||||||
|
Crystal = 2,
|
||||||
|
// 3 is unused / placeholder in the client enum.
|
||||||
|
Item = 4,
|
||||||
|
Card = 5,
|
||||||
|
Sleeve = 6,
|
||||||
|
Emblem = 7,
|
||||||
|
Degree = 8,
|
||||||
|
Rupy = 9,
|
||||||
|
Skin = 10, // LeaderSkin in our schema
|
||||||
|
SpotCard = 11,
|
||||||
|
SpotCardPoint = 12,
|
||||||
|
SpotCardOnlyLatestCardPack = 13,
|
||||||
|
FreeGachaCount = 14,
|
||||||
|
MyPageBG = 15,
|
||||||
|
}
|
||||||
2164
SVSim.Database/Migrations/20260525055824_AddBasicPuzzle.Designer.cs
generated
Normal file
2164
SVSim.Database/Migrations/20260525055824_AddBasicPuzzle.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
116
SVSim.Database/Migrations/20260525055824_AddBasicPuzzle.cs
Normal file
116
SVSim.Database/Migrations/20260525055824_AddBasicPuzzle.cs
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace SVSim.Database.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddBasicPuzzle : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "PuzzleGroups",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
PuzzleMasterId = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
BasicTitleTextId = table.Column<string>(type: "text", nullable: false),
|
||||||
|
PuzzleCharaId = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
CharaId = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
SortType = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
DifficultyNameListJson = table.Column<string>(type: "text", nullable: false),
|
||||||
|
DateCreated = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||||
|
DateUpdated = table.Column<DateTime>(type: "timestamp with time zone", nullable: true)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_PuzzleGroups", x => x.Id);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "PuzzleMissions",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
MissionName = table.Column<string>(type: "text", nullable: false),
|
||||||
|
AchievedMessage = table.Column<string>(type: "text", nullable: false),
|
||||||
|
RequireNumber = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
CampaignCommenceTime = table.Column<long>(type: "bigint", nullable: false),
|
||||||
|
OrderId = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
RewardType = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
RewardDetailId = table.Column<long>(type: "bigint", nullable: false),
|
||||||
|
RewardNumber = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
TargetPuzzleGroupId = table.Column<int>(type: "integer", nullable: true),
|
||||||
|
DateCreated = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||||
|
DateUpdated = table.Column<DateTime>(type: "timestamp with time zone", nullable: true)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_PuzzleMissions", x => x.Id);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "ViewerPuzzleClears",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
ViewerId = table.Column<long>(type: "bigint", nullable: false),
|
||||||
|
PuzzleId = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
ClearedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||||
|
BestRetryCount = table.Column<int>(type: "integer", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_ViewerPuzzleClears", x => new { x.ViewerId, x.PuzzleId });
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "Puzzles",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
PuzzleId = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
GroupId = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
PuzzleDifficulty = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
IsAdditional = table.Column<bool>(type: "boolean", nullable: false),
|
||||||
|
IsPlayable = table.Column<bool>(type: "boolean", nullable: false),
|
||||||
|
ReleaseConditionTextId = table.Column<string>(type: "text", nullable: false),
|
||||||
|
DateCreated = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||||
|
DateUpdated = table.Column<DateTime>(type: "timestamp with time zone", nullable: true)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_Puzzles", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_Puzzles_PuzzleGroups_GroupId",
|
||||||
|
column: x => x.GroupId,
|
||||||
|
principalTable: "PuzzleGroups",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Puzzles_GroupId",
|
||||||
|
table: "Puzzles",
|
||||||
|
column: "GroupId");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "PuzzleMissions");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "Puzzles");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "ViewerPuzzleClears");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "PuzzleGroups");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
2167
SVSim.Database/Migrations/20260525143340_AddDeckMyRotationId.Designer.cs
generated
Normal file
2167
SVSim.Database/Migrations/20260525143340_AddDeckMyRotationId.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,28 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace SVSim.Database.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddDeckMyRotationId : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "MyRotationId",
|
||||||
|
table: "Decks",
|
||||||
|
type: "text",
|
||||||
|
nullable: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "MyRotationId",
|
||||||
|
table: "Decks");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -976,6 +976,124 @@ namespace SVSim.Database.Migrations
|
|||||||
b.ToTable("PreReleaseInfos");
|
b.ToTable("PreReleaseInfos");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("SVSim.Database.Models.PuzzleEntry", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<DateTime>("DateCreated")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("DateUpdated")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<int>("GroupId")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<bool>("IsAdditional")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<bool>("IsPlayable")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<int>("PuzzleDifficulty")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int>("PuzzleId")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<string>("ReleaseConditionTextId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("GroupId");
|
||||||
|
|
||||||
|
b.ToTable("Puzzles");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("SVSim.Database.Models.PuzzleGroupEntry", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<string>("BasicTitleTextId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<int>("CharaId")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<DateTime>("DateCreated")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("DateUpdated")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("DifficultyNameListJson")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<int>("PuzzleCharaId")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int>("PuzzleMasterId")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int>("SortType")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.ToTable("PuzzleGroups");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("SVSim.Database.Models.PuzzleMissionEntry", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<string>("AchievedMessage")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<long>("CampaignCommenceTime")
|
||||||
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
|
b.Property<DateTime>("DateCreated")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("DateUpdated")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("MissionName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<int>("OrderId")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int>("RequireNumber")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<long>("RewardDetailId")
|
||||||
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
|
b.Property<int>("RewardNumber")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int>("RewardType")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int?>("TargetPuzzleGroupId")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.ToTable("PuzzleMissions");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("SVSim.Database.Models.RankInfoEntry", b =>
|
modelBuilder.Entity("SVSim.Database.Models.RankInfoEntry", b =>
|
||||||
{
|
{
|
||||||
b.Property<int>("Id")
|
b.Property<int>("Id")
|
||||||
@@ -1200,6 +1318,9 @@ namespace SVSim.Database.Migrations
|
|||||||
b.Property<int>("LeaderSkinId")
|
b.Property<int>("LeaderSkinId")
|
||||||
.HasColumnType("integer");
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<string>("MyRotationId")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
b.Property<string>("Name")
|
b.Property<string>("Name")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasColumnType("text");
|
.HasColumnType("text");
|
||||||
@@ -1347,6 +1468,25 @@ namespace SVSim.Database.Migrations
|
|||||||
b.ToTable("Viewers");
|
b.ToTable("Viewers");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("SVSim.Database.Models.ViewerPuzzleClear", b =>
|
||||||
|
{
|
||||||
|
b.Property<long>("ViewerId")
|
||||||
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
|
b.Property<int>("PuzzleId")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int>("BestRetryCount")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<DateTime>("ClearedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.HasKey("ViewerId", "PuzzleId");
|
||||||
|
|
||||||
|
b.ToTable("ViewerPuzzleClears");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("SleeveEntryViewer", b =>
|
modelBuilder.Entity("SleeveEntryViewer", b =>
|
||||||
{
|
{
|
||||||
b.Property<int>("SleevesId")
|
b.Property<int>("SleevesId")
|
||||||
@@ -1546,6 +1686,17 @@ namespace SVSim.Database.Migrations
|
|||||||
b.Navigation("GachaPointConfig");
|
b.Navigation("GachaPointConfig");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("SVSim.Database.Models.PuzzleEntry", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("SVSim.Database.Models.PuzzleGroupEntry", "Group")
|
||||||
|
.WithMany("Puzzles")
|
||||||
|
.HasForeignKey("GroupId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Group");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("SVSim.Database.Models.ShadowverseCardEntry", b =>
|
modelBuilder.Entity("SVSim.Database.Models.ShadowverseCardEntry", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("SVSim.Database.Models.ClassEntry", "Class")
|
b.HasOne("SVSim.Database.Models.ClassEntry", "Class")
|
||||||
@@ -1993,6 +2144,11 @@ namespace SVSim.Database.Migrations
|
|||||||
b.Navigation("LeaderSkins");
|
b.Navigation("LeaderSkins");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("SVSim.Database.Models.PuzzleGroupEntry", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("Puzzles");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("SVSim.Database.Models.ShadowverseCardSetEntry", b =>
|
modelBuilder.Entity("SVSim.Database.Models.ShadowverseCardSetEntry", b =>
|
||||||
{
|
{
|
||||||
b.Navigation("Cards");
|
b.Navigation("Cards");
|
||||||
|
|||||||
25
SVSim.Database/Models/PuzzleEntry.cs
Normal file
25
SVSim.Database/Models/PuzzleEntry.cs
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
using SVSim.Database.Common;
|
||||||
|
|
||||||
|
namespace SVSim.Database.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One row per basic_puzzle within a group. Static catalog seeded by SVSim.Bootstrap.
|
||||||
|
/// See docs/api-spec/endpoints/post-login/basic-puzzle/info.md (PuzzleEntry).
|
||||||
|
/// </summary>
|
||||||
|
public class PuzzleEntry : BaseEntity<int>
|
||||||
|
{
|
||||||
|
/// <summary>puzzle_id on the wire. PK.</summary>
|
||||||
|
public int PuzzleId { get => Id; set => Id = value; }
|
||||||
|
|
||||||
|
/// <summary>FK to <see cref="PuzzleGroupEntry"/>. Index this column for mission evaluation.</summary>
|
||||||
|
public int GroupId { get; set; }
|
||||||
|
|
||||||
|
public PuzzleGroupEntry Group { get; set; } = null!;
|
||||||
|
|
||||||
|
/// <summary>0..3 difficulty band.</summary>
|
||||||
|
public int PuzzleDifficulty { get; set; }
|
||||||
|
|
||||||
|
public bool IsAdditional { get; set; }
|
||||||
|
public bool IsPlayable { get; set; } = true;
|
||||||
|
public string ReleaseConditionTextId { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
32
SVSim.Database/Models/PuzzleGroupEntry.cs
Normal file
32
SVSim.Database/Models/PuzzleGroupEntry.cs
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
using SVSim.Database.Common;
|
||||||
|
|
||||||
|
namespace SVSim.Database.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One row per basic_puzzle group (puzzle_master_id). Static catalog seeded by
|
||||||
|
/// SVSim.Bootstrap.GlobalsImporter from prod-captures/basic-puzzle-info-*.json.
|
||||||
|
/// See docs/api-spec/endpoints/post-login/basic-puzzle/info.md.
|
||||||
|
/// </summary>
|
||||||
|
public class PuzzleGroupEntry : BaseEntity<int>
|
||||||
|
{
|
||||||
|
/// <summary>puzzle_master_id on the wire. PK + display order key.</summary>
|
||||||
|
public int PuzzleMasterId { get => Id; set => Id = value; }
|
||||||
|
|
||||||
|
/// <summary>SystemText id. "Puzzle_QuestSelect_0301" etc. Client resolves with Data.SystemText.Get.</summary>
|
||||||
|
public string BasicTitleTextId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Character id for the group portrait. Wire as string but stored as int.</summary>
|
||||||
|
public int PuzzleCharaId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Mission-attribution chara. Usually == PuzzleCharaId but observed group 2 has 3208/2703 split.</summary>
|
||||||
|
public int CharaId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>1 = Special/Expert rounds, 2 = Regular numbered rounds. Drives client display ordering.</summary>
|
||||||
|
public int SortType { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Difficulty-name dict serialized as JSON (e.g. {"Beginner":"0","Experienced":"1","Expert":"2"}).</summary>
|
||||||
|
public string DifficultyNameListJson { get; set; } = "{}";
|
||||||
|
|
||||||
|
// Navigation
|
||||||
|
public List<PuzzleEntry> Puzzles { get; set; } = new();
|
||||||
|
}
|
||||||
33
SVSim.Database/Models/PuzzleMissionEntry.cs
Normal file
33
SVSim.Database/Models/PuzzleMissionEntry.cs
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
using SVSim.Database.Common;
|
||||||
|
|
||||||
|
namespace SVSim.Database.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One row per basic_puzzle mission (e.g. "Clear all Round 1 puzzles"). Static catalog
|
||||||
|
/// seeded by SVSim.Bootstrap from prod-captures/basic-puzzle-mission-*.json. The wire has no
|
||||||
|
/// stable id; importer assigns 1-based by capture order via the inherited <see cref="BaseEntity{TKey}.Id"/>.
|
||||||
|
/// See docs/api-spec/endpoints/post-login/basic-puzzle/mission.md.
|
||||||
|
/// </summary>
|
||||||
|
public class PuzzleMissionEntry : BaseEntity<int>
|
||||||
|
{
|
||||||
|
/// <summary>Pre-localized name on the wire. "Clear all Round 1 puzzles".</summary>
|
||||||
|
public string MissionName { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Pre-localized achievement banner ("Cleared all Round 1 puzzles"). Derived by importer.</summary>
|
||||||
|
public string AchievedMessage { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public int RequireNumber { get; set; }
|
||||||
|
public long CampaignCommenceTime { get; set; }
|
||||||
|
public int OrderId { get; set; }
|
||||||
|
|
||||||
|
// Reward (single-entry per mission)
|
||||||
|
public int RewardType { get; set; } // UserGoodsType
|
||||||
|
public long RewardDetailId { get; set; }
|
||||||
|
public int RewardNumber { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Maps Round-N missions to their target group (300+N). NULL for Special-Round missions
|
||||||
|
/// (deferred per Phase 1; they always surface as total_count=0).
|
||||||
|
/// </summary>
|
||||||
|
public int? TargetPuzzleGroupId { get; set; }
|
||||||
|
}
|
||||||
@@ -21,6 +21,14 @@ public class ShadowverseDeckEntry : BaseEntity<Guid>
|
|||||||
public Format Format { get; set; }
|
public Format Format { get; set; }
|
||||||
public bool RandomLeaderSkin { get; set; }
|
public bool RandomLeaderSkin { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// MyRotation period id (key into <see cref="MyRotationSettingEntry"/>). Required when
|
||||||
|
/// <see cref="Format"/> is <see cref="Format.MyRotation"/> so the client can resolve the
|
||||||
|
/// deck's pack range; null for every other format. If null on a MyRotation deck, clicking
|
||||||
|
/// the deck NREs inside DeckData.CreateMyRotationClassName (info.LastPackText on null).
|
||||||
|
/// </summary>
|
||||||
|
public string? MyRotationId { get; set; }
|
||||||
|
|
||||||
#region Navigation Properties
|
#region Navigation Properties
|
||||||
|
|
||||||
public ClassEntry Class { get; set; } = new ClassEntry();
|
public ClassEntry Class { get; set; } = new ClassEntry();
|
||||||
|
|||||||
22
SVSim.Database/Models/ViewerPuzzleClear.cs
Normal file
22
SVSim.Database/Models/ViewerPuzzleClear.cs
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using SVSim.Database.Common;
|
||||||
|
|
||||||
|
namespace SVSim.Database.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Per-viewer record of a cleared puzzle. Composite PK (ViewerId, PuzzleId) — at most one
|
||||||
|
/// row per (viewer, puzzle). NOT a Viewer owned collection on purpose (see CLAUDE.md
|
||||||
|
/// "EF nav include pitfall" — owned collection joins cartesian-explode the viewer graph).
|
||||||
|
/// </summary>
|
||||||
|
[PrimaryKey(nameof(ViewerId), nameof(PuzzleId))]
|
||||||
|
public class ViewerPuzzleClear
|
||||||
|
{
|
||||||
|
public long ViewerId { get; set; }
|
||||||
|
public int PuzzleId { get; set; }
|
||||||
|
|
||||||
|
public DateTime ClearedAt { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Min retry_count across all wins. RetryCount = in-battle reset count, not loss retries.</summary>
|
||||||
|
public int BestRetryCount { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
using SVSim.Database.Models;
|
||||||
|
|
||||||
|
namespace SVSim.Database.Repositories.Globals;
|
||||||
|
|
||||||
|
public interface IPuzzleCatalogRepository
|
||||||
|
{
|
||||||
|
Task<List<PuzzleGroupEntry>> GetAllGroupsWithPuzzles();
|
||||||
|
Task<PuzzleGroupEntry?> GetGroupWithPuzzles(int puzzleMasterId);
|
||||||
|
Task<List<PuzzleMissionEntry>> GetAllMissionsOrdered();
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using SVSim.Database.Models;
|
||||||
|
|
||||||
|
namespace SVSim.Database.Repositories.Globals;
|
||||||
|
|
||||||
|
public class PuzzleCatalogRepository : IPuzzleCatalogRepository
|
||||||
|
{
|
||||||
|
private readonly SVSimDbContext _db;
|
||||||
|
public PuzzleCatalogRepository(SVSimDbContext db) => _db = db;
|
||||||
|
|
||||||
|
public Task<List<PuzzleGroupEntry>> GetAllGroupsWithPuzzles() =>
|
||||||
|
_db.PuzzleGroups
|
||||||
|
.Include(g => g.Puzzles)
|
||||||
|
.AsNoTracking()
|
||||||
|
.AsSplitQuery() // avoid the cartesian-explode pitfall (CLAUDE.md)
|
||||||
|
.OrderBy(g => g.Id)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
public Task<PuzzleGroupEntry?> GetGroupWithPuzzles(int puzzleMasterId) =>
|
||||||
|
_db.PuzzleGroups
|
||||||
|
.Include(g => g.Puzzles)
|
||||||
|
.AsNoTracking()
|
||||||
|
.FirstOrDefaultAsync(g => g.Id == puzzleMasterId);
|
||||||
|
|
||||||
|
public Task<List<PuzzleMissionEntry>> GetAllMissionsOrdered() =>
|
||||||
|
_db.PuzzleMissions
|
||||||
|
.AsNoTracking()
|
||||||
|
.OrderBy(m => m.OrderId)
|
||||||
|
.ThenByDescending(m => m.CampaignCommenceTime)
|
||||||
|
.ToListAsync();
|
||||||
|
}
|
||||||
15
SVSim.Database/Repositories/Viewer/IPuzzleClearRepository.cs
Normal file
15
SVSim.Database/Repositories/Viewer/IPuzzleClearRepository.cs
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
namespace SVSim.Database.Repositories.Viewer;
|
||||||
|
|
||||||
|
public interface IPuzzleClearRepository
|
||||||
|
{
|
||||||
|
/// <summary>Returns the set of puzzle_ids this viewer has cleared.</summary>
|
||||||
|
Task<HashSet<int>> GetClearedPuzzleIds(long viewerId);
|
||||||
|
|
||||||
|
/// <summary>Returns cleared puzzle_ids grouped by their PuzzleEntry.GroupId. Only groups
|
||||||
|
/// with at least one clear appear in the dictionary.</summary>
|
||||||
|
Task<Dictionary<int, HashSet<int>>> GetClearedPuzzleIdsByGroup(long viewerId);
|
||||||
|
|
||||||
|
/// <summary>Inserts or updates the (viewer, puzzle) clear row. BestRetryCount keeps the
|
||||||
|
/// minimum retry_count across all wins.</summary>
|
||||||
|
Task UpsertClearAsync(long viewerId, int puzzleId, int retryCount);
|
||||||
|
}
|
||||||
59
SVSim.Database/Repositories/Viewer/PuzzleClearRepository.cs
Normal file
59
SVSim.Database/Repositories/Viewer/PuzzleClearRepository.cs
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using SVSim.Database.Models;
|
||||||
|
|
||||||
|
namespace SVSim.Database.Repositories.Viewer;
|
||||||
|
|
||||||
|
public class PuzzleClearRepository : IPuzzleClearRepository
|
||||||
|
{
|
||||||
|
private readonly SVSimDbContext _db;
|
||||||
|
public PuzzleClearRepository(SVSimDbContext db) => _db = db;
|
||||||
|
|
||||||
|
public async Task<HashSet<int>> GetClearedPuzzleIds(long viewerId)
|
||||||
|
{
|
||||||
|
var ids = await _db.ViewerPuzzleClears
|
||||||
|
.Where(c => c.ViewerId == viewerId)
|
||||||
|
.Select(c => c.PuzzleId)
|
||||||
|
.ToListAsync();
|
||||||
|
return ids.ToHashSet();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Dictionary<int, HashSet<int>>> GetClearedPuzzleIdsByGroup(long viewerId)
|
||||||
|
{
|
||||||
|
// Join via Puzzles to resolve each cleared PuzzleId to its GroupId.
|
||||||
|
var rows = await (
|
||||||
|
from c in _db.ViewerPuzzleClears
|
||||||
|
where c.ViewerId == viewerId
|
||||||
|
join p in _db.Puzzles on c.PuzzleId equals p.Id
|
||||||
|
select new { p.GroupId, c.PuzzleId }
|
||||||
|
).ToListAsync();
|
||||||
|
|
||||||
|
return rows
|
||||||
|
.GroupBy(r => r.GroupId)
|
||||||
|
.ToDictionary(g => g.Key, g => g.Select(r => r.PuzzleId).ToHashSet());
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task UpsertClearAsync(long viewerId, int puzzleId, int retryCount)
|
||||||
|
{
|
||||||
|
// CONCURRENCY: this read-then-write is not isolated. Two simultaneous /finish calls
|
||||||
|
// for the same (viewer, puzzle) could both insert and one will lose to the PK. The
|
||||||
|
// wider mission-completion concurrency note lives on PuzzleController.Finish.
|
||||||
|
var existing = await _db.ViewerPuzzleClears
|
||||||
|
.FirstOrDefaultAsync(c => c.ViewerId == viewerId && c.PuzzleId == puzzleId);
|
||||||
|
|
||||||
|
if (existing is null)
|
||||||
|
{
|
||||||
|
_db.ViewerPuzzleClears.Add(new ViewerPuzzleClear
|
||||||
|
{
|
||||||
|
ViewerId = viewerId,
|
||||||
|
PuzzleId = puzzleId,
|
||||||
|
ClearedAt = DateTime.UtcNow,
|
||||||
|
BestRetryCount = retryCount,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
existing.BestRetryCount = Math.Min(existing.BestRetryCount, retryCount);
|
||||||
|
}
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -62,6 +62,10 @@ public class SVSimDbContext : DbContext
|
|||||||
public DbSet<FeatureMaintenanceEntry> FeatureMaintenances => Set<FeatureMaintenanceEntry>();
|
public DbSet<FeatureMaintenanceEntry> FeatureMaintenances => Set<FeatureMaintenanceEntry>();
|
||||||
public DbSet<PreReleaseInfo> PreReleaseInfos => Set<PreReleaseInfo>();
|
public DbSet<PreReleaseInfo> PreReleaseInfos => Set<PreReleaseInfo>();
|
||||||
public DbSet<PracticeOpponentEntry> PracticeOpponents => Set<PracticeOpponentEntry>();
|
public DbSet<PracticeOpponentEntry> PracticeOpponents => Set<PracticeOpponentEntry>();
|
||||||
|
public DbSet<PuzzleGroupEntry> PuzzleGroups => Set<PuzzleGroupEntry>();
|
||||||
|
public DbSet<PuzzleEntry> Puzzles => Set<PuzzleEntry>();
|
||||||
|
public DbSet<PuzzleMissionEntry> PuzzleMissions => Set<PuzzleMissionEntry>();
|
||||||
|
public DbSet<ViewerPuzzleClear> ViewerPuzzleClears => Set<ViewerPuzzleClear>();
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
|
|||||||
118
SVSim.Database/Services/RewardGrantService.cs
Normal file
118
SVSim.Database/Services/RewardGrantService.cs
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using SVSim.Database.Enums;
|
||||||
|
using SVSim.Database.Models;
|
||||||
|
|
||||||
|
namespace SVSim.Database.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Wire-shape returned by <see cref="RewardGrantService.Apply"/>. Field names match the
|
||||||
|
/// <c>reward_list</c> entries used by <c>/pack/open</c> and <c>/basic_puzzle/finish</c>.
|
||||||
|
/// reward_num is a POST-STATE TOTAL for currencies and a count for collection grants — see
|
||||||
|
/// <see cref="Models.RewardListEntry"/>... see SVSim.EmulatedEntrypoint.Models.Dtos.RewardListEntry
|
||||||
|
/// for the on-the-wire DTO and the rationale.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record GrantedReward(int RewardType, long RewardId, int RewardNum);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// General reward-grant primitive. Switches on <see cref="UserGoodsType"/>, mutates the
|
||||||
|
/// appropriate viewer collection or <see cref="ViewerCurrency"/> field, and returns the
|
||||||
|
/// wire-shape entry the caller should embed in its response's reward_list.
|
||||||
|
///
|
||||||
|
/// Caller is responsible for <c>SaveChangesAsync</c> — this service only mutates the in-memory
|
||||||
|
/// graph so a controller can stack several grants in a single transaction.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class RewardGrantService
|
||||||
|
{
|
||||||
|
private readonly SVSimDbContext _db;
|
||||||
|
public RewardGrantService(SVSimDbContext db) => _db = db;
|
||||||
|
|
||||||
|
public GrantedReward Apply(Viewer viewer, UserGoodsType type, long detailId, int num)
|
||||||
|
{
|
||||||
|
switch (type)
|
||||||
|
{
|
||||||
|
case UserGoodsType.Sleeve:
|
||||||
|
AddCosmeticIfMissing(viewer.Sleeves, detailId, _db.Sleeves);
|
||||||
|
return new GrantedReward((int)type, detailId, 1);
|
||||||
|
|
||||||
|
case UserGoodsType.Emblem:
|
||||||
|
AddCosmeticIfMissing(viewer.Emblems, detailId, _db.Emblems);
|
||||||
|
return new GrantedReward((int)type, detailId, 1);
|
||||||
|
|
||||||
|
case UserGoodsType.Skin: // LeaderSkin in our schema
|
||||||
|
AddCosmeticIfMissing(viewer.LeaderSkins, detailId, _db.LeaderSkins);
|
||||||
|
return new GrantedReward((int)type, detailId, 1);
|
||||||
|
|
||||||
|
case UserGoodsType.Degree:
|
||||||
|
AddCosmeticIfMissing(viewer.Degrees, detailId, _db.Degrees);
|
||||||
|
return new GrantedReward((int)type, detailId, 1);
|
||||||
|
|
||||||
|
case UserGoodsType.MyPageBG:
|
||||||
|
AddCosmeticIfMissing(viewer.MyPageBackgrounds, detailId, _db.MyPageBackgrounds);
|
||||||
|
return new GrantedReward((int)type, detailId, 1);
|
||||||
|
|
||||||
|
case UserGoodsType.Rupy:
|
||||||
|
viewer.Currency.Rupees += (ulong)num;
|
||||||
|
return new GrantedReward((int)type, detailId, checked((int)viewer.Currency.Rupees));
|
||||||
|
|
||||||
|
case UserGoodsType.Crystal:
|
||||||
|
viewer.Currency.Crystals += (ulong)num;
|
||||||
|
return new GrantedReward((int)type, detailId, checked((int)viewer.Currency.Crystals));
|
||||||
|
|
||||||
|
case UserGoodsType.RedEther:
|
||||||
|
viewer.Currency.RedEther += (ulong)num;
|
||||||
|
return new GrantedReward((int)type, detailId, checked((int)viewer.Currency.RedEther));
|
||||||
|
|
||||||
|
case UserGoodsType.Item:
|
||||||
|
{
|
||||||
|
var owned = viewer.Items.FirstOrDefault(i => i.Item.Id == (int)detailId);
|
||||||
|
if (owned is null)
|
||||||
|
{
|
||||||
|
var item = _db.Items.Find((int)detailId)
|
||||||
|
?? throw new InvalidOperationException($"Item {detailId} not in catalog");
|
||||||
|
viewer.Items.Add(new OwnedItemEntry { Item = item, Count = num, Viewer = viewer });
|
||||||
|
return new GrantedReward((int)type, detailId, num);
|
||||||
|
}
|
||||||
|
owned.Count += num;
|
||||||
|
return new GrantedReward((int)type, detailId, owned.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
case UserGoodsType.Card:
|
||||||
|
case UserGoodsType.SpotCard:
|
||||||
|
case UserGoodsType.SpotCardOnlyLatestCardPack:
|
||||||
|
throw new NotSupportedException(
|
||||||
|
$"{type} rewards are out of Phase 1 scope — extend RewardGrantService when /pack/open or similar needs them.");
|
||||||
|
|
||||||
|
default:
|
||||||
|
throw new NotSupportedException($"UserGoodsType {type} not yet handled by RewardGrantService");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AddCosmeticIfMissing<T>(List<T> collection, long detailId, DbSet<T> catalog) where T : class
|
||||||
|
{
|
||||||
|
// Cosmetic ownership is binary — if the viewer already owns it, the grant is a no-op
|
||||||
|
// (matches client UpdateHaveUserGoodsNum behaviour which just calls .Acquired() each time).
|
||||||
|
bool alreadyOwned = collection.Any(e => GetId(e) == detailId);
|
||||||
|
if (alreadyOwned) return;
|
||||||
|
|
||||||
|
// Wire reward_detail_id is long, but every cosmetic catalog in this codebase uses
|
||||||
|
// BaseEntity<int>; downcast for Find. The checked() throws OverflowException if a
|
||||||
|
// future capture ships a real long id rather than silently truncating it.
|
||||||
|
var entity = catalog.Find(checked((int)detailId))
|
||||||
|
?? throw new InvalidOperationException(
|
||||||
|
$"Cosmetic id {detailId} not in catalog for type {typeof(T).Name}");
|
||||||
|
collection.Add(entity);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reflectively reads an entity's Id property — works for both <c>BaseEntity<int></c>
|
||||||
|
/// (cosmetics) and <c>BaseEntity<long></c> (e.g. Viewer/Card) without forcing two
|
||||||
|
/// non-generic overloads of <see cref="AddCosmeticIfMissing"/>.
|
||||||
|
/// </summary>
|
||||||
|
private static long GetId<T>(T e)
|
||||||
|
{
|
||||||
|
var prop = typeof(T).GetProperty("Id")
|
||||||
|
?? throw new InvalidOperationException($"Type {typeof(T).Name} missing Id property");
|
||||||
|
var val = prop.GetValue(e);
|
||||||
|
return val switch { long l => l, int i => i, _ => 0 };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -198,6 +198,16 @@ public class AdminController : SVSimController
|
|||||||
.ToList();
|
.ToList();
|
||||||
var cards = await _dbContext.Cards.Where(c => allCardIds.Contains(c.Id)).ToDictionaryAsync(c => c.Id);
|
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)
|
foreach (var format in SeededDeckFormats)
|
||||||
{
|
{
|
||||||
int slot = 1;
|
int slot = 1;
|
||||||
@@ -224,6 +234,7 @@ public class AdminController : SVSimController
|
|||||||
LeaderSkin = leaderSkin,
|
LeaderSkin = leaderSkin,
|
||||||
RandomLeaderSkin = false,
|
RandomLeaderSkin = false,
|
||||||
Cards = deckCards,
|
Cards = deckCards,
|
||||||
|
MyRotationId = format == Format.MyRotation ? latestMyRotationId : null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -181,6 +181,9 @@ public class DeckController : SVSimController
|
|||||||
if (skin is not null) deck.LeaderSkin = skin;
|
if (skin is not null) deck.LeaderSkin = skin;
|
||||||
deck.RandomLeaderSkin = request.IsRandomLeaderSkin;
|
deck.RandomLeaderSkin = request.IsRandomLeaderSkin;
|
||||||
deck.Cards = cards;
|
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(),
|
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,
|
IsBattlePassPeriod = rotation.IsBattlePassPeriod,
|
||||||
SpecialCrystalInfo = new(), // TODO(mypage-stub): same shape/source as /load/index
|
SpecialCrystalInfo = new(), // TODO(mypage-stub): same shape/source as /load/index
|
||||||
// CompetitionInfo, ShopNotification, StoryNotification, GuildNotification, GatheringInfo,
|
// 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>
|
/// <summary>
|
||||||
/// basic_puzzle.is_display_badge — drives the "practice puzzle" badge on the
|
/// basic_puzzle.is_display_badge — drives the "practice puzzle" badge on the
|
||||||
/// footer. Read by MyPageTask.cs:177.
|
/// 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>
|
/// </summary>
|
||||||
[MessagePackObject]
|
[MessagePackObject]
|
||||||
public class BasicPuzzle
|
public class BasicPuzzleBadge
|
||||||
{
|
{
|
||||||
[JsonPropertyName("is_display_badge")]
|
[JsonPropertyName("is_display_badge")]
|
||||||
[Key("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")]
|
[JsonPropertyName("basic_puzzle")]
|
||||||
[Key("basic_puzzle")]
|
[Key("basic_puzzle")]
|
||||||
public BasicPuzzle BasicPuzzle { get; set; } = new();
|
public BasicPuzzleBadge BasicPuzzle { get; set; } = new();
|
||||||
|
|
||||||
// ── Battle Pass period flag ────────────────────────────────────────────
|
// ── Battle Pass period flag ────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
@@ -56,6 +56,16 @@ public class UserDeck
|
|||||||
[Key("create_deck_time")]
|
[Key("create_deck_time")]
|
||||||
public DateTime? DeckCreateTime { get; set; }
|
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>
|
/// <summary>
|
||||||
/// Empty placeholder matching the wire shape prod uses to pad deck-list responses up to the
|
/// 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
|
/// 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.IsRandomLeaderSkin = deck.RandomLeaderSkin ? 1 : 0;
|
||||||
this.Order = deck.Number;
|
this.Order = deck.Number;
|
||||||
this.DeckCreateTime = deck.DateCreated;
|
this.DeckCreateTime = deck.DateCreated;
|
||||||
|
this.RotationId = deck.MyRotationId;
|
||||||
|
|
||||||
//TODO probably want to calc some of these on demand
|
//TODO probably want to calc some of these on demand
|
||||||
this.IsCompleteDeck = 1;
|
this.IsCompleteDeck = 1;
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ using SVSim.Database.Repositories.Deck;
|
|||||||
using SVSim.Database.Repositories.Globals;
|
using SVSim.Database.Repositories.Globals;
|
||||||
using SVSim.Database.Repositories.Pack;
|
using SVSim.Database.Repositories.Pack;
|
||||||
using SVSim.Database.Repositories.Viewer;
|
using SVSim.Database.Repositories.Viewer;
|
||||||
|
using SVSim.Database.Services;
|
||||||
using SVSim.EmulatedEntrypoint.Configuration;
|
using SVSim.EmulatedEntrypoint.Configuration;
|
||||||
using SVSim.EmulatedEntrypoint.Extensions;
|
using SVSim.EmulatedEntrypoint.Extensions;
|
||||||
using SVSim.EmulatedEntrypoint.Middlewares;
|
using SVSim.EmulatedEntrypoint.Middlewares;
|
||||||
@@ -58,10 +59,12 @@ public class Program
|
|||||||
opt.UseNpgsql(builder.Configuration.GetConnectionString("ApplicationDb"));
|
opt.UseNpgsql(builder.Configuration.GetConnectionString("ApplicationDb"));
|
||||||
});
|
});
|
||||||
builder.Services.AddTransient<IViewerRepository, ViewerRepository>();
|
builder.Services.AddTransient<IViewerRepository, ViewerRepository>();
|
||||||
|
builder.Services.AddTransient<IPuzzleClearRepository, PuzzleClearRepository>();
|
||||||
builder.Services.AddTransient<ICardRepository, CardRepository>();
|
builder.Services.AddTransient<ICardRepository, CardRepository>();
|
||||||
builder.Services.AddTransient<ICardInventoryRepository, CardInventoryRepository>();
|
builder.Services.AddTransient<ICardInventoryRepository, CardInventoryRepository>();
|
||||||
builder.Services.AddTransient<ICollectionRepository, CollectionRepository>();
|
builder.Services.AddTransient<ICollectionRepository, CollectionRepository>();
|
||||||
builder.Services.AddTransient<IGlobalsRepository, GlobalsRepository>();
|
builder.Services.AddTransient<IGlobalsRepository, GlobalsRepository>();
|
||||||
|
builder.Services.AddTransient<IPuzzleCatalogRepository, PuzzleCatalogRepository>();
|
||||||
builder.Services.AddTransient<IDeckRepository, DeckRepository>();
|
builder.Services.AddTransient<IDeckRepository, DeckRepository>();
|
||||||
builder.Services.AddTransient<IPackRepository, PackRepository>();
|
builder.Services.AddTransient<IPackRepository, PackRepository>();
|
||||||
// Scoped (not Singleton) to avoid the singleton-depends-on-scoped-DbContext lifecycle
|
// 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<ICardPoolProvider, DbCardPoolProvider>();
|
||||||
builder.Services.AddScoped<PackOpenService>();
|
builder.Services.AddScoped<PackOpenService>();
|
||||||
builder.Services.AddScoped<ICardAcquisitionService, CardAcquisitionService>();
|
builder.Services.AddScoped<ICardAcquisitionService, CardAcquisitionService>();
|
||||||
|
builder.Services.AddScoped<RewardGrantService>();
|
||||||
builder.Services.AddSingleton<IRandom, SystemRandom>();
|
builder.Services.AddSingleton<IRandom, SystemRandom>();
|
||||||
|
builder.Services.AddSingleton<PuzzleMissionEvaluator>();
|
||||||
|
|
||||||
#endregion
|
#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);
|
||||||
|
}
|
||||||
|
}
|
||||||
290
SVSim.UnitTests/Controllers/PuzzleControllerTests.cs
Normal file
290
SVSim.UnitTests/Controllers/PuzzleControllerTests.cs
Normal file
@@ -0,0 +1,290 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using SVSim.Database;
|
||||||
|
using SVSim.Database.Models;
|
||||||
|
using SVSim.Database.Repositories.Viewer;
|
||||||
|
using SVSim.UnitTests.Infrastructure;
|
||||||
|
|
||||||
|
namespace SVSim.UnitTests.Controllers;
|
||||||
|
|
||||||
|
public class PuzzleControllerTests
|
||||||
|
{
|
||||||
|
private const string BaseRequestJson =
|
||||||
|
"""{"viewer_id":"0","steam_id":0,"steam_session_ticket":""}""";
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Info_returns_25_groups_with_puzzles()
|
||||||
|
{
|
||||||
|
using var factory = new SVSimTestFactory();
|
||||||
|
await factory.SeedGlobalsAsync();
|
||||||
|
long viewerId = await factory.SeedViewerAsync();
|
||||||
|
using var client = factory.CreateAuthenticatedClient(viewerId);
|
||||||
|
|
||||||
|
var response = await client.PostAsync("/basic_puzzle/info",
|
||||||
|
new StringContent(BaseRequestJson, Encoding.UTF8, "application/json"));
|
||||||
|
|
||||||
|
var body = await response.Content.ReadAsStringAsync();
|
||||||
|
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK), body);
|
||||||
|
|
||||||
|
using var doc = JsonDocument.Parse(body);
|
||||||
|
// Controllers return the inner data payload; the wrapping {data_headers, data} envelope
|
||||||
|
// is added by ShadowverseTranslationMiddleware which the test factory bypasses, so the
|
||||||
|
// root element here IS the array (see PracticeControllerTests for the same pattern).
|
||||||
|
var data = doc.RootElement;
|
||||||
|
Assert.That(data.ValueKind, Is.EqualTo(JsonValueKind.Array));
|
||||||
|
Assert.That(data.GetArrayLength(), Is.EqualTo(25));
|
||||||
|
|
||||||
|
var g301 = data.EnumerateArray().Single(g => g.GetProperty("puzzle_master_id").GetString() == "301");
|
||||||
|
Assert.That(g301.GetProperty("is_all_cleared").GetBoolean(), Is.False);
|
||||||
|
Assert.That(g301.GetProperty("puzzle_data").GetArrayLength(), Is.EqualTo(3));
|
||||||
|
// String-on-wire assertion: puzzle_master_id ships as a JSON string, not number.
|
||||||
|
Assert.That(g301.GetProperty("puzzle_master_id").ValueKind, Is.EqualTo(JsonValueKind.String));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Info_reflects_per_viewer_clears()
|
||||||
|
{
|
||||||
|
using var factory = new SVSimTestFactory();
|
||||||
|
await factory.SeedGlobalsAsync();
|
||||||
|
long viewerId = await factory.SeedViewerAsync();
|
||||||
|
// Resolve the repo from a scope, not factory.Services directly (scoped service constraint).
|
||||||
|
using (var scope = factory.Services.CreateScope())
|
||||||
|
{
|
||||||
|
var clearRepo = scope.ServiceProvider.GetRequiredService<IPuzzleClearRepository>();
|
||||||
|
await clearRepo.UpsertClearAsync(viewerId, 37, 0);
|
||||||
|
}
|
||||||
|
using var client = factory.CreateAuthenticatedClient(viewerId);
|
||||||
|
|
||||||
|
var body = await (await client.PostAsync("/basic_puzzle/info",
|
||||||
|
new StringContent(BaseRequestJson, Encoding.UTF8, "application/json")))
|
||||||
|
.Content.ReadAsStringAsync();
|
||||||
|
|
||||||
|
using var doc = JsonDocument.Parse(body);
|
||||||
|
var g301 = doc.RootElement.EnumerateArray()
|
||||||
|
.Single(g => g.GetProperty("puzzle_master_id").GetString() == "301");
|
||||||
|
var p37 = g301.GetProperty("puzzle_data").EnumerateArray()
|
||||||
|
.Single(p => p.GetProperty("puzzle_id").GetString() == "37");
|
||||||
|
Assert.That(p37.GetProperty("is_cleared").GetBoolean(), Is.True);
|
||||||
|
|
||||||
|
Assert.That(g301.GetProperty("is_mission_target").GetBoolean(), Is.True,
|
||||||
|
"Round 1 mission still incomplete (1/3) so group 301 is still a mission target");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task OpenPuzzleDialog_returns_one_group()
|
||||||
|
{
|
||||||
|
using var factory = new SVSimTestFactory();
|
||||||
|
await factory.SeedGlobalsAsync();
|
||||||
|
long viewerId = await factory.SeedViewerAsync();
|
||||||
|
using var client = factory.CreateAuthenticatedClient(viewerId);
|
||||||
|
|
||||||
|
var req = """{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","puzzle_master_id":301}""";
|
||||||
|
var body = await (await client.PostAsync("/basic_puzzle/open_puzzle_dialog",
|
||||||
|
new StringContent(req, Encoding.UTF8, "application/json"))).Content.ReadAsStringAsync();
|
||||||
|
|
||||||
|
using var doc = JsonDocument.Parse(body);
|
||||||
|
var root = doc.RootElement;
|
||||||
|
Assert.That(root.GetProperty("puzzle_quest").GetArrayLength(), Is.EqualTo(3));
|
||||||
|
Assert.That(root.GetProperty("puzzle_quest_chara_id").GetString(), Is.EqualTo("3704"));
|
||||||
|
Assert.That(root.GetProperty("is_display_badge").GetBoolean(), Is.False);
|
||||||
|
Assert.That(root.GetProperty("is_display_puzzle_new").GetBoolean(), Is.False);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task OpenPuzzleDialog_unknown_group_returns_empty_payload()
|
||||||
|
{
|
||||||
|
using var factory = new SVSimTestFactory();
|
||||||
|
await factory.SeedGlobalsAsync();
|
||||||
|
long viewerId = await factory.SeedViewerAsync();
|
||||||
|
using var client = factory.CreateAuthenticatedClient(viewerId);
|
||||||
|
|
||||||
|
var req = """{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","puzzle_master_id":99999}""";
|
||||||
|
var resp = await client.PostAsync("/basic_puzzle/open_puzzle_dialog",
|
||||||
|
new StringContent(req, Encoding.UTF8, "application/json"));
|
||||||
|
|
||||||
|
Assert.That(resp.StatusCode, Is.EqualTo(HttpStatusCode.OK));
|
||||||
|
var root = JsonDocument.Parse(await resp.Content.ReadAsStringAsync()).RootElement;
|
||||||
|
Assert.That(root.GetProperty("puzzle_quest").GetArrayLength(), Is.EqualTo(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Start_returns_empty_array()
|
||||||
|
{
|
||||||
|
using var factory = new SVSimTestFactory();
|
||||||
|
long viewerId = await factory.SeedViewerAsync();
|
||||||
|
using var client = factory.CreateAuthenticatedClient(viewerId);
|
||||||
|
|
||||||
|
var req = """{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","puzzle_id":1}""";
|
||||||
|
var body = await (await client.PostAsync("/basic_puzzle/start",
|
||||||
|
new StringContent(req, Encoding.UTF8, "application/json"))).Content.ReadAsStringAsync();
|
||||||
|
|
||||||
|
using var doc = JsonDocument.Parse(body);
|
||||||
|
Assert.That(doc.RootElement.ValueKind, Is.EqualTo(JsonValueKind.Array));
|
||||||
|
Assert.That(doc.RootElement.GetArrayLength(), Is.EqualTo(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Mission_returns_19_entries_ordered_and_progress_tracked()
|
||||||
|
{
|
||||||
|
using var factory = new SVSimTestFactory();
|
||||||
|
await factory.SeedGlobalsAsync();
|
||||||
|
long viewerId = await factory.SeedViewerAsync();
|
||||||
|
// Clear 2 of 3 puzzles in group 301 (the Round-1 mission target).
|
||||||
|
using (var scope = factory.Services.CreateScope())
|
||||||
|
{
|
||||||
|
var clearRepo = scope.ServiceProvider.GetRequiredService<IPuzzleClearRepository>();
|
||||||
|
await clearRepo.UpsertClearAsync(viewerId, 37, 0);
|
||||||
|
await clearRepo.UpsertClearAsync(viewerId, 38, 0);
|
||||||
|
}
|
||||||
|
using var client = factory.CreateAuthenticatedClient(viewerId);
|
||||||
|
|
||||||
|
var body = await (await client.PostAsync("/basic_puzzle/mission",
|
||||||
|
new StringContent(BaseRequestJson, Encoding.UTF8, "application/json"))).Content.ReadAsStringAsync();
|
||||||
|
|
||||||
|
using var doc = JsonDocument.Parse(body);
|
||||||
|
var data = doc.RootElement;
|
||||||
|
Assert.That(data.GetArrayLength(), Is.EqualTo(19));
|
||||||
|
|
||||||
|
var round1 = data.EnumerateArray()
|
||||||
|
.Single(m => m.GetProperty("mission_name").GetString() == "Clear all Round 1 puzzles");
|
||||||
|
Assert.That(round1.GetProperty("total_count").GetString(), Is.EqualTo("2"));
|
||||||
|
Assert.That(round1.GetProperty("require_number").GetString(), Is.EqualTo("3"));
|
||||||
|
Assert.That(round1.GetProperty("is_achieved").GetBoolean(), Is.False);
|
||||||
|
|
||||||
|
var special = data.EnumerateArray()
|
||||||
|
.Single(m => m.GetProperty("mission_name").GetString() == "Clear all Special Round puzzles");
|
||||||
|
Assert.That(special.GetProperty("total_count").GetString(), Is.EqualTo("0"),
|
||||||
|
"Special-Round missions always surface as 0 in Phase 1");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Finish_loss_is_stateless_and_returns_loss_shape()
|
||||||
|
{
|
||||||
|
using var factory = new SVSimTestFactory();
|
||||||
|
await factory.SeedGlobalsAsync();
|
||||||
|
long viewerId = await factory.SeedViewerAsync();
|
||||||
|
using var client = factory.CreateAuthenticatedClient(viewerId);
|
||||||
|
|
||||||
|
var req = """{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","puzzle_id":37,"retry_count":0,"is_win":false}""";
|
||||||
|
var body = await (await client.PostAsync("/basic_puzzle/finish",
|
||||||
|
new StringContent(req, Encoding.UTF8, "application/json"))).Content.ReadAsStringAsync();
|
||||||
|
|
||||||
|
using var doc = JsonDocument.Parse(body);
|
||||||
|
var data = doc.RootElement;
|
||||||
|
|
||||||
|
// Loss-specific: win_count is the NUMBER 0, not the string "1".
|
||||||
|
Assert.That(data.GetProperty("win_count").ValueKind, Is.EqualTo(JsonValueKind.Number));
|
||||||
|
Assert.That(data.GetProperty("win_count").GetInt32(), Is.EqualTo(0));
|
||||||
|
Assert.That(data.GetProperty("achieved_info").GetProperty("mission_start_data").GetArrayLength(), Is.EqualTo(0));
|
||||||
|
Assert.That(data.GetProperty("reward_list").GetArrayLength(), Is.EqualTo(0));
|
||||||
|
|
||||||
|
// No DB writes.
|
||||||
|
using var scope = factory.Services.CreateScope();
|
||||||
|
var ctx = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||||
|
Assert.That(await ctx.ViewerPuzzleClears.CountAsync(), Is.EqualTo(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Finish_win_persists_clear_and_returns_win_shape()
|
||||||
|
{
|
||||||
|
using var factory = new SVSimTestFactory();
|
||||||
|
await factory.SeedGlobalsAsync();
|
||||||
|
long viewerId = await factory.SeedViewerAsync();
|
||||||
|
using var client = factory.CreateAuthenticatedClient(viewerId);
|
||||||
|
|
||||||
|
var req = """{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","puzzle_id":37,"retry_count":0,"is_win":true}""";
|
||||||
|
var body = await (await client.PostAsync("/basic_puzzle/finish",
|
||||||
|
new StringContent(req, Encoding.UTF8, "application/json"))).Content.ReadAsStringAsync();
|
||||||
|
|
||||||
|
using var doc = JsonDocument.Parse(body);
|
||||||
|
var data = doc.RootElement;
|
||||||
|
|
||||||
|
// Win-specific: win_count is the STRING "1".
|
||||||
|
Assert.That(data.GetProperty("win_count").ValueKind, Is.EqualTo(JsonValueKind.String));
|
||||||
|
Assert.That(data.GetProperty("win_count").GetString(), Is.EqualTo("1"));
|
||||||
|
|
||||||
|
// 1/3 in group 301 → no mission completion yet.
|
||||||
|
Assert.That(data.GetProperty("achieved_info").GetProperty("achieved_mission_list").GetArrayLength(), Is.EqualTo(0));
|
||||||
|
Assert.That(data.GetProperty("reward_list").GetArrayLength(), Is.EqualTo(0));
|
||||||
|
|
||||||
|
// mission_start_data still contains the un-achieved Round-1 mission.
|
||||||
|
var starts = data.GetProperty("achieved_info").GetProperty("mission_start_data");
|
||||||
|
Assert.That(starts.EnumerateArray().Any(e => e.GetProperty("mission_name").GetString() == "Clear all Round 1 puzzles"), Is.True);
|
||||||
|
|
||||||
|
// Clear was persisted.
|
||||||
|
using var scope = factory.Services.CreateScope();
|
||||||
|
var ctx = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||||
|
Assert.That(await ctx.ViewerPuzzleClears.AnyAsync(c => c.ViewerId == viewerId && c.PuzzleId == 37), Is.True);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Finish_completes_mission_grants_reward_and_toggles_mission_target()
|
||||||
|
{
|
||||||
|
using var factory = new SVSimTestFactory();
|
||||||
|
await factory.SeedGlobalsAsync();
|
||||||
|
long viewerId = await factory.SeedViewerAsync();
|
||||||
|
|
||||||
|
// The Round-1 mission rewards LeaderSkin 3704. SeedGlobalsAsync's leaderskins.csv may
|
||||||
|
// already include this id; insert defensively (skip if exists) so the test is
|
||||||
|
// independent of seed data shape.
|
||||||
|
using (var setup = factory.Services.CreateScope())
|
||||||
|
{
|
||||||
|
var ctx = setup.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||||
|
if (!await ctx.LeaderSkins.AnyAsync(s => s.Id == 3704))
|
||||||
|
{
|
||||||
|
ctx.LeaderSkins.Add(new LeaderSkinEntry { Id = 3704, Name = "Round1Reward" });
|
||||||
|
await ctx.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
var clearRepo = setup.ServiceProvider.GetRequiredService<IPuzzleClearRepository>();
|
||||||
|
await clearRepo.UpsertClearAsync(viewerId, 37, 0);
|
||||||
|
await clearRepo.UpsertClearAsync(viewerId, 38, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
using var client = factory.CreateAuthenticatedClient(viewerId);
|
||||||
|
var req = """{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","puzzle_id":39,"retry_count":0,"is_win":true}""";
|
||||||
|
var body = await (await client.PostAsync("/basic_puzzle/finish",
|
||||||
|
new StringContent(req, Encoding.UTF8, "application/json"))).Content.ReadAsStringAsync();
|
||||||
|
|
||||||
|
using var doc = JsonDocument.Parse(body);
|
||||||
|
var data = doc.RootElement;
|
||||||
|
var ai = data.GetProperty("achieved_info");
|
||||||
|
|
||||||
|
// Achievement banner emitted.
|
||||||
|
Assert.That(ai.GetProperty("achieved_mission_list").GetArrayLength(), Is.EqualTo(1));
|
||||||
|
Assert.That(ai.GetProperty("achieved_mission_list")[0].GetProperty("achieved_message").GetString(),
|
||||||
|
Is.EqualTo("Cleared all Round 1 puzzles"));
|
||||||
|
|
||||||
|
// mission_reward_* prefixed shape (NOT reward_detail_id/number).
|
||||||
|
var mrl = ai.GetProperty("achieved_mission_reward_list");
|
||||||
|
Assert.That(mrl.GetArrayLength(), Is.EqualTo(1));
|
||||||
|
Assert.That(mrl[0].GetProperty("mission_reward_type").GetString(), Is.EqualTo("10"));
|
||||||
|
Assert.That(mrl[0].GetProperty("mission_reward_detail_id").GetString(), Is.EqualTo("3704"));
|
||||||
|
|
||||||
|
// Top-level reward_list mirrors as TreasureReward shape (reward_id / reward_num).
|
||||||
|
var rl = data.GetProperty("reward_list");
|
||||||
|
Assert.That(rl.GetArrayLength(), Is.EqualTo(1));
|
||||||
|
Assert.That(rl[0].GetProperty("reward_id").GetString(), Is.EqualTo("3704"));
|
||||||
|
Assert.That(rl[0].GetProperty("reward_num").GetString(), Is.EqualTo("1"));
|
||||||
|
|
||||||
|
// Viewer collection updated — owns the leader skin now.
|
||||||
|
using var verify = factory.Services.CreateScope();
|
||||||
|
var verifyCtx = verify.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||||
|
var viewer = await verifyCtx.Viewers.Include(v => v.LeaderSkins).FirstAsync(v => v.Id == viewerId);
|
||||||
|
Assert.That(viewer.LeaderSkins.Any(s => s.Id == 3704), Is.True);
|
||||||
|
|
||||||
|
// mission_start_data no longer contains the achieved Round-1 mission.
|
||||||
|
var starts = ai.GetProperty("mission_start_data");
|
||||||
|
Assert.That(starts.EnumerateArray().Any(e => e.GetProperty("mission_name").GetString() == "Clear all Round 1 puzzles"),
|
||||||
|
Is.False);
|
||||||
|
|
||||||
|
// puzzle_list entry for group 301 has is_mission_target=false now.
|
||||||
|
var g301 = data.GetProperty("puzzle_list").EnumerateArray()
|
||||||
|
.Single(g => g.GetProperty("puzzle_master_id").GetString() == "301");
|
||||||
|
Assert.That(g301.GetProperty("is_all_cleared").GetBoolean(), Is.True);
|
||||||
|
Assert.That(g301.GetProperty("is_mission_target").GetBoolean(), Is.False);
|
||||||
|
}
|
||||||
|
}
|
||||||
64
SVSim.UnitTests/Importers/GlobalsImporterPuzzleTests.cs
Normal file
64
SVSim.UnitTests/Importers/GlobalsImporterPuzzleTests.cs
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using SVSim.Database;
|
||||||
|
using SVSim.UnitTests.Infrastructure;
|
||||||
|
|
||||||
|
namespace SVSim.UnitTests.Importers;
|
||||||
|
|
||||||
|
public class GlobalsImporterPuzzleTests
|
||||||
|
{
|
||||||
|
[Test]
|
||||||
|
public async Task ImportsAllPuzzleGroupsAndPuzzles()
|
||||||
|
{
|
||||||
|
using var factory = new SVSimTestFactory();
|
||||||
|
await factory.SeedGlobalsAsync();
|
||||||
|
|
||||||
|
var ctx = factory.Services.GetRequiredService<SVSimDbContext>();
|
||||||
|
Assert.That(await ctx.PuzzleGroups.CountAsync(), Is.EqualTo(25),
|
||||||
|
"25 groups in the captured /basic_puzzle/info (puzzle_master_ids 1..9 plus 301..316)");
|
||||||
|
Assert.That(await ctx.Puzzles.CountAsync(), Is.GreaterThan(100),
|
||||||
|
"~110 puzzles total across all groups");
|
||||||
|
|
||||||
|
// Spot-check group 301 (the Round-1 character group, contains puzzles 37/38/39).
|
||||||
|
var g301 = await ctx.PuzzleGroups.Include(g => g.Puzzles).FirstAsync(g => g.Id == 301);
|
||||||
|
Assert.That(g301.BasicTitleTextId, Is.EqualTo("Puzzle_QuestSelect_0301"));
|
||||||
|
Assert.That(g301.PuzzleCharaId, Is.EqualTo(3704));
|
||||||
|
Assert.That(g301.Puzzles.Select(p => p.Id).OrderBy(x => x), Is.EqualTo(new[] { 37, 38, 39 }));
|
||||||
|
Assert.That(g301.DifficultyNameListJson, Does.Contain("\"Beginner\":\"0\""));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task IsIdempotent()
|
||||||
|
{
|
||||||
|
using var factory = new SVSimTestFactory();
|
||||||
|
await factory.SeedGlobalsAsync();
|
||||||
|
await factory.SeedGlobalsAsync(); // second run — must not duplicate
|
||||||
|
|
||||||
|
var ctx = factory.Services.GetRequiredService<SVSimDbContext>();
|
||||||
|
Assert.That(await ctx.PuzzleGroups.CountAsync(), Is.EqualTo(25));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task ImportsAllPuzzleMissionsWithRoundMapping()
|
||||||
|
{
|
||||||
|
using var factory = new SVSimTestFactory();
|
||||||
|
await factory.SeedGlobalsAsync();
|
||||||
|
|
||||||
|
var ctx = factory.Services.GetRequiredService<SVSimDbContext>();
|
||||||
|
Assert.That(await ctx.PuzzleMissions.CountAsync(), Is.EqualTo(19),
|
||||||
|
"19 entries in the captured /basic_puzzle/mission");
|
||||||
|
|
||||||
|
// "Clear all Round 1 puzzles" -> target group 301 + AchievedMessage derived.
|
||||||
|
var round1 = await ctx.PuzzleMissions.FirstAsync(m => m.MissionName == "Clear all Round 1 puzzles");
|
||||||
|
Assert.That(round1.TargetPuzzleGroupId, Is.EqualTo(301));
|
||||||
|
Assert.That(round1.AchievedMessage, Is.EqualTo("Cleared all Round 1 puzzles"));
|
||||||
|
Assert.That(round1.RequireNumber, Is.EqualTo(3));
|
||||||
|
Assert.That(round1.RewardType, Is.EqualTo(10)); // LeaderSkin
|
||||||
|
Assert.That(round1.RewardDetailId, Is.EqualTo(3704L)); // chara_id matching group 301
|
||||||
|
Assert.That(round1.RewardNumber, Is.EqualTo(1));
|
||||||
|
|
||||||
|
// Special-Round mission -> TargetPuzzleGroupId is null (deferred per Phase 1).
|
||||||
|
var special = await ctx.PuzzleMissions.FirstAsync(m => m.MissionName == "Clear all Special Round puzzles");
|
||||||
|
Assert.That(special.TargetPuzzleGroupId, Is.Null);
|
||||||
|
}
|
||||||
|
}
|
||||||
62
SVSim.UnitTests/Repositories/PuzzleCatalogRepositoryTests.cs
Normal file
62
SVSim.UnitTests/Repositories/PuzzleCatalogRepositoryTests.cs
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using SVSim.Database;
|
||||||
|
using SVSim.Database.Repositories.Globals;
|
||||||
|
using SVSim.UnitTests.Infrastructure;
|
||||||
|
|
||||||
|
namespace SVSim.UnitTests.Repositories;
|
||||||
|
|
||||||
|
public class PuzzleCatalogRepositoryTests
|
||||||
|
{
|
||||||
|
[Test]
|
||||||
|
public async Task GetAllGroupsWithPuzzles_returns_25_groups_each_with_puzzles()
|
||||||
|
{
|
||||||
|
using var factory = new SVSimTestFactory();
|
||||||
|
await factory.SeedGlobalsAsync();
|
||||||
|
var repo = factory.Services.GetRequiredService<IPuzzleCatalogRepository>();
|
||||||
|
|
||||||
|
var groups = await repo.GetAllGroupsWithPuzzles();
|
||||||
|
|
||||||
|
Assert.That(groups, Has.Count.EqualTo(25));
|
||||||
|
Assert.That(groups.All(g => g.Puzzles.Count > 0), Is.True,
|
||||||
|
"every group must have its Puzzles navigation populated");
|
||||||
|
var g301 = groups.Single(g => g.Id == 301);
|
||||||
|
Assert.That(g301.Puzzles.Select(p => p.Id).OrderBy(x => x), Is.EqualTo(new[] { 37, 38, 39 }));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task GetGroupWithPuzzles_returns_one_group_or_null()
|
||||||
|
{
|
||||||
|
using var factory = new SVSimTestFactory();
|
||||||
|
await factory.SeedGlobalsAsync();
|
||||||
|
var repo = factory.Services.GetRequiredService<IPuzzleCatalogRepository>();
|
||||||
|
|
||||||
|
var g = await repo.GetGroupWithPuzzles(301);
|
||||||
|
Assert.That(g, Is.Not.Null);
|
||||||
|
Assert.That(g!.Puzzles, Has.Count.EqualTo(3));
|
||||||
|
|
||||||
|
var missing = await repo.GetGroupWithPuzzles(99999);
|
||||||
|
Assert.That(missing, Is.Null);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task GetAllMissionsOrdered_returns_19_missions_in_correct_order()
|
||||||
|
{
|
||||||
|
using var factory = new SVSimTestFactory();
|
||||||
|
await factory.SeedGlobalsAsync();
|
||||||
|
var repo = factory.Services.GetRequiredService<IPuzzleCatalogRepository>();
|
||||||
|
|
||||||
|
var missions = await repo.GetAllMissionsOrdered();
|
||||||
|
Assert.That(missions, Has.Count.EqualTo(19));
|
||||||
|
|
||||||
|
// Captured order: by OrderId asc, then CampaignCommenceTime desc.
|
||||||
|
var pairs = missions.Select(m => (m.OrderId, m.CampaignCommenceTime)).ToList();
|
||||||
|
for (int i = 1; i < pairs.Count; i++)
|
||||||
|
{
|
||||||
|
var prev = pairs[i - 1]; var cur = pairs[i];
|
||||||
|
Assert.That(prev.OrderId, Is.LessThanOrEqualTo(cur.OrderId));
|
||||||
|
if (prev.OrderId == cur.OrderId)
|
||||||
|
Assert.That(prev.CampaignCommenceTime, Is.GreaterThanOrEqualTo(cur.CampaignCommenceTime));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
50
SVSim.UnitTests/Repositories/PuzzleClearRepositoryTests.cs
Normal file
50
SVSim.UnitTests/Repositories/PuzzleClearRepositoryTests.cs
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using SVSim.Database;
|
||||||
|
using SVSim.Database.Repositories.Viewer;
|
||||||
|
using SVSim.UnitTests.Infrastructure;
|
||||||
|
|
||||||
|
namespace SVSim.UnitTests.Repositories;
|
||||||
|
|
||||||
|
public class PuzzleClearRepositoryTests
|
||||||
|
{
|
||||||
|
[Test]
|
||||||
|
public async Task UpsertClear_inserts_then_updates_idempotently()
|
||||||
|
{
|
||||||
|
using var factory = new SVSimTestFactory();
|
||||||
|
long viewerId = await factory.SeedViewerAsync();
|
||||||
|
var repo = factory.Services.GetRequiredService<IPuzzleClearRepository>();
|
||||||
|
|
||||||
|
var clearsBefore = await repo.GetClearedPuzzleIds(viewerId);
|
||||||
|
Assert.That(clearsBefore, Is.Empty);
|
||||||
|
|
||||||
|
await repo.UpsertClearAsync(viewerId, puzzleId: 37, retryCount: 2);
|
||||||
|
await repo.UpsertClearAsync(viewerId, puzzleId: 37, retryCount: 0); // better clear; BestRetryCount should drop to 0
|
||||||
|
await repo.UpsertClearAsync(viewerId, puzzleId: 38, retryCount: 1);
|
||||||
|
|
||||||
|
var ids = await repo.GetClearedPuzzleIds(viewerId);
|
||||||
|
Assert.That(ids, Is.EquivalentTo(new[] { 37, 38 }));
|
||||||
|
|
||||||
|
var ctx = factory.Services.GetRequiredService<SVSimDbContext>();
|
||||||
|
var row37 = await ctx.ViewerPuzzleClears.FirstAsync(c => c.ViewerId == viewerId && c.PuzzleId == 37);
|
||||||
|
Assert.That(row37.BestRetryCount, Is.EqualTo(0), "BestRetryCount is min across all wins");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task GetClearedPuzzleIdsByGroup_groups_by_FK()
|
||||||
|
{
|
||||||
|
using var factory = new SVSimTestFactory();
|
||||||
|
await factory.SeedGlobalsAsync(); // need Puzzles table populated for GroupId FKs
|
||||||
|
long viewerId = await factory.SeedViewerAsync();
|
||||||
|
var repo = factory.Services.GetRequiredService<IPuzzleClearRepository>();
|
||||||
|
|
||||||
|
await repo.UpsertClearAsync(viewerId, 37, 0); // group 301
|
||||||
|
await repo.UpsertClearAsync(viewerId, 38, 0); // group 301
|
||||||
|
await repo.UpsertClearAsync(viewerId, 64, 0); // group 306
|
||||||
|
|
||||||
|
var byGroup = await repo.GetClearedPuzzleIdsByGroup(viewerId);
|
||||||
|
Assert.That(byGroup[301], Is.EquivalentTo(new[] { 37, 38 }));
|
||||||
|
Assert.That(byGroup[306], Is.EquivalentTo(new[] { 64 }));
|
||||||
|
Assert.That(byGroup.Keys, Does.Not.Contain(999));
|
||||||
|
}
|
||||||
|
}
|
||||||
53
SVSim.UnitTests/Services/PuzzleMissionEvaluatorTests.cs
Normal file
53
SVSim.UnitTests/Services/PuzzleMissionEvaluatorTests.cs
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
using SVSim.Database.Models;
|
||||||
|
using SVSim.EmulatedEntrypoint.Services;
|
||||||
|
|
||||||
|
namespace SVSim.UnitTests.Services;
|
||||||
|
|
||||||
|
public class PuzzleMissionEvaluatorTests
|
||||||
|
{
|
||||||
|
private static readonly PuzzleMissionEntry Round1 = new()
|
||||||
|
{ Id = 1, MissionName = "Clear all Round 1 puzzles", RequireNumber = 3, TargetPuzzleGroupId = 301 };
|
||||||
|
private static readonly PuzzleMissionEntry SpecialAll = new()
|
||||||
|
{ Id = 2, MissionName = "Clear all Special Round puzzles", RequireNumber = 8, TargetPuzzleGroupId = null };
|
||||||
|
|
||||||
|
private readonly PuzzleMissionEvaluator _e = new();
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Evaluate_unmapped_mission_always_zero()
|
||||||
|
{
|
||||||
|
var cleared = new Dictionary<int, HashSet<int>> { [316] = new() { 106, 107, 108 } };
|
||||||
|
var result = _e.Evaluate(new[] { SpecialAll }, cleared);
|
||||||
|
|
||||||
|
Assert.That(result.Single().TotalCount, Is.EqualTo(0));
|
||||||
|
Assert.That(result.Single().IsAchieved, Is.False);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Evaluate_mapped_mission_counts_clears_in_target_group_capped()
|
||||||
|
{
|
||||||
|
var partial = new Dictionary<int, HashSet<int>> { [301] = new() { 37, 38 } };
|
||||||
|
Assert.That(_e.Evaluate(new[] { Round1 }, partial).Single().TotalCount, Is.EqualTo(2));
|
||||||
|
Assert.That(_e.Evaluate(new[] { Round1 }, partial).Single().IsAchieved, Is.False);
|
||||||
|
|
||||||
|
var complete = new Dictionary<int, HashSet<int>> { [301] = new() { 37, 38, 39 } };
|
||||||
|
Assert.That(_e.Evaluate(new[] { Round1 }, complete).Single().IsAchieved, Is.True);
|
||||||
|
|
||||||
|
// Imagine a future where the group has more puzzles than RequireNumber — cap at RequireNumber.
|
||||||
|
var over = new Dictionary<int, HashSet<int>> { [301] = new() { 37, 38, 39, 999 } };
|
||||||
|
Assert.That(_e.Evaluate(new[] { Round1 }, over).Single().TotalCount, Is.EqualTo(3));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void FreshlyCompleted_returns_only_missions_flipping_true()
|
||||||
|
{
|
||||||
|
var before = new Dictionary<int, HashSet<int>> { [301] = new() { 37, 38 } };
|
||||||
|
var after = new Dictionary<int, HashSet<int>> { [301] = new() { 37, 38, 39 } };
|
||||||
|
|
||||||
|
var fresh = _e.FreshlyCompleted(new[] { Round1, SpecialAll }, before, after);
|
||||||
|
Assert.That(fresh, Has.Count.EqualTo(1));
|
||||||
|
Assert.That(fresh[0].Mission.Id, Is.EqualTo(Round1.Id));
|
||||||
|
|
||||||
|
// Re-evaluating with same before==after returns no fresh completions.
|
||||||
|
Assert.That(_e.FreshlyCompleted(new[] { Round1, SpecialAll }, after, after), Is.Empty);
|
||||||
|
}
|
||||||
|
}
|
||||||
99
SVSim.UnitTests/Services/RewardGrantServiceTests.cs
Normal file
99
SVSim.UnitTests/Services/RewardGrantServiceTests.cs
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using SVSim.Database;
|
||||||
|
using SVSim.Database.Enums;
|
||||||
|
using SVSim.Database.Models;
|
||||||
|
using SVSim.Database.Services;
|
||||||
|
using SVSim.UnitTests.Infrastructure;
|
||||||
|
|
||||||
|
namespace SVSim.UnitTests.Services;
|
||||||
|
|
||||||
|
public class RewardGrantServiceTests
|
||||||
|
{
|
||||||
|
[Test]
|
||||||
|
public async Task Sleeve_added_to_viewer_collection()
|
||||||
|
{
|
||||||
|
using var factory = new SVSimTestFactory();
|
||||||
|
long viewerId = await factory.SeedViewerAsync();
|
||||||
|
using var scope = factory.Services.CreateScope();
|
||||||
|
var ctx = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||||
|
|
||||||
|
// Pick an Id above the seeded sleeves.csv range so this test doesn't collide with the
|
||||||
|
// reference-CSV importer SVSimTestFactory runs at host construction.
|
||||||
|
const int testSleeveId = 2_000_000_000;
|
||||||
|
var sleeve = new SleeveEntry { Id = testSleeveId }; // SleeveEntry has no Name field; Id only
|
||||||
|
ctx.Sleeves.Add(sleeve);
|
||||||
|
await ctx.SaveChangesAsync();
|
||||||
|
|
||||||
|
var viewer = await ctx.Viewers.Include(v => v.Sleeves).FirstAsync(v => v.Id == viewerId);
|
||||||
|
var svc = scope.ServiceProvider.GetRequiredService<RewardGrantService>();
|
||||||
|
|
||||||
|
var entry = svc.Apply(viewer, UserGoodsType.Sleeve, detailId: testSleeveId, num: 1);
|
||||||
|
await ctx.SaveChangesAsync();
|
||||||
|
|
||||||
|
Assert.That(viewer.Sleeves.Any(s => s.Id == testSleeveId), Is.True);
|
||||||
|
Assert.That(entry.RewardType, Is.EqualTo((int)UserGoodsType.Sleeve));
|
||||||
|
Assert.That(entry.RewardId, Is.EqualTo((long)testSleeveId));
|
||||||
|
Assert.That(entry.RewardNum, Is.EqualTo(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Rupy_sets_currency_post_state_total()
|
||||||
|
{
|
||||||
|
using var factory = new SVSimTestFactory();
|
||||||
|
long viewerId = await factory.SeedViewerAsync();
|
||||||
|
using var scope = factory.Services.CreateScope();
|
||||||
|
var ctx = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||||
|
var viewer = await ctx.Viewers.FirstAsync(v => v.Id == viewerId);
|
||||||
|
viewer.Currency.Rupees = 100UL;
|
||||||
|
await ctx.SaveChangesAsync();
|
||||||
|
|
||||||
|
var svc = scope.ServiceProvider.GetRequiredService<RewardGrantService>();
|
||||||
|
|
||||||
|
// Reward grants 50; final balance becomes 150 and reward_num on the wire is the new total.
|
||||||
|
var entry = svc.Apply(viewer, UserGoodsType.Rupy, detailId: 0, num: 50);
|
||||||
|
await ctx.SaveChangesAsync();
|
||||||
|
|
||||||
|
Assert.That(viewer.Currency.Rupees, Is.EqualTo(150UL));
|
||||||
|
Assert.That(entry.RewardNum, Is.EqualTo(150));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task LeaderSkin_added_idempotently()
|
||||||
|
{
|
||||||
|
using var factory = new SVSimTestFactory();
|
||||||
|
long viewerId = await factory.SeedViewerAsync();
|
||||||
|
using var scope = factory.Services.CreateScope();
|
||||||
|
var ctx = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||||
|
|
||||||
|
// Pick an Id above the seeded leaderskins.csv range so this test doesn't collide with
|
||||||
|
// the reference-CSV importer SVSimTestFactory runs at host construction.
|
||||||
|
const int testSkinId = 9_999_999;
|
||||||
|
ctx.LeaderSkins.Add(new LeaderSkinEntry { Id = testSkinId, Name = "Round1Reward" });
|
||||||
|
await ctx.SaveChangesAsync();
|
||||||
|
|
||||||
|
var viewer = await ctx.Viewers.Include(v => v.LeaderSkins).FirstAsync(v => v.Id == viewerId);
|
||||||
|
var svc = scope.ServiceProvider.GetRequiredService<RewardGrantService>();
|
||||||
|
|
||||||
|
svc.Apply(viewer, UserGoodsType.Skin, testSkinId, 1);
|
||||||
|
svc.Apply(viewer, UserGoodsType.Skin, testSkinId, 1); // second grant is a no-op on collection size
|
||||||
|
await ctx.SaveChangesAsync();
|
||||||
|
|
||||||
|
Assert.That(viewer.LeaderSkins.Count(s => s.Id == testSkinId), Is.EqualTo(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Card_reward_throws_NotSupported()
|
||||||
|
{
|
||||||
|
using var factory = new SVSimTestFactory();
|
||||||
|
long viewerId = await factory.SeedViewerAsync();
|
||||||
|
using var scope = factory.Services.CreateScope();
|
||||||
|
var ctx = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||||
|
|
||||||
|
var viewer = await ctx.Viewers.FirstAsync(v => v.Id == viewerId);
|
||||||
|
var svc = scope.ServiceProvider.GetRequiredService<RewardGrantService>();
|
||||||
|
|
||||||
|
Assert.Throws<NotSupportedException>(() =>
|
||||||
|
svc.Apply(viewer, UserGoodsType.Card, 10001001L, 1));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user