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

File diff suppressed because one or more lines are too long

View File

@@ -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}]}

View File

@@ -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)

View 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,
}

File diff suppressed because it is too large Load Diff

View 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");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -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");
}
}
}

View File

@@ -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");

View 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;
}

View 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();
}

View 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; }
}

View File

@@ -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();

View 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; }
}

View File

@@ -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();
}

View File

@@ -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();
}

View 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);
}

View 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();
}
}

View File

@@ -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

View 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&lt;int&gt;</c>
/// (cosmetics) and <c>BaseEntity&lt;long&gt;</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 };
}
}

View File

@@ -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,
}); });
} }
} }

View File

@@ -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;
}); });
} }

View File

@@ -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,

View File

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

View File

@@ -6,9 +6,13 @@ namespace SVSim.EmulatedEntrypoint.Models.Dtos;
/// <summary> /// <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")]

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -222,7 +222,7 @@ public class MyPageIndexResponse
[JsonPropertyName("basic_puzzle")] [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 ────────────────────────────────────────────

View File

@@ -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;

View File

@@ -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

View File

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

View 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);
}
}

View 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);
}
}

View 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));
}
}
}

View 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));
}
}

View 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);
}
}

View 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));
}
}