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

View File

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

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

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

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