Puzzles
This commit is contained in:
26
SVSim.Database/Enums/UserGoodsType.cs
Normal file
26
SVSim.Database/Enums/UserGoodsType.cs
Normal file
@@ -0,0 +1,26 @@
|
||||
namespace SVSim.Database.Enums;
|
||||
|
||||
/// <summary>
|
||||
/// Mirrors the client's <c>Wizard.UserGoods.Type</c> enum (Shadowverse_Code/Wizard/UserGoods.cs).
|
||||
/// These integers travel on the wire as <c>reward_type</c> on <c>reward_list</c> entries; the
|
||||
/// client uses them in <c>PlayerStaticData.UpdateHaveUserGoodsNumByJsonData</c> to route the
|
||||
/// grant into the right collection / currency total.
|
||||
/// </summary>
|
||||
public enum UserGoodsType
|
||||
{
|
||||
RedEther = 1,
|
||||
Crystal = 2,
|
||||
// 3 is unused / placeholder in the client enum.
|
||||
Item = 4,
|
||||
Card = 5,
|
||||
Sleeve = 6,
|
||||
Emblem = 7,
|
||||
Degree = 8,
|
||||
Rupy = 9,
|
||||
Skin = 10, // LeaderSkin in our schema
|
||||
SpotCard = 11,
|
||||
SpotCardPoint = 12,
|
||||
SpotCardOnlyLatestCardPack = 13,
|
||||
FreeGachaCount = 14,
|
||||
MyPageBG = 15,
|
||||
}
|
||||
2164
SVSim.Database/Migrations/20260525055824_AddBasicPuzzle.Designer.cs
generated
Normal file
2164
SVSim.Database/Migrations/20260525055824_AddBasicPuzzle.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
116
SVSim.Database/Migrations/20260525055824_AddBasicPuzzle.cs
Normal file
116
SVSim.Database/Migrations/20260525055824_AddBasicPuzzle.cs
Normal file
@@ -0,0 +1,116 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace SVSim.Database.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddBasicPuzzle : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "PuzzleGroups",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "integer", nullable: false),
|
||||
PuzzleMasterId = table.Column<int>(type: "integer", nullable: false),
|
||||
BasicTitleTextId = table.Column<string>(type: "text", nullable: false),
|
||||
PuzzleCharaId = table.Column<int>(type: "integer", nullable: false),
|
||||
CharaId = table.Column<int>(type: "integer", nullable: false),
|
||||
SortType = table.Column<int>(type: "integer", nullable: false),
|
||||
DifficultyNameListJson = table.Column<string>(type: "text", nullable: false),
|
||||
DateCreated = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
DateUpdated = table.Column<DateTime>(type: "timestamp with time zone", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_PuzzleGroups", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "PuzzleMissions",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "integer", nullable: false),
|
||||
MissionName = table.Column<string>(type: "text", nullable: false),
|
||||
AchievedMessage = table.Column<string>(type: "text", nullable: false),
|
||||
RequireNumber = table.Column<int>(type: "integer", nullable: false),
|
||||
CampaignCommenceTime = table.Column<long>(type: "bigint", nullable: false),
|
||||
OrderId = table.Column<int>(type: "integer", nullable: false),
|
||||
RewardType = table.Column<int>(type: "integer", nullable: false),
|
||||
RewardDetailId = table.Column<long>(type: "bigint", nullable: false),
|
||||
RewardNumber = table.Column<int>(type: "integer", nullable: false),
|
||||
TargetPuzzleGroupId = table.Column<int>(type: "integer", nullable: true),
|
||||
DateCreated = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
DateUpdated = table.Column<DateTime>(type: "timestamp with time zone", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_PuzzleMissions", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "ViewerPuzzleClears",
|
||||
columns: table => new
|
||||
{
|
||||
ViewerId = table.Column<long>(type: "bigint", nullable: false),
|
||||
PuzzleId = table.Column<int>(type: "integer", nullable: false),
|
||||
ClearedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
BestRetryCount = table.Column<int>(type: "integer", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_ViewerPuzzleClears", x => new { x.ViewerId, x.PuzzleId });
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Puzzles",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "integer", nullable: false),
|
||||
PuzzleId = table.Column<int>(type: "integer", nullable: false),
|
||||
GroupId = table.Column<int>(type: "integer", nullable: false),
|
||||
PuzzleDifficulty = table.Column<int>(type: "integer", nullable: false),
|
||||
IsAdditional = table.Column<bool>(type: "boolean", nullable: false),
|
||||
IsPlayable = table.Column<bool>(type: "boolean", nullable: false),
|
||||
ReleaseConditionTextId = table.Column<string>(type: "text", nullable: false),
|
||||
DateCreated = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
DateUpdated = table.Column<DateTime>(type: "timestamp with time zone", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Puzzles", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_Puzzles_PuzzleGroups_GroupId",
|
||||
column: x => x.GroupId,
|
||||
principalTable: "PuzzleGroups",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Puzzles_GroupId",
|
||||
table: "Puzzles",
|
||||
column: "GroupId");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "PuzzleMissions");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "Puzzles");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "ViewerPuzzleClears");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "PuzzleGroups");
|
||||
}
|
||||
}
|
||||
}
|
||||
2167
SVSim.Database/Migrations/20260525143340_AddDeckMyRotationId.Designer.cs
generated
Normal file
2167
SVSim.Database/Migrations/20260525143340_AddDeckMyRotationId.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,28 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace SVSim.Database.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddDeckMyRotationId : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "MyRotationId",
|
||||
table: "Decks",
|
||||
type: "text",
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "MyRotationId",
|
||||
table: "Decks");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -976,6 +976,124 @@ namespace SVSim.Database.Migrations
|
||||
b.ToTable("PreReleaseInfos");
|
||||
});
|
||||
|
||||
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 =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
@@ -1200,6 +1318,9 @@ namespace SVSim.Database.Migrations
|
||||
b.Property<int>("LeaderSkinId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("MyRotationId")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
@@ -1347,6 +1468,25 @@ namespace SVSim.Database.Migrations
|
||||
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 =>
|
||||
{
|
||||
b.Property<int>("SleevesId")
|
||||
@@ -1546,6 +1686,17 @@ namespace SVSim.Database.Migrations
|
||||
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 =>
|
||||
{
|
||||
b.HasOne("SVSim.Database.Models.ClassEntry", "Class")
|
||||
@@ -1993,6 +2144,11 @@ namespace SVSim.Database.Migrations
|
||||
b.Navigation("LeaderSkins");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SVSim.Database.Models.PuzzleGroupEntry", b =>
|
||||
{
|
||||
b.Navigation("Puzzles");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SVSim.Database.Models.ShadowverseCardSetEntry", b =>
|
||||
{
|
||||
b.Navigation("Cards");
|
||||
|
||||
25
SVSim.Database/Models/PuzzleEntry.cs
Normal file
25
SVSim.Database/Models/PuzzleEntry.cs
Normal file
@@ -0,0 +1,25 @@
|
||||
using SVSim.Database.Common;
|
||||
|
||||
namespace SVSim.Database.Models;
|
||||
|
||||
/// <summary>
|
||||
/// One row per basic_puzzle within a group. Static catalog seeded by SVSim.Bootstrap.
|
||||
/// See docs/api-spec/endpoints/post-login/basic-puzzle/info.md (PuzzleEntry).
|
||||
/// </summary>
|
||||
public class PuzzleEntry : BaseEntity<int>
|
||||
{
|
||||
/// <summary>puzzle_id on the wire. PK.</summary>
|
||||
public int PuzzleId { get => Id; set => Id = value; }
|
||||
|
||||
/// <summary>FK to <see cref="PuzzleGroupEntry"/>. Index this column for mission evaluation.</summary>
|
||||
public int GroupId { get; set; }
|
||||
|
||||
public PuzzleGroupEntry Group { get; set; } = null!;
|
||||
|
||||
/// <summary>0..3 difficulty band.</summary>
|
||||
public int PuzzleDifficulty { get; set; }
|
||||
|
||||
public bool IsAdditional { get; set; }
|
||||
public bool IsPlayable { get; set; } = true;
|
||||
public string ReleaseConditionTextId { get; set; } = string.Empty;
|
||||
}
|
||||
32
SVSim.Database/Models/PuzzleGroupEntry.cs
Normal file
32
SVSim.Database/Models/PuzzleGroupEntry.cs
Normal file
@@ -0,0 +1,32 @@
|
||||
using SVSim.Database.Common;
|
||||
|
||||
namespace SVSim.Database.Models;
|
||||
|
||||
/// <summary>
|
||||
/// One row per basic_puzzle group (puzzle_master_id). Static catalog seeded by
|
||||
/// SVSim.Bootstrap.GlobalsImporter from prod-captures/basic-puzzle-info-*.json.
|
||||
/// See docs/api-spec/endpoints/post-login/basic-puzzle/info.md.
|
||||
/// </summary>
|
||||
public class PuzzleGroupEntry : BaseEntity<int>
|
||||
{
|
||||
/// <summary>puzzle_master_id on the wire. PK + display order key.</summary>
|
||||
public int PuzzleMasterId { get => Id; set => Id = value; }
|
||||
|
||||
/// <summary>SystemText id. "Puzzle_QuestSelect_0301" etc. Client resolves with Data.SystemText.Get.</summary>
|
||||
public string BasicTitleTextId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Character id for the group portrait. Wire as string but stored as int.</summary>
|
||||
public int PuzzleCharaId { get; set; }
|
||||
|
||||
/// <summary>Mission-attribution chara. Usually == PuzzleCharaId but observed group 2 has 3208/2703 split.</summary>
|
||||
public int CharaId { get; set; }
|
||||
|
||||
/// <summary>1 = Special/Expert rounds, 2 = Regular numbered rounds. Drives client display ordering.</summary>
|
||||
public int SortType { get; set; }
|
||||
|
||||
/// <summary>Difficulty-name dict serialized as JSON (e.g. {"Beginner":"0","Experienced":"1","Expert":"2"}).</summary>
|
||||
public string DifficultyNameListJson { get; set; } = "{}";
|
||||
|
||||
// Navigation
|
||||
public List<PuzzleEntry> Puzzles { get; set; } = new();
|
||||
}
|
||||
33
SVSim.Database/Models/PuzzleMissionEntry.cs
Normal file
33
SVSim.Database/Models/PuzzleMissionEntry.cs
Normal file
@@ -0,0 +1,33 @@
|
||||
using SVSim.Database.Common;
|
||||
|
||||
namespace SVSim.Database.Models;
|
||||
|
||||
/// <summary>
|
||||
/// One row per basic_puzzle mission (e.g. "Clear all Round 1 puzzles"). Static catalog
|
||||
/// seeded by SVSim.Bootstrap from prod-captures/basic-puzzle-mission-*.json. The wire has no
|
||||
/// stable id; importer assigns 1-based by capture order via the inherited <see cref="BaseEntity{TKey}.Id"/>.
|
||||
/// See docs/api-spec/endpoints/post-login/basic-puzzle/mission.md.
|
||||
/// </summary>
|
||||
public class PuzzleMissionEntry : BaseEntity<int>
|
||||
{
|
||||
/// <summary>Pre-localized name on the wire. "Clear all Round 1 puzzles".</summary>
|
||||
public string MissionName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Pre-localized achievement banner ("Cleared all Round 1 puzzles"). Derived by importer.</summary>
|
||||
public string AchievedMessage { get; set; } = string.Empty;
|
||||
|
||||
public int RequireNumber { get; set; }
|
||||
public long CampaignCommenceTime { get; set; }
|
||||
public int OrderId { get; set; }
|
||||
|
||||
// Reward (single-entry per mission)
|
||||
public int RewardType { get; set; } // UserGoodsType
|
||||
public long RewardDetailId { get; set; }
|
||||
public int RewardNumber { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Maps Round-N missions to their target group (300+N). NULL for Special-Round missions
|
||||
/// (deferred per Phase 1; they always surface as total_count=0).
|
||||
/// </summary>
|
||||
public int? TargetPuzzleGroupId { get; set; }
|
||||
}
|
||||
@@ -21,6 +21,14 @@ public class ShadowverseDeckEntry : BaseEntity<Guid>
|
||||
public Format Format { get; set; }
|
||||
public 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
|
||||
|
||||
public ClassEntry Class { get; set; } = new ClassEntry();
|
||||
|
||||
22
SVSim.Database/Models/ViewerPuzzleClear.cs
Normal file
22
SVSim.Database/Models/ViewerPuzzleClear.cs
Normal file
@@ -0,0 +1,22 @@
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SVSim.Database.Common;
|
||||
|
||||
namespace SVSim.Database.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Per-viewer record of a cleared puzzle. Composite PK (ViewerId, PuzzleId) — at most one
|
||||
/// row per (viewer, puzzle). NOT a Viewer owned collection on purpose (see CLAUDE.md
|
||||
/// "EF nav include pitfall" — owned collection joins cartesian-explode the viewer graph).
|
||||
/// </summary>
|
||||
[PrimaryKey(nameof(ViewerId), nameof(PuzzleId))]
|
||||
public class ViewerPuzzleClear
|
||||
{
|
||||
public long ViewerId { get; set; }
|
||||
public int PuzzleId { get; set; }
|
||||
|
||||
public DateTime ClearedAt { get; set; }
|
||||
|
||||
/// <summary>Min retry_count across all wins. RetryCount = in-battle reset count, not loss retries.</summary>
|
||||
public int BestRetryCount { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
using SVSim.Database.Models;
|
||||
|
||||
namespace SVSim.Database.Repositories.Globals;
|
||||
|
||||
public interface IPuzzleCatalogRepository
|
||||
{
|
||||
Task<List<PuzzleGroupEntry>> GetAllGroupsWithPuzzles();
|
||||
Task<PuzzleGroupEntry?> GetGroupWithPuzzles(int puzzleMasterId);
|
||||
Task<List<PuzzleMissionEntry>> GetAllMissionsOrdered();
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SVSim.Database.Models;
|
||||
|
||||
namespace SVSim.Database.Repositories.Globals;
|
||||
|
||||
public class PuzzleCatalogRepository : IPuzzleCatalogRepository
|
||||
{
|
||||
private readonly SVSimDbContext _db;
|
||||
public PuzzleCatalogRepository(SVSimDbContext db) => _db = db;
|
||||
|
||||
public Task<List<PuzzleGroupEntry>> GetAllGroupsWithPuzzles() =>
|
||||
_db.PuzzleGroups
|
||||
.Include(g => g.Puzzles)
|
||||
.AsNoTracking()
|
||||
.AsSplitQuery() // avoid the cartesian-explode pitfall (CLAUDE.md)
|
||||
.OrderBy(g => g.Id)
|
||||
.ToListAsync();
|
||||
|
||||
public Task<PuzzleGroupEntry?> GetGroupWithPuzzles(int puzzleMasterId) =>
|
||||
_db.PuzzleGroups
|
||||
.Include(g => g.Puzzles)
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(g => g.Id == puzzleMasterId);
|
||||
|
||||
public Task<List<PuzzleMissionEntry>> GetAllMissionsOrdered() =>
|
||||
_db.PuzzleMissions
|
||||
.AsNoTracking()
|
||||
.OrderBy(m => m.OrderId)
|
||||
.ThenByDescending(m => m.CampaignCommenceTime)
|
||||
.ToListAsync();
|
||||
}
|
||||
15
SVSim.Database/Repositories/Viewer/IPuzzleClearRepository.cs
Normal file
15
SVSim.Database/Repositories/Viewer/IPuzzleClearRepository.cs
Normal file
@@ -0,0 +1,15 @@
|
||||
namespace SVSim.Database.Repositories.Viewer;
|
||||
|
||||
public interface IPuzzleClearRepository
|
||||
{
|
||||
/// <summary>Returns the set of puzzle_ids this viewer has cleared.</summary>
|
||||
Task<HashSet<int>> GetClearedPuzzleIds(long viewerId);
|
||||
|
||||
/// <summary>Returns cleared puzzle_ids grouped by their PuzzleEntry.GroupId. Only groups
|
||||
/// with at least one clear appear in the dictionary.</summary>
|
||||
Task<Dictionary<int, HashSet<int>>> GetClearedPuzzleIdsByGroup(long viewerId);
|
||||
|
||||
/// <summary>Inserts or updates the (viewer, puzzle) clear row. BestRetryCount keeps the
|
||||
/// minimum retry_count across all wins.</summary>
|
||||
Task UpsertClearAsync(long viewerId, int puzzleId, int retryCount);
|
||||
}
|
||||
59
SVSim.Database/Repositories/Viewer/PuzzleClearRepository.cs
Normal file
59
SVSim.Database/Repositories/Viewer/PuzzleClearRepository.cs
Normal file
@@ -0,0 +1,59 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SVSim.Database.Models;
|
||||
|
||||
namespace SVSim.Database.Repositories.Viewer;
|
||||
|
||||
public class PuzzleClearRepository : IPuzzleClearRepository
|
||||
{
|
||||
private readonly SVSimDbContext _db;
|
||||
public PuzzleClearRepository(SVSimDbContext db) => _db = db;
|
||||
|
||||
public async Task<HashSet<int>> GetClearedPuzzleIds(long viewerId)
|
||||
{
|
||||
var ids = await _db.ViewerPuzzleClears
|
||||
.Where(c => c.ViewerId == viewerId)
|
||||
.Select(c => c.PuzzleId)
|
||||
.ToListAsync();
|
||||
return ids.ToHashSet();
|
||||
}
|
||||
|
||||
public async Task<Dictionary<int, HashSet<int>>> GetClearedPuzzleIdsByGroup(long viewerId)
|
||||
{
|
||||
// Join via Puzzles to resolve each cleared PuzzleId to its GroupId.
|
||||
var rows = await (
|
||||
from c in _db.ViewerPuzzleClears
|
||||
where c.ViewerId == viewerId
|
||||
join p in _db.Puzzles on c.PuzzleId equals p.Id
|
||||
select new { p.GroupId, c.PuzzleId }
|
||||
).ToListAsync();
|
||||
|
||||
return rows
|
||||
.GroupBy(r => r.GroupId)
|
||||
.ToDictionary(g => g.Key, g => g.Select(r => r.PuzzleId).ToHashSet());
|
||||
}
|
||||
|
||||
public async Task UpsertClearAsync(long viewerId, int puzzleId, int retryCount)
|
||||
{
|
||||
// CONCURRENCY: this read-then-write is not isolated. Two simultaneous /finish calls
|
||||
// for the same (viewer, puzzle) could both insert and one will lose to the PK. The
|
||||
// wider mission-completion concurrency note lives on PuzzleController.Finish.
|
||||
var existing = await _db.ViewerPuzzleClears
|
||||
.FirstOrDefaultAsync(c => c.ViewerId == viewerId && c.PuzzleId == puzzleId);
|
||||
|
||||
if (existing is null)
|
||||
{
|
||||
_db.ViewerPuzzleClears.Add(new ViewerPuzzleClear
|
||||
{
|
||||
ViewerId = viewerId,
|
||||
PuzzleId = puzzleId,
|
||||
ClearedAt = DateTime.UtcNow,
|
||||
BestRetryCount = retryCount,
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
existing.BestRetryCount = Math.Min(existing.BestRetryCount, retryCount);
|
||||
}
|
||||
await _db.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
@@ -62,6 +62,10 @@ public class SVSimDbContext : DbContext
|
||||
public DbSet<FeatureMaintenanceEntry> FeatureMaintenances => Set<FeatureMaintenanceEntry>();
|
||||
public DbSet<PreReleaseInfo> PreReleaseInfos => Set<PreReleaseInfo>();
|
||||
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
|
||||
|
||||
|
||||
118
SVSim.Database/Services/RewardGrantService.cs
Normal file
118
SVSim.Database/Services/RewardGrantService.cs
Normal file
@@ -0,0 +1,118 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SVSim.Database.Enums;
|
||||
using SVSim.Database.Models;
|
||||
|
||||
namespace SVSim.Database.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Wire-shape returned by <see cref="RewardGrantService.Apply"/>. Field names match the
|
||||
/// <c>reward_list</c> entries used by <c>/pack/open</c> and <c>/basic_puzzle/finish</c>.
|
||||
/// reward_num is a POST-STATE TOTAL for currencies and a count for collection grants — see
|
||||
/// <see cref="Models.RewardListEntry"/>... see SVSim.EmulatedEntrypoint.Models.Dtos.RewardListEntry
|
||||
/// for the on-the-wire DTO and the rationale.
|
||||
/// </summary>
|
||||
public sealed record GrantedReward(int RewardType, long RewardId, int RewardNum);
|
||||
|
||||
/// <summary>
|
||||
/// General reward-grant primitive. Switches on <see cref="UserGoodsType"/>, mutates the
|
||||
/// appropriate viewer collection or <see cref="ViewerCurrency"/> field, and returns the
|
||||
/// wire-shape entry the caller should embed in its response's reward_list.
|
||||
///
|
||||
/// Caller is responsible for <c>SaveChangesAsync</c> — this service only mutates the in-memory
|
||||
/// graph so a controller can stack several grants in a single transaction.
|
||||
/// </summary>
|
||||
public sealed class RewardGrantService
|
||||
{
|
||||
private readonly SVSimDbContext _db;
|
||||
public RewardGrantService(SVSimDbContext db) => _db = db;
|
||||
|
||||
public GrantedReward Apply(Viewer viewer, UserGoodsType type, long detailId, int num)
|
||||
{
|
||||
switch (type)
|
||||
{
|
||||
case UserGoodsType.Sleeve:
|
||||
AddCosmeticIfMissing(viewer.Sleeves, detailId, _db.Sleeves);
|
||||
return new GrantedReward((int)type, detailId, 1);
|
||||
|
||||
case UserGoodsType.Emblem:
|
||||
AddCosmeticIfMissing(viewer.Emblems, detailId, _db.Emblems);
|
||||
return new GrantedReward((int)type, detailId, 1);
|
||||
|
||||
case UserGoodsType.Skin: // LeaderSkin in our schema
|
||||
AddCosmeticIfMissing(viewer.LeaderSkins, detailId, _db.LeaderSkins);
|
||||
return new GrantedReward((int)type, detailId, 1);
|
||||
|
||||
case UserGoodsType.Degree:
|
||||
AddCosmeticIfMissing(viewer.Degrees, detailId, _db.Degrees);
|
||||
return new GrantedReward((int)type, detailId, 1);
|
||||
|
||||
case UserGoodsType.MyPageBG:
|
||||
AddCosmeticIfMissing(viewer.MyPageBackgrounds, detailId, _db.MyPageBackgrounds);
|
||||
return new GrantedReward((int)type, detailId, 1);
|
||||
|
||||
case UserGoodsType.Rupy:
|
||||
viewer.Currency.Rupees += (ulong)num;
|
||||
return new GrantedReward((int)type, detailId, checked((int)viewer.Currency.Rupees));
|
||||
|
||||
case UserGoodsType.Crystal:
|
||||
viewer.Currency.Crystals += (ulong)num;
|
||||
return new GrantedReward((int)type, detailId, checked((int)viewer.Currency.Crystals));
|
||||
|
||||
case UserGoodsType.RedEther:
|
||||
viewer.Currency.RedEther += (ulong)num;
|
||||
return new GrantedReward((int)type, detailId, checked((int)viewer.Currency.RedEther));
|
||||
|
||||
case UserGoodsType.Item:
|
||||
{
|
||||
var owned = viewer.Items.FirstOrDefault(i => i.Item.Id == (int)detailId);
|
||||
if (owned is null)
|
||||
{
|
||||
var item = _db.Items.Find((int)detailId)
|
||||
?? throw new InvalidOperationException($"Item {detailId} not in catalog");
|
||||
viewer.Items.Add(new OwnedItemEntry { Item = item, Count = num, Viewer = viewer });
|
||||
return new GrantedReward((int)type, detailId, num);
|
||||
}
|
||||
owned.Count += num;
|
||||
return new GrantedReward((int)type, detailId, owned.Count);
|
||||
}
|
||||
|
||||
case UserGoodsType.Card:
|
||||
case UserGoodsType.SpotCard:
|
||||
case UserGoodsType.SpotCardOnlyLatestCardPack:
|
||||
throw new NotSupportedException(
|
||||
$"{type} rewards are out of Phase 1 scope — extend RewardGrantService when /pack/open or similar needs them.");
|
||||
|
||||
default:
|
||||
throw new NotSupportedException($"UserGoodsType {type} not yet handled by RewardGrantService");
|
||||
}
|
||||
}
|
||||
|
||||
private static void AddCosmeticIfMissing<T>(List<T> collection, long detailId, DbSet<T> catalog) where T : class
|
||||
{
|
||||
// Cosmetic ownership is binary — if the viewer already owns it, the grant is a no-op
|
||||
// (matches client UpdateHaveUserGoodsNum behaviour which just calls .Acquired() each time).
|
||||
bool alreadyOwned = collection.Any(e => GetId(e) == detailId);
|
||||
if (alreadyOwned) return;
|
||||
|
||||
// Wire reward_detail_id is long, but every cosmetic catalog in this codebase uses
|
||||
// BaseEntity<int>; downcast for Find. The checked() throws OverflowException if a
|
||||
// future capture ships a real long id rather than silently truncating it.
|
||||
var entity = catalog.Find(checked((int)detailId))
|
||||
?? throw new InvalidOperationException(
|
||||
$"Cosmetic id {detailId} not in catalog for type {typeof(T).Name}");
|
||||
collection.Add(entity);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reflectively reads an entity's Id property — works for both <c>BaseEntity<int></c>
|
||||
/// (cosmetics) and <c>BaseEntity<long></c> (e.g. Viewer/Card) without forcing two
|
||||
/// non-generic overloads of <see cref="AddCosmeticIfMissing"/>.
|
||||
/// </summary>
|
||||
private static long GetId<T>(T e)
|
||||
{
|
||||
var prop = typeof(T).GetProperty("Id")
|
||||
?? throw new InvalidOperationException($"Type {typeof(T).Name} missing Id property");
|
||||
var val = prop.GetValue(e);
|
||||
return val switch { long l => l, int i => i, _ => 0 };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user