From 03179a232460601685dd1d2d35ef2ba02671acfd Mon Sep 17 00:00:00 2001 From: Sean-mn Date: Thu, 2 Jul 2026 13:33:06 +0900 Subject: [PATCH 1/9] =?UTF-8?q?feat:=20PlayerDungeonProgress=20=EC=97=94?= =?UTF-8?q?=ED=8B=B0=ED=8B=B0=20=EB=B0=8F=20=EB=A7=88=EC=9D=B4=EA=B7=B8?= =?UTF-8?q?=EB=A0=88=EC=9D=B4=EC=85=98=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Config/PlayerDungeonProgressConfig.cs | 51 ++ .../Dungeon/Entity/PlayerDungeonProgress.cs | 34 + .../Domain/Dungeon/Enum/DungeonType.cs | 9 + .../Global/Infrastructure/AppDbContext.cs | 1 + ...34853_AddPlayerDungeonProgress.Designer.cs | 660 ++++++++++++++++++ ...20260701234853_AddPlayerDungeonProgress.cs | 57 ++ .../Migrations/AppDbContextModelSnapshot.cs | 51 ++ 7 files changed, 863 insertions(+) create mode 100644 Fantasy-server/Fantasy.Server/Domain/Dungeon/Entity/Config/PlayerDungeonProgressConfig.cs create mode 100644 Fantasy-server/Fantasy.Server/Domain/Dungeon/Entity/PlayerDungeonProgress.cs create mode 100644 Fantasy-server/Fantasy.Server/Domain/Dungeon/Enum/DungeonType.cs create mode 100644 Fantasy-server/Fantasy.Server/Migrations/20260701234853_AddPlayerDungeonProgress.Designer.cs create mode 100644 Fantasy-server/Fantasy.Server/Migrations/20260701234853_AddPlayerDungeonProgress.cs diff --git a/Fantasy-server/Fantasy.Server/Domain/Dungeon/Entity/Config/PlayerDungeonProgressConfig.cs b/Fantasy-server/Fantasy.Server/Domain/Dungeon/Entity/Config/PlayerDungeonProgressConfig.cs new file mode 100644 index 0000000..3571615 --- /dev/null +++ b/Fantasy-server/Fantasy.Server/Domain/Dungeon/Entity/Config/PlayerDungeonProgressConfig.cs @@ -0,0 +1,51 @@ +using Fantasy.Server.Domain.Dungeon.Entity; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using PlayerEntity = Fantasy.Server.Domain.Player.Entity.Player; + +namespace Fantasy.Server.Domain.Dungeon.Entity.Config; + +public class PlayerDungeonProgressConfig : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("player_dungeon_progress", "dungeon"); + + builder.HasKey(p => p.Id); + + builder.Property(p => p.Id) + .ValueGeneratedOnAdd(); + + builder.Property(p => p.PlayerId) + .IsRequired(); + + builder.Property(p => p.DungeonType) + .HasConversion() + .IsRequired(); + + builder.Property(p => p.HighestClearedStage) + .IsRequired() + .HasDefaultValue(1L); + + builder.Property(p => p.HighScore) + .IsRequired() + .HasDefaultValue(0L); + + builder.Property(p => p.LastClearedAt) + .IsRequired(false); + + builder.HasIndex(p => new { p.PlayerId, p.DungeonType }) + .IsUnique(); + + builder.HasOne() + .WithMany() + .HasForeignKey(p => p.PlayerId) + .OnDelete(DeleteBehavior.Cascade); + + builder.Property("xmin") + .HasColumnName("xmin") + .HasColumnType("xid") + .ValueGeneratedOnAddOrUpdate() + .IsConcurrencyToken(); + } +} diff --git a/Fantasy-server/Fantasy.Server/Domain/Dungeon/Entity/PlayerDungeonProgress.cs b/Fantasy-server/Fantasy.Server/Domain/Dungeon/Entity/PlayerDungeonProgress.cs new file mode 100644 index 0000000..98ecb7e --- /dev/null +++ b/Fantasy-server/Fantasy.Server/Domain/Dungeon/Entity/PlayerDungeonProgress.cs @@ -0,0 +1,34 @@ +using Fantasy.Server.Domain.Dungeon.Enum; + +namespace Fantasy.Server.Domain.Dungeon.Entity; + +public class PlayerDungeonProgress +{ + public long Id { get; private set; } + public long PlayerId { get; private set; } + public DungeonType DungeonType { get; private set; } + public long HighestClearedStage { get; private set; } + public long HighScore { get; private set; } + public DateTime? LastClearedAt { get; private set; } + + public static PlayerDungeonProgress Create(long playerId, DungeonType dungeonType) => new() + { + PlayerId = playerId, + DungeonType = dungeonType, + HighestClearedStage = 1, + HighScore = 0, + LastClearedAt = null + }; + + public void ClearStage(long stage) + { + if (stage > HighestClearedStage) HighestClearedStage = stage; + LastClearedAt = DateTime.UtcNow; + } + + public void UpdateHighScore(long score) + { + if (score > HighScore) HighScore = score; + LastClearedAt = DateTime.UtcNow; + } +} diff --git a/Fantasy-server/Fantasy.Server/Domain/Dungeon/Enum/DungeonType.cs b/Fantasy-server/Fantasy.Server/Domain/Dungeon/Enum/DungeonType.cs new file mode 100644 index 0000000..0a92650 --- /dev/null +++ b/Fantasy-server/Fantasy.Server/Domain/Dungeon/Enum/DungeonType.cs @@ -0,0 +1,9 @@ +namespace Fantasy.Server.Domain.Dungeon.Enum; + +public enum DungeonType +{ + Basic, + Gold, + Weapon, + Boss +} diff --git a/Fantasy-server/Fantasy.Server/Global/Infrastructure/AppDbContext.cs b/Fantasy-server/Fantasy.Server/Global/Infrastructure/AppDbContext.cs index 9399354..2b9cf90 100644 --- a/Fantasy-server/Fantasy.Server/Global/Infrastructure/AppDbContext.cs +++ b/Fantasy-server/Fantasy.Server/Global/Infrastructure/AppDbContext.cs @@ -25,6 +25,7 @@ public AppDbContext(DbContextOptions options) public DbSet AccountDungeonTickets => Set(); public DbSet GoldDungeonRuns => Set(); + public DbSet PlayerDungeonProgresses => Set(); public DbSet LevelTables => Set(); public DbSet WeaponDatas => Set(); diff --git a/Fantasy-server/Fantasy.Server/Migrations/20260701234853_AddPlayerDungeonProgress.Designer.cs b/Fantasy-server/Fantasy.Server/Migrations/20260701234853_AddPlayerDungeonProgress.Designer.cs new file mode 100644 index 0000000..fc4401b --- /dev/null +++ b/Fantasy-server/Fantasy.Server/Migrations/20260701234853_AddPlayerDungeonProgress.Designer.cs @@ -0,0 +1,660 @@ +// +using System; +using Fantasy.Server.Global.Infrastructure; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Fantasy.Server.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260701234853_AddPlayerDungeonProgress")] + partial class AddPlayerDungeonProgress + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.5") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Fantasy.Server.Domain.Account.Entity.Account", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("IsNewAccount") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("Password") + .IsRequired() + .HasColumnType("text"); + + b.Property("Role") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique(); + + b.ToTable("account", "account"); + }); + + modelBuilder.Entity("Fantasy.Server.Domain.Dungeon.Entity.AccountDungeonTicket", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("bigint"); + + b.Property("DailyAdRewardClaimedDate") + .HasColumnType("date"); + + b.Property("LastDailyGrantDate") + .HasColumnType("date"); + + b.Property("TicketCount") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0); + + b.Property("xmin") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.HasKey("Id"); + + b.HasIndex("AccountId") + .IsUnique(); + + b.ToTable("account_dungeon_ticket", "dungeon"); + }); + + modelBuilder.Entity("Fantasy.Server.Domain.Dungeon.Entity.GoldDungeonRun", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AccountId") + .HasColumnType("bigint"); + + b.Property("ClaimedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ClaimedClicks") + .HasColumnType("integer"); + + b.Property("DurationSeconds") + .HasColumnType("integer"); + + b.Property("EarnedGold") + .HasColumnType("bigint"); + + b.Property("EarnedMithril") + .HasColumnType("integer"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("MaxClicks") + .HasColumnType("integer"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("xmin") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.ToTable("gold_dungeon_run", "dungeon"); + }); + + modelBuilder.Entity("Fantasy.Server.Domain.Dungeon.Entity.PlayerDungeonProgress", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("DungeonType") + .IsRequired() + .HasColumnType("text"); + + b.Property("HighScore") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasDefaultValue(0L); + + b.Property("HighestClearedStage") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasDefaultValue(1L); + + b.Property("LastClearedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PlayerId") + .HasColumnType("bigint"); + + b.Property("xmin") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId", "DungeonType") + .IsUnique(); + + b.ToTable("player_dungeon_progress", "dungeon"); + }); + + modelBuilder.Entity("Fantasy.Server.Domain.GameData.Entity.JobBaseStat", b => + { + b.Property("JobType") + .HasColumnType("text"); + + b.Property("AtkPerLevel") + .HasColumnType("double precision"); + + b.Property("BaseAtk") + .HasColumnType("bigint"); + + b.Property("BaseHp") + .HasColumnType("bigint"); + + b.Property("CritDmgMultiplier") + .HasColumnType("double precision"); + + b.Property("CritRate") + .HasColumnType("double precision"); + + b.Property("HpPerLevel") + .HasColumnType("double precision"); + + b.HasKey("JobType"); + + b.ToTable("job_base_stat", "game_data"); + }); + + modelBuilder.Entity("Fantasy.Server.Domain.GameData.Entity.LevelTable", b => + { + b.Property("Level") + .HasColumnType("bigint"); + + b.Property("RequiredExp") + .HasColumnType("bigint"); + + b.Property("RewardSp") + .HasColumnType("bigint"); + + b.HasKey("Level"); + + b.ToTable("level_table", "game_data"); + }); + + modelBuilder.Entity("Fantasy.Server.Domain.GameData.Entity.SkillData", b => + { + b.Property("SkillId") + .HasColumnType("integer"); + + b.Property("EffectType") + .IsRequired() + .HasColumnType("text"); + + b.Property("EffectValue") + .HasColumnType("double precision"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("JobType") + .IsRequired() + .HasColumnType("text"); + + b.Property("PrereqSkillId") + .HasColumnType("integer"); + + b.Property("SpCost") + .HasColumnType("bigint"); + + b.HasKey("SkillId"); + + b.ToTable("skill_data", "game_data"); + }); + + modelBuilder.Entity("Fantasy.Server.Domain.GameData.Entity.StageData", b => + { + b.Property("Stage") + .HasColumnType("bigint"); + + b.Property("GoldPerSecond") + .HasColumnType("bigint"); + + b.Property("IsBossStage") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("MonsterAtk") + .HasColumnType("bigint"); + + b.Property("MonsterHp") + .HasColumnType("bigint"); + + b.Property("XpPerSecond") + .HasColumnType("bigint"); + + b.HasKey("Stage"); + + b.ToTable("stage_data", "game_data"); + }); + + modelBuilder.Entity("Fantasy.Server.Domain.GameData.Entity.WeaponData", b => + { + b.Property("WeaponId") + .HasColumnType("integer"); + + b.Property("AtkPerEnhancement") + .HasColumnType("bigint"); + + b.Property("BaseAtk") + .HasColumnType("bigint"); + + b.Property("Grade") + .IsRequired() + .HasColumnType("text"); + + b.Property("JobType") + .IsRequired() + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("WeaponId"); + + b.ToTable("weapon_data", "game_data"); + }); + + modelBuilder.Entity("Fantasy.Server.Domain.Player.Entity.Player", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Exp") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasDefaultValue(0L); + + b.Property("JobType") + .IsRequired() + .HasColumnType("text"); + + b.Property("Level") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasDefaultValue(1L); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("AccountId") + .IsUnique(); + + b.ToTable("player", "player"); + }); + + modelBuilder.Entity("Fantasy.Server.Domain.Player.Entity.PlayerResource", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("EnhancementScroll") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasDefaultValue(0L); + + b.Property("Gold") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasDefaultValue(0L); + + b.Property("Mithril") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasDefaultValue(0L); + + b.Property("PlayerId") + .HasColumnType("bigint"); + + b.Property("SmithGrade") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0); + + b.Property("Sp") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasDefaultValue(0L); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("xmin") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId") + .IsUnique(); + + b.ToTable("player_resource", "player"); + }); + + modelBuilder.Entity("Fantasy.Server.Domain.Player.Entity.PlayerSession", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.PrimitiveCollection("ActiveSkills") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("integer[]") + .HasDefaultValueSql("ARRAY[]::integer[]"); + + b.Property("LastWeaponId") + .HasColumnType("integer"); + + b.Property("PlayerId") + .HasColumnType("bigint"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("xmin") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId") + .IsUnique(); + + b.ToTable("player_session", "player"); + }); + + modelBuilder.Entity("Fantasy.Server.Domain.Player.Entity.PlayerSkill", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("IsUnlocked") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("PlayerId") + .HasColumnType("bigint"); + + b.Property("SkillId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId", "SkillId") + .IsUnique(); + + b.ToTable("player_skill", "player"); + }); + + modelBuilder.Entity("Fantasy.Server.Domain.Player.Entity.PlayerStage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("LastCalculatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("NOW()"); + + b.Property("MaxStage") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasDefaultValue(1L); + + b.Property("PlayerId") + .HasColumnType("bigint"); + + b.Property("xmin") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId") + .IsUnique(); + + b.ToTable("player_stage", "player"); + }); + + modelBuilder.Entity("Fantasy.Server.Domain.Player.Entity.PlayerWeapon", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AwakeningCount") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasDefaultValue(0L); + + b.Property("Count") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasDefaultValue(0L); + + b.Property("EnhancementLevel") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasDefaultValue(0L); + + b.Property("PlayerId") + .HasColumnType("bigint"); + + b.Property("WeaponId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId", "WeaponId") + .IsUnique(); + + b.ToTable("player_weapon", "player"); + }); + + modelBuilder.Entity("Fantasy.Server.Domain.Tutorial.Entity.PlayerTutorial", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PlayerId") + .HasColumnType("bigint"); + + b.Property("TutorialId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId", "TutorialId") + .IsUnique(); + + b.ToTable("player_tutorial", "tutorial"); + }); + + modelBuilder.Entity("Fantasy.Server.Domain.Dungeon.Entity.AccountDungeonTicket", b => + { + b.HasOne("Fantasy.Server.Domain.Account.Entity.Account", null) + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Fantasy.Server.Domain.Dungeon.Entity.GoldDungeonRun", b => + { + b.HasOne("Fantasy.Server.Domain.Account.Entity.Account", null) + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Fantasy.Server.Domain.Dungeon.Entity.PlayerDungeonProgress", b => + { + b.HasOne("Fantasy.Server.Domain.Player.Entity.Player", null) + .WithMany() + .HasForeignKey("PlayerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Fantasy.Server.Domain.Player.Entity.PlayerResource", b => + { + b.HasOne("Fantasy.Server.Domain.Player.Entity.Player", null) + .WithOne() + .HasForeignKey("Fantasy.Server.Domain.Player.Entity.PlayerResource", "PlayerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Fantasy.Server.Domain.Player.Entity.PlayerSession", b => + { + b.HasOne("Fantasy.Server.Domain.Player.Entity.Player", null) + .WithOne() + .HasForeignKey("Fantasy.Server.Domain.Player.Entity.PlayerSession", "PlayerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Fantasy.Server.Domain.Player.Entity.PlayerSkill", b => + { + b.HasOne("Fantasy.Server.Domain.Player.Entity.Player", null) + .WithMany() + .HasForeignKey("PlayerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Fantasy.Server.Domain.Player.Entity.PlayerStage", b => + { + b.HasOne("Fantasy.Server.Domain.Player.Entity.Player", null) + .WithOne() + .HasForeignKey("Fantasy.Server.Domain.Player.Entity.PlayerStage", "PlayerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Fantasy.Server.Domain.Player.Entity.PlayerWeapon", b => + { + b.HasOne("Fantasy.Server.Domain.Player.Entity.Player", null) + .WithMany() + .HasForeignKey("PlayerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Fantasy.Server.Domain.Tutorial.Entity.PlayerTutorial", b => + { + b.HasOne("Fantasy.Server.Domain.Player.Entity.Player", null) + .WithMany() + .HasForeignKey("PlayerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Fantasy-server/Fantasy.Server/Migrations/20260701234853_AddPlayerDungeonProgress.cs b/Fantasy-server/Fantasy.Server/Migrations/20260701234853_AddPlayerDungeonProgress.cs new file mode 100644 index 0000000..cf314d6 --- /dev/null +++ b/Fantasy-server/Fantasy.Server/Migrations/20260701234853_AddPlayerDungeonProgress.cs @@ -0,0 +1,57 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Fantasy.Server.Migrations +{ + /// + public partial class AddPlayerDungeonProgress : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "player_dungeon_progress", + schema: "dungeon", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + PlayerId = table.Column(type: "bigint", nullable: false), + DungeonType = table.Column(type: "text", nullable: false), + HighestClearedStage = table.Column(type: "bigint", nullable: false, defaultValue: 1L), + HighScore = table.Column(type: "bigint", nullable: false, defaultValue: 0L), + LastClearedAt = table.Column(type: "timestamp with time zone", nullable: true), + xmin = table.Column(type: "xid", rowVersion: true, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_player_dungeon_progress", x => x.Id); + table.ForeignKey( + name: "FK_player_dungeon_progress_player_PlayerId", + column: x => x.PlayerId, + principalSchema: "player", + principalTable: "player", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_player_dungeon_progress_PlayerId_DungeonType", + schema: "dungeon", + table: "player_dungeon_progress", + columns: new[] { "PlayerId", "DungeonType" }, + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "player_dungeon_progress", + schema: "dungeon"); + } + } +} diff --git a/Fantasy-server/Fantasy.Server/Migrations/AppDbContextModelSnapshot.cs b/Fantasy-server/Fantasy.Server/Migrations/AppDbContextModelSnapshot.cs index 04f5b40..143981b 100644 --- a/Fantasy-server/Fantasy.Server/Migrations/AppDbContextModelSnapshot.cs +++ b/Fantasy-server/Fantasy.Server/Migrations/AppDbContextModelSnapshot.cs @@ -144,6 +144,48 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("gold_dungeon_run", "dungeon"); }); + modelBuilder.Entity("Fantasy.Server.Domain.Dungeon.Entity.PlayerDungeonProgress", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("DungeonType") + .IsRequired() + .HasColumnType("text"); + + b.Property("HighScore") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasDefaultValue(0L); + + b.Property("HighestClearedStage") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasDefaultValue(1L); + + b.Property("LastClearedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PlayerId") + .HasColumnType("bigint"); + + b.Property("xmin") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId", "DungeonType") + .IsUnique(); + + b.ToTable("player_dungeon_progress", "dungeon"); + }); + modelBuilder.Entity("Fantasy.Server.Domain.GameData.Entity.JobBaseStat", b => { b.Property("JobType") @@ -547,6 +589,15 @@ protected override void BuildModel(ModelBuilder modelBuilder) .IsRequired(); }); + modelBuilder.Entity("Fantasy.Server.Domain.Dungeon.Entity.PlayerDungeonProgress", b => + { + b.HasOne("Fantasy.Server.Domain.Player.Entity.Player", null) + .WithMany() + .HasForeignKey("PlayerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + modelBuilder.Entity("Fantasy.Server.Domain.Player.Entity.PlayerResource", b => { b.HasOne("Fantasy.Server.Domain.Player.Entity.Player", null) From 137cd9224f9d247bc073380e589020ce5e8ee8b2 Mon Sep 17 00:00:00 2001 From: Sean-mn Date: Thu, 2 Jul 2026 13:33:16 +0900 Subject: [PATCH 2/9] =?UTF-8?q?feat:=20PlayerDungeonProgressRepository=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20=EB=B0=8F=20DI=20=EB=93=B1=EB=A1=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Dungeon/Config/DungeonServiceConfig.cs | 1 + .../IPlayerDungeonProgressRepository.cs | 11 ++ .../PlayerDungeonProgressRepository.cs | 31 +++++ .../PlayerDungeonProgressRepositoryTests.cs | 114 ++++++++++++++++++ 4 files changed, 157 insertions(+) create mode 100644 Fantasy-server/Fantasy.Server/Domain/Dungeon/Repository/Interface/IPlayerDungeonProgressRepository.cs create mode 100644 Fantasy-server/Fantasy.Server/Domain/Dungeon/Repository/PlayerDungeonProgressRepository.cs create mode 100644 Fantasy-server/Fantasy.Test/Dungeon/Repository/PlayerDungeonProgressRepositoryTests.cs diff --git a/Fantasy-server/Fantasy.Server/Domain/Dungeon/Config/DungeonServiceConfig.cs b/Fantasy-server/Fantasy.Server/Domain/Dungeon/Config/DungeonServiceConfig.cs index 656b966..f622018 100644 --- a/Fantasy-server/Fantasy.Server/Domain/Dungeon/Config/DungeonServiceConfig.cs +++ b/Fantasy-server/Fantasy.Server/Domain/Dungeon/Config/DungeonServiceConfig.cs @@ -18,6 +18,7 @@ public static IServiceCollection AddDungeonServices(this IServiceCollection serv services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/Fantasy-server/Fantasy.Server/Domain/Dungeon/Repository/Interface/IPlayerDungeonProgressRepository.cs b/Fantasy-server/Fantasy.Server/Domain/Dungeon/Repository/Interface/IPlayerDungeonProgressRepository.cs new file mode 100644 index 0000000..75e1db0 --- /dev/null +++ b/Fantasy-server/Fantasy.Server/Domain/Dungeon/Repository/Interface/IPlayerDungeonProgressRepository.cs @@ -0,0 +1,11 @@ +using Fantasy.Server.Domain.Dungeon.Entity; +using Fantasy.Server.Domain.Dungeon.Enum; + +namespace Fantasy.Server.Domain.Dungeon.Repository.Interface; + +public interface IPlayerDungeonProgressRepository +{ + Task FindByPlayerIdAndDungeonTypeAsync(long playerId, DungeonType dungeonType); + Task SaveAsync(PlayerDungeonProgress progress); + Task UpdateAsync(PlayerDungeonProgress progress); +} diff --git a/Fantasy-server/Fantasy.Server/Domain/Dungeon/Repository/PlayerDungeonProgressRepository.cs b/Fantasy-server/Fantasy.Server/Domain/Dungeon/Repository/PlayerDungeonProgressRepository.cs new file mode 100644 index 0000000..890ae4a --- /dev/null +++ b/Fantasy-server/Fantasy.Server/Domain/Dungeon/Repository/PlayerDungeonProgressRepository.cs @@ -0,0 +1,31 @@ +using Fantasy.Server.Domain.Dungeon.Entity; +using Fantasy.Server.Domain.Dungeon.Enum; +using Fantasy.Server.Domain.Dungeon.Repository.Interface; +using Fantasy.Server.Global.Infrastructure; +using Microsoft.EntityFrameworkCore; + +namespace Fantasy.Server.Domain.Dungeon.Repository; + +public class PlayerDungeonProgressRepository : IPlayerDungeonProgressRepository +{ + private readonly AppDbContext _db; + + public PlayerDungeonProgressRepository(AppDbContext db) => _db = db; + + public async Task FindByPlayerIdAndDungeonTypeAsync(long playerId, DungeonType dungeonType) + => await _db.PlayerDungeonProgresses + .AsNoTracking() + .FirstOrDefaultAsync(p => p.PlayerId == playerId && p.DungeonType == dungeonType); + + public async Task SaveAsync(PlayerDungeonProgress progress) + { + await _db.PlayerDungeonProgresses.AddAsync(progress); + await _db.SaveChangesAsync(); + } + + public async Task UpdateAsync(PlayerDungeonProgress progress) + { + _db.PlayerDungeonProgresses.Update(progress); + await _db.SaveChangesAsync(); + } +} diff --git a/Fantasy-server/Fantasy.Test/Dungeon/Repository/PlayerDungeonProgressRepositoryTests.cs b/Fantasy-server/Fantasy.Test/Dungeon/Repository/PlayerDungeonProgressRepositoryTests.cs new file mode 100644 index 0000000..4b67d25 --- /dev/null +++ b/Fantasy-server/Fantasy.Test/Dungeon/Repository/PlayerDungeonProgressRepositoryTests.cs @@ -0,0 +1,114 @@ +using Fantasy.Server.Domain.Dungeon.Entity; +using Fantasy.Server.Domain.Dungeon.Enum; +using Fantasy.Server.Domain.Dungeon.Repository; +using Fantasy.Server.Domain.GameData.Entity; +using Fantasy.Server.Global.Infrastructure; +using FluentAssertions; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Xunit; + +namespace Fantasy.Test.Dungeon.Repository; + +public class PlayerDungeonProgressRepositoryTests : IDisposable +{ + private readonly SqliteConnection _connection; + private readonly AppDbContext _dbContext; + private readonly PlayerDungeonProgressRepository _sut; + + public PlayerDungeonProgressRepositoryTests() + { + _connection = new SqliteConnection("DataSource=:memory:"); + _connection.Open(); + + var options = new DbContextOptionsBuilder() + .UseSqlite(_connection) + .Options; + + _dbContext = new TestAppDbContext(options); + _dbContext.Database.EnsureCreated(); + _sut = new PlayerDungeonProgressRepository(_dbContext); + } + + [Fact] + public async Task FindByPlayerIdAndDungeonTypeAsync_존재하지_않으면_null을_반환한다() + { + var result = await _sut.FindByPlayerIdAndDungeonTypeAsync(1L, DungeonType.Weapon); + + result.Should().BeNull(); + } + + [Fact] + public async Task SaveAsync_저장한_레코드를_FindByPlayerIdAndDungeonTypeAsync로_조회할_수_있다() + { + await _sut.SaveAsync(PlayerDungeonProgress.Create(1L, DungeonType.Weapon)); + + var result = await _sut.FindByPlayerIdAndDungeonTypeAsync(1L, DungeonType.Weapon); + + result.Should().NotBeNull(); + result!.HighestClearedStage.Should().Be(1); + } + + [Fact] + public async Task UpdateAsync_변경한_값이_반영된다() + { + var progress = PlayerDungeonProgress.Create(1L, DungeonType.Gold); + await _sut.SaveAsync(progress); + + progress.UpdateHighScore(500L); + await _sut.UpdateAsync(progress); + + var result = await _sut.FindByPlayerIdAndDungeonTypeAsync(1L, DungeonType.Gold); + result!.HighScore.Should().Be(500L); + } + + [Fact] + public async Task 다른_DungeonType은_독립적으로_저장된다() + { + await _sut.SaveAsync(PlayerDungeonProgress.Create(1L, DungeonType.Weapon)); + await _sut.SaveAsync(PlayerDungeonProgress.Create(1L, DungeonType.Boss)); + + var weapon = await _sut.FindByPlayerIdAndDungeonTypeAsync(1L, DungeonType.Weapon); + var boss = await _sut.FindByPlayerIdAndDungeonTypeAsync(1L, DungeonType.Boss); + + weapon.Should().NotBeNull(); + boss.Should().NotBeNull(); + weapon!.DungeonType.Should().Be(DungeonType.Weapon); + boss!.DungeonType.Should().Be(DungeonType.Boss); + } + + public void Dispose() + { + _dbContext.Dispose(); + _connection.Dispose(); + } + + private sealed class TestAppDbContext : AppDbContext + { + public TestAppDbContext(DbContextOptions options) + : base(options) + { + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Ignore(); + modelBuilder.Ignore(); + modelBuilder.Ignore(); + modelBuilder.Ignore(); + modelBuilder.Ignore(); + + modelBuilder.Entity(entity => + { + entity.ToTable("player_dungeon_progress"); + entity.HasKey(p => p.Id); + entity.Property(p => p.Id).ValueGeneratedOnAdd(); + entity.Property(p => p.PlayerId).IsRequired(); + entity.Property(p => p.DungeonType).HasConversion().IsRequired(); + entity.Property(p => p.HighestClearedStage).IsRequired(); + entity.Property(p => p.HighScore).IsRequired(); + entity.HasIndex(p => new { p.PlayerId, p.DungeonType }).IsUnique(); + }); + } + } +} From 69e0476cbdbb7b5d59aefbbcb01fbe49a9c8cbf6 Mon Sep 17 00:00:00 2001 From: Sean-mn Date: Thu, 2 Jul 2026 13:33:16 +0900 Subject: [PATCH 3/9] =?UTF-8?q?feat:=20=EB=AC=B4=EA=B8=B0=20=EB=8D=98?= =?UTF-8?q?=EC=A0=84=EC=97=90=20=EB=8F=85=EB=A6=BD=20=EC=A7=84=ED=96=89?= =?UTF-8?q?=EB=8F=84=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Dto/Response/WeaponDungeonResponse.cs | 3 +- .../Dungeon/Service/WeaponDungeonService.cs | 53 ++++-- .../Service/WeaponDungeonServiceTests.cs | 177 +++++++++++++++++- 3 files changed, 216 insertions(+), 17 deletions(-) diff --git a/Fantasy-server/Fantasy.Server/Domain/Dungeon/Dto/Response/WeaponDungeonResponse.cs b/Fantasy-server/Fantasy.Server/Domain/Dungeon/Dto/Response/WeaponDungeonResponse.cs index bfb120d..875e474 100644 --- a/Fantasy-server/Fantasy.Server/Domain/Dungeon/Dto/Response/WeaponDungeonResponse.cs +++ b/Fantasy-server/Fantasy.Server/Domain/Dungeon/Dto/Response/WeaponDungeonResponse.cs @@ -3,5 +3,6 @@ namespace Fantasy.Server.Domain.Dungeon.Dto.Response; public record WeaponDungeonResponse( bool Cleared, List DroppedWeapons, - long DroppedScrolls + long DroppedScrolls, + long HighestClearedStage ); diff --git a/Fantasy-server/Fantasy.Server/Domain/Dungeon/Service/WeaponDungeonService.cs b/Fantasy-server/Fantasy.Server/Domain/Dungeon/Service/WeaponDungeonService.cs index 34cb47b..5244d19 100644 --- a/Fantasy-server/Fantasy.Server/Domain/Dungeon/Service/WeaponDungeonService.cs +++ b/Fantasy-server/Fantasy.Server/Domain/Dungeon/Service/WeaponDungeonService.cs @@ -1,10 +1,14 @@ using Fantasy.Server.Domain.Dungeon.Dto.Response; +using Fantasy.Server.Domain.Dungeon.Entity; +using Fantasy.Server.Domain.Dungeon.Enum; +using Fantasy.Server.Domain.Dungeon.Repository.Interface; using Fantasy.Server.Domain.Dungeon.Service.Interface; using Fantasy.Server.Domain.GameData.Entity; using Fantasy.Server.Domain.GameData.Enum; using Fantasy.Server.Domain.GameData.Service.Interface; using Fantasy.Server.Domain.Player.Dto.Request; using Fantasy.Server.Domain.Player.Repository.Interface; +using Fantasy.Server.Global.Infrastructure; using Fantasy.Server.Global.Security.Provider; using Gamism.SDK.Extensions.AspNetCore.Exceptions; @@ -24,6 +28,8 @@ public class WeaponDungeonService : IWeaponDungeonService private readonly IPlayerSkillRepository _playerSkillRepository; private readonly IPlayerRedisRepository _playerRedisRepository; private readonly IGameDataCacheService _gameDataCacheService; + private readonly IPlayerDungeonProgressRepository _playerDungeonProgressRepository; + private readonly IAppDbTransactionRunner _transactionRunner; private readonly ICurrentUserProvider _currentUserProvider; private readonly ICombatStatCalculator _calculator; @@ -36,6 +42,8 @@ public WeaponDungeonService( IPlayerSkillRepository playerSkillRepository, IPlayerRedisRepository playerRedisRepository, IGameDataCacheService gameDataCacheService, + IPlayerDungeonProgressRepository playerDungeonProgressRepository, + IAppDbTransactionRunner transactionRunner, ICurrentUserProvider currentUserProvider, ICombatStatCalculator calculator) { @@ -47,6 +55,8 @@ public WeaponDungeonService( _playerSkillRepository = playerSkillRepository; _playerRedisRepository = playerRedisRepository; _gameDataCacheService = gameDataCacheService; + _playerDungeonProgressRepository = playerDungeonProgressRepository; + _transactionRunner = transactionRunner; _currentUserProvider = currentUserProvider; _calculator = calculator; } @@ -93,9 +103,12 @@ public async Task ExecuteAsync() var combatStat = _calculator.Calculate(player.Level, jobStat, weaponData, weaponEnhancement, unlockedPassiveSkills); - var stageData = await _gameDataCacheService.GetStageDataAsync(stage.MaxStage); - if (stageData is null) - throw new NotFoundException("스테이지 데이터를 찾을 수 없습니다."); + var progress = await _playerDungeonProgressRepository.FindByPlayerIdAndDungeonTypeAsync(player.Id, DungeonType.Weapon); + var isNewProgress = progress is null; + var currentStage = progress?.HighestClearedStage ?? 1; + + var stageData = await _gameDataCacheService.GetStageDataAsync(currentStage) + ?? throw new NotFoundException("스테이지 데이터를 찾을 수 없습니다."); var dps = _calculator.CalculateDps(combatStat); var cleared = dps * 30 >= stageData.MonsterHp; @@ -131,10 +144,10 @@ public async Task ExecuteAsync() // 스크롤 드랍 시도 if (Random.Shared.Next(0, 100) < ScrollDropRatePercent) droppedScrolls = 1; - } - if (droppedWeapons.Count > 0 || droppedScrolls > 0) - { + progress ??= PlayerDungeonProgress.Create(player.Id, DungeonType.Weapon); + progress.ClearStage(currentStage + 1); + var weaponChanges = droppedWeapons .Select(w => { @@ -144,18 +157,28 @@ public async Task ExecuteAsync() }) .ToList(); - if (weaponChanges.Count > 0) - await _playerWeaponRepository.UpsertRangeAsync(player.Id, weaponChanges); - - if (droppedScrolls > 0) + await _transactionRunner.ExecuteAsync(async () => { - resource.UpdateChangeData(resource.EnhancementScroll + droppedScrolls, null, null); - await _playerResourceRepository.UpdateAsync(resource); - } + if (weaponChanges.Count > 0) + await _playerWeaponRepository.UpsertRangeAsync(player.Id, weaponChanges); + + if (droppedScrolls > 0) + { + resource.UpdateChangeData(resource.EnhancementScroll + droppedScrolls, null, null); + await _playerResourceRepository.UpdateAsync(resource); + } + + if (isNewProgress) + await _playerDungeonProgressRepository.SaveAsync(progress); + else + await _playerDungeonProgressRepository.UpdateAsync(progress); + }); - await _playerRedisRepository.DeleteAsync(accountId); + if (droppedWeapons.Count > 0 || droppedScrolls > 0) + await _playerRedisRepository.DeleteAsync(accountId); } - return new WeaponDungeonResponse(cleared, droppedWeapons, droppedScrolls); + var highestClearedStage = progress?.HighestClearedStage ?? currentStage; + return new WeaponDungeonResponse(cleared, droppedWeapons, droppedScrolls, highestClearedStage); } } diff --git a/Fantasy-server/Fantasy.Test/Dungeon/Service/WeaponDungeonServiceTests.cs b/Fantasy-server/Fantasy.Test/Dungeon/Service/WeaponDungeonServiceTests.cs index c71292c..18391c1 100644 --- a/Fantasy-server/Fantasy.Test/Dungeon/Service/WeaponDungeonServiceTests.cs +++ b/Fantasy-server/Fantasy.Test/Dungeon/Service/WeaponDungeonServiceTests.cs @@ -1,3 +1,6 @@ +using Fantasy.Server.Domain.Dungeon.Entity; +using Fantasy.Server.Domain.Dungeon.Enum; +using Fantasy.Server.Domain.Dungeon.Repository.Interface; using Fantasy.Server.Domain.Dungeon.Service; using Fantasy.Server.Domain.Dungeon.Service.Interface; using Fantasy.Server.Domain.GameData.Entity; @@ -6,6 +9,7 @@ using Fantasy.Server.Domain.Player.Entity; using Fantasy.Server.Domain.Player.Enum; using Fantasy.Server.Domain.Player.Repository.Interface; +using Fantasy.Server.Global.Infrastructure; using Fantasy.Server.Global.Security.Provider; using FluentAssertions; using Gamism.SDK.Extensions.AspNetCore.Exceptions; @@ -26,6 +30,8 @@ private static WeaponDungeonService BuildSut( IPlayerSkillRepository? skillRepo = null, IPlayerRedisRepository? redisRepo = null, IGameDataCacheService? cache = null, + IPlayerDungeonProgressRepository? progressRepo = null, + IAppDbTransactionRunner? txRunner = null, ICurrentUserProvider? userProvider = null, ICombatStatCalculator? calculator = null) { @@ -37,13 +43,15 @@ private static WeaponDungeonService BuildSut( skillRepo ??= Substitute.For(); redisRepo ??= Substitute.For(); cache ??= Substitute.For(); + progressRepo ??= Substitute.For(); + txRunner ??= Substitute.For(); userProvider ??= Substitute.For(); calculator ??= new CombatStatCalculator(); return new WeaponDungeonService( playerRepo, resourceRepo, stageRepo, sessionRepo, weaponRepo, skillRepo, redisRepo, cache, - userProvider, calculator); + progressRepo, txRunner, userProvider, calculator); } public class 플레이어가_없을_때 @@ -268,4 +276,171 @@ public async Task Cleared가_true이다() result.Cleared.Should().BeTrue(); } } + + public class 진행도가_있고_클리어에_성공할_때 + { + private readonly IPlayerRepository _playerRepository = Substitute.For(); + private readonly IPlayerResourceRepository _playerResourceRepository = Substitute.For(); + private readonly IPlayerStageRepository _playerStageRepository = Substitute.For(); + private readonly IPlayerSessionRepository _playerSessionRepository = Substitute.For(); + private readonly IPlayerWeaponRepository _playerWeaponRepository = Substitute.For(); + private readonly IPlayerSkillRepository _playerSkillRepository = Substitute.For(); + private readonly IPlayerRedisRepository _playerRedisRepository = Substitute.For(); + private readonly IGameDataCacheService _gameDataCacheService = Substitute.For(); + private readonly IPlayerDungeonProgressRepository _progressRepository = Substitute.For(); + private readonly IAppDbTransactionRunner _transactionRunner = Substitute.For(); + private readonly ICurrentUserProvider _currentUserProvider = Substitute.For(); + + public 진행도가_있고_클리어에_성공할_때() + { + _currentUserProvider.GetAccountId().Returns(1L); + _playerRepository.FindByAccountAsync(1L) + .Returns(PlayerEntity.Create(1L, JobType.Warrior)); + _playerResourceRepository.FindByPlayerIdAsync(Arg.Any()) + .Returns(PlayerResource.Create(1L)); + _playerStageRepository.FindByPlayerIdAsync(Arg.Any()) + .Returns(PlayerStage.Create(1L)); + _playerSessionRepository.FindByPlayerIdAsync(Arg.Any()) + .Returns(PlayerSession.Create(1L)); + _playerWeaponRepository.FindAllByPlayerIdAsync(Arg.Any()).Returns([]); + _playerSkillRepository.FindAllByPlayerIdAsync(Arg.Any()).Returns([]); + + // 기존 진행도: HighestClearedStage = 5 + var existingProgress = PlayerDungeonProgress.Create(1L, DungeonType.Weapon); + existingProgress.ClearStage(5); + _progressRepository.FindByPlayerIdAndDungeonTypeAsync(Arg.Any(), DungeonType.Weapon) + .Returns(existingProgress); + + // 몬스터 HP = 1 → 항상 클리어 + var stageData = StageData.Create(5, monsterHp: 1, monsterAtk: 1, xpPerSecond: 5, goldPerSecond: 10); + _gameDataCacheService.GetStageDataAsync(5).Returns(stageData); + _gameDataCacheService.GetJobBaseStatAsync(JobType.Warrior) + .Returns(JobBaseStat.Create(JobType.Warrior, 1000, 100, 0, 1.5, 10, 10)); + _gameDataCacheService.GetSkillDataByJobAsync(Arg.Any()).Returns([]); + _gameDataCacheService.GetWeaponDataByGradeAsync(Arg.Any()).Returns([]); + + _transactionRunner.ExecuteAsync(Arg.Any>()) + .Returns(callInfo => callInfo.Arg>()()); + } + + [Fact] + public async Task 자기_진행도_기준으로_스테이지를_조회한다() + { + var sut = BuildSut( + playerRepo: _playerRepository, resourceRepo: _playerResourceRepository, + stageRepo: _playerStageRepository, sessionRepo: _playerSessionRepository, + weaponRepo: _playerWeaponRepository, skillRepo: _playerSkillRepository, + redisRepo: _playerRedisRepository, cache: _gameDataCacheService, + progressRepo: _progressRepository, txRunner: _transactionRunner, + userProvider: _currentUserProvider); + + await sut.ExecuteAsync(); + + await _gameDataCacheService.Received(1).GetStageDataAsync(5); + } + + [Fact] + public async Task HighestClearedStage가_1_증가한다() + { + var sut = BuildSut( + playerRepo: _playerRepository, resourceRepo: _playerResourceRepository, + stageRepo: _playerStageRepository, sessionRepo: _playerSessionRepository, + weaponRepo: _playerWeaponRepository, skillRepo: _playerSkillRepository, + redisRepo: _playerRedisRepository, cache: _gameDataCacheService, + progressRepo: _progressRepository, txRunner: _transactionRunner, + userProvider: _currentUserProvider); + + var result = await sut.ExecuteAsync(); + + result.HighestClearedStage.Should().Be(6); + } + + [Fact] + public async Task 진행도_UpdateAsync가_호출된다() + { + var sut = BuildSut( + playerRepo: _playerRepository, resourceRepo: _playerResourceRepository, + stageRepo: _playerStageRepository, sessionRepo: _playerSessionRepository, + weaponRepo: _playerWeaponRepository, skillRepo: _playerSkillRepository, + redisRepo: _playerRedisRepository, cache: _gameDataCacheService, + progressRepo: _progressRepository, txRunner: _transactionRunner, + userProvider: _currentUserProvider); + + await sut.ExecuteAsync(); + + await _progressRepository.Received(1).UpdateAsync(Arg.Any()); + } + } + + public class 진행도가_없을_때_신규_생성후_클리어에_성공하면 + { + private readonly IPlayerRepository _playerRepository = Substitute.For(); + private readonly IPlayerResourceRepository _playerResourceRepository = Substitute.For(); + private readonly IPlayerStageRepository _playerStageRepository = Substitute.For(); + private readonly IPlayerSessionRepository _playerSessionRepository = Substitute.For(); + private readonly IPlayerWeaponRepository _playerWeaponRepository = Substitute.For(); + private readonly IPlayerSkillRepository _playerSkillRepository = Substitute.For(); + private readonly IGameDataCacheService _gameDataCacheService = Substitute.For(); + private readonly IPlayerDungeonProgressRepository _progressRepository = Substitute.For(); + private readonly IAppDbTransactionRunner _transactionRunner = Substitute.For(); + private readonly ICurrentUserProvider _currentUserProvider = Substitute.For(); + + public 진행도가_없을_때_신규_생성후_클리어에_성공하면() + { + _currentUserProvider.GetAccountId().Returns(1L); + _playerRepository.FindByAccountAsync(1L) + .Returns(PlayerEntity.Create(1L, JobType.Warrior)); + _playerResourceRepository.FindByPlayerIdAsync(Arg.Any()) + .Returns(PlayerResource.Create(1L)); + _playerStageRepository.FindByPlayerIdAsync(Arg.Any()) + .Returns(PlayerStage.Create(1L)); + _playerSessionRepository.FindByPlayerIdAsync(Arg.Any()) + .Returns(PlayerSession.Create(1L)); + _playerWeaponRepository.FindAllByPlayerIdAsync(Arg.Any()).Returns([]); + _playerSkillRepository.FindAllByPlayerIdAsync(Arg.Any()).Returns([]); + + _progressRepository.FindByPlayerIdAndDungeonTypeAsync(Arg.Any(), DungeonType.Weapon) + .Returns((PlayerDungeonProgress?)null); + + var stageData = StageData.Create(1, monsterHp: 1, monsterAtk: 1, xpPerSecond: 5, goldPerSecond: 10); + _gameDataCacheService.GetStageDataAsync(1).Returns(stageData); + _gameDataCacheService.GetJobBaseStatAsync(JobType.Warrior) + .Returns(JobBaseStat.Create(JobType.Warrior, 1000, 100, 0, 1.5, 10, 10)); + _gameDataCacheService.GetSkillDataByJobAsync(Arg.Any()).Returns([]); + _gameDataCacheService.GetWeaponDataByGradeAsync(Arg.Any()).Returns([]); + + _transactionRunner.ExecuteAsync(Arg.Any>()) + .Returns(callInfo => callInfo.Arg>()()); + } + + [Fact] + public async Task HighestClearedStage가_2로_반환된다() + { + var sut = BuildSut( + playerRepo: _playerRepository, resourceRepo: _playerResourceRepository, + stageRepo: _playerStageRepository, sessionRepo: _playerSessionRepository, + weaponRepo: _playerWeaponRepository, skillRepo: _playerSkillRepository, + cache: _gameDataCacheService, progressRepo: _progressRepository, + txRunner: _transactionRunner, userProvider: _currentUserProvider); + + var result = await sut.ExecuteAsync(); + + result.HighestClearedStage.Should().Be(2); + } + + [Fact] + public async Task 진행도_SaveAsync가_호출된다() + { + var sut = BuildSut( + playerRepo: _playerRepository, resourceRepo: _playerResourceRepository, + stageRepo: _playerStageRepository, sessionRepo: _playerSessionRepository, + weaponRepo: _playerWeaponRepository, skillRepo: _playerSkillRepository, + cache: _gameDataCacheService, progressRepo: _progressRepository, + txRunner: _transactionRunner, userProvider: _currentUserProvider); + + await sut.ExecuteAsync(); + + await _progressRepository.Received(1).SaveAsync(Arg.Any()); + } + } } From 75881d988f1d4b64e02e67008b8a1bd6d88ce266 Mon Sep 17 00:00:00 2001 From: Sean-mn Date: Thu, 2 Jul 2026 13:33:16 +0900 Subject: [PATCH 4/9] =?UTF-8?q?feat:=20=EB=B3=B4=EC=8A=A4=20=EB=8D=98?= =?UTF-8?q?=EC=A0=84=EC=97=90=20=EB=8F=85=EB=A6=BD=20=EC=A7=84=ED=96=89?= =?UTF-8?q?=EB=8F=84=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Dto/Response/BossDungeonResponse.cs | 3 +- .../Dungeon/Service/BossDungeonService.cs | 27 +++- .../Service/BossDungeonServiceTests.cs | 152 +++++++++++++++++- 3 files changed, 175 insertions(+), 7 deletions(-) diff --git a/Fantasy-server/Fantasy.Server/Domain/Dungeon/Dto/Response/BossDungeonResponse.cs b/Fantasy-server/Fantasy.Server/Domain/Dungeon/Dto/Response/BossDungeonResponse.cs index acdb21e..7859c3a 100644 --- a/Fantasy-server/Fantasy.Server/Domain/Dungeon/Dto/Response/BossDungeonResponse.cs +++ b/Fantasy-server/Fantasy.Server/Domain/Dungeon/Dto/Response/BossDungeonResponse.cs @@ -7,5 +7,6 @@ public record BossDungeonResponse( long EarnedMithril, DroppedWeaponInfo? DroppedWeapon, long EarnedXp, - List LevelUps + List LevelUps, + long HighestClearedStage ); diff --git a/Fantasy-server/Fantasy.Server/Domain/Dungeon/Service/BossDungeonService.cs b/Fantasy-server/Fantasy.Server/Domain/Dungeon/Service/BossDungeonService.cs index 776787b..0f4127f 100644 --- a/Fantasy-server/Fantasy.Server/Domain/Dungeon/Service/BossDungeonService.cs +++ b/Fantasy-server/Fantasy.Server/Domain/Dungeon/Service/BossDungeonService.cs @@ -1,4 +1,7 @@ using Fantasy.Server.Domain.Dungeon.Dto.Response; +using Fantasy.Server.Domain.Dungeon.Entity; +using Fantasy.Server.Domain.Dungeon.Enum; +using Fantasy.Server.Domain.Dungeon.Repository.Interface; using Fantasy.Server.Domain.Dungeon.Service.Interface; using Fantasy.Server.Domain.GameData.Entity; using Fantasy.Server.Domain.GameData.Enum; @@ -26,6 +29,7 @@ public class BossDungeonService : IBossDungeonService private readonly IPlayerRedisRepository _playerRedisRepository; private readonly IGameDataCacheService _gameDataCacheService; private readonly ILevelUpService _levelUpService; + private readonly IPlayerDungeonProgressRepository _playerDungeonProgressRepository; private readonly IAppDbTransactionRunner _transactionRunner; private readonly ICurrentUserProvider _currentUserProvider; private readonly ICombatStatCalculator _calculator; @@ -40,6 +44,7 @@ public BossDungeonService( IPlayerRedisRepository playerRedisRepository, IGameDataCacheService gameDataCacheService, ILevelUpService levelUpService, + IPlayerDungeonProgressRepository playerDungeonProgressRepository, IAppDbTransactionRunner transactionRunner, ICurrentUserProvider currentUserProvider, ICombatStatCalculator calculator) @@ -53,6 +58,7 @@ public BossDungeonService( _playerRedisRepository = playerRedisRepository; _gameDataCacheService = gameDataCacheService; _levelUpService = levelUpService; + _playerDungeonProgressRepository = playerDungeonProgressRepository; _transactionRunner = transactionRunner; _currentUserProvider = currentUserProvider; _calculator = calculator; @@ -100,9 +106,11 @@ public async Task ExecuteAsync() var combatStat = _calculator.Calculate(player.Level, jobStat, weaponData, weaponEnhancement, unlockedPassiveSkills); - var stageData = await _gameDataCacheService.GetStageDataAsync(stage.MaxStage); - if (stageData is null) - throw new NotFoundException("스테이지 데이터를 찾을 수 없습니다."); + var progress = await _playerDungeonProgressRepository.FindByPlayerIdAndDungeonTypeAsync(player.Id, DungeonType.Boss); + var currentStage = progress?.HighestClearedStage ?? 1; + + var stageData = await _gameDataCacheService.GetStageDataAsync(currentStage) + ?? throw new NotFoundException("스테이지 데이터를 찾을 수 없습니다."); // 보스는 일반 몬스터의 5배 체력 var bossHp = stageData.MonsterHp * 5; @@ -110,7 +118,11 @@ public async Task ExecuteAsync() var cleared = dps * 30 >= bossHp; if (!cleared) - return new BossDungeonResponse(false, 0, null, 0, []); + return new BossDungeonResponse(false, 0, null, 0, [], currentStage); + + var isNewProgress = progress is null; + progress ??= PlayerDungeonProgress.Create(player.Id, DungeonType.Boss); + progress.ClearStage(currentStage + 1); var earnedXp = stageData.XpPerSecond * BossXpMultiplier; var levelUps = await _levelUpService.ExecuteAsync(player, resource, earnedXp); @@ -137,10 +149,15 @@ await _transactionRunner.ExecuteAsync(async () => await _playerResourceRepository.UpdateAsync(resource); if (weaponChanges.Count > 0) await _playerWeaponRepository.UpsertRangeAsync(player.Id, weaponChanges); + + if (isNewProgress) + await _playerDungeonProgressRepository.SaveAsync(progress); + else + await _playerDungeonProgressRepository.UpdateAsync(progress); }); await _playerRedisRepository.DeleteAsync(accountId); - return new BossDungeonResponse(true, BossMithrilReward, droppedWeapon, earnedXp, levelUps); + return new BossDungeonResponse(true, BossMithrilReward, droppedWeapon, earnedXp, levelUps, progress.HighestClearedStage); } } diff --git a/Fantasy-server/Fantasy.Test/Dungeon/Service/BossDungeonServiceTests.cs b/Fantasy-server/Fantasy.Test/Dungeon/Service/BossDungeonServiceTests.cs index cfef641..f5c5069 100644 --- a/Fantasy-server/Fantasy.Test/Dungeon/Service/BossDungeonServiceTests.cs +++ b/Fantasy-server/Fantasy.Test/Dungeon/Service/BossDungeonServiceTests.cs @@ -1,3 +1,6 @@ +using Fantasy.Server.Domain.Dungeon.Entity; +using Fantasy.Server.Domain.Dungeon.Enum; +using Fantasy.Server.Domain.Dungeon.Repository.Interface; using Fantasy.Server.Domain.Dungeon.Service; using Fantasy.Server.Domain.Dungeon.Service.Interface; using Fantasy.Server.Domain.GameData.Entity; @@ -29,6 +32,7 @@ private static BossDungeonService BuildSut( IPlayerRedisRepository? redisRepo = null, IGameDataCacheService? cache = null, ILevelUpService? levelUpService = null, + IPlayerDungeonProgressRepository? progressRepo = null, IAppDbTransactionRunner? txRunner = null, ICurrentUserProvider? userProvider = null, ICombatStatCalculator? calculator = null) @@ -42,6 +46,7 @@ private static BossDungeonService BuildSut( redisRepo ??= Substitute.For(); cache ??= Substitute.For(); levelUpService ??= Substitute.For(); + progressRepo ??= Substitute.For(); txRunner ??= Substitute.For(); userProvider ??= Substitute.For(); calculator ??= new CombatStatCalculator(); @@ -49,7 +54,7 @@ private static BossDungeonService BuildSut( return new BossDungeonService( playerRepo, resourceRepo, stageRepo, sessionRepo, weaponRepo, skillRepo, redisRepo, cache, - levelUpService, txRunner, userProvider, calculator); + levelUpService, progressRepo, txRunner, userProvider, calculator); } public class 플레이어가_없을_때 @@ -312,4 +317,149 @@ public async Task Cleared가_true이다() result.Cleared.Should().BeTrue(); } } + + public class 진행도가_있고_클리어에_성공할_때 + { + private readonly IPlayerRepository _playerRepository = Substitute.For(); + private readonly IPlayerResourceRepository _playerResourceRepository = Substitute.For(); + private readonly IPlayerStageRepository _playerStageRepository = Substitute.For(); + private readonly IPlayerSessionRepository _playerSessionRepository = Substitute.For(); + private readonly IPlayerWeaponRepository _playerWeaponRepository = Substitute.For(); + private readonly IPlayerSkillRepository _playerSkillRepository = Substitute.For(); + private readonly IPlayerRedisRepository _playerRedisRepository = Substitute.For(); + private readonly IGameDataCacheService _gameDataCacheService = Substitute.For(); + private readonly ILevelUpService _levelUpService = Substitute.For(); + private readonly IPlayerDungeonProgressRepository _progressRepository = Substitute.For(); + private readonly IAppDbTransactionRunner _transactionRunner = Substitute.For(); + private readonly ICurrentUserProvider _currentUserProvider = Substitute.For(); + + public 진행도가_있고_클리어에_성공할_때() + { + _currentUserProvider.GetAccountId().Returns(1L); + _playerRepository.FindByAccountAsync(1L) + .Returns(PlayerEntity.Create(1L, JobType.Warrior)); + _playerResourceRepository.FindByPlayerIdAsync(Arg.Any()) + .Returns(PlayerResource.Create(1L)); + _playerStageRepository.FindByPlayerIdAsync(Arg.Any()) + .Returns(PlayerStage.Create(1L)); + _playerSessionRepository.FindByPlayerIdAsync(Arg.Any()) + .Returns(PlayerSession.Create(1L)); + _playerWeaponRepository.FindAllByPlayerIdAsync(Arg.Any()).Returns([]); + _playerSkillRepository.FindAllByPlayerIdAsync(Arg.Any()).Returns([]); + + var existingProgress = PlayerDungeonProgress.Create(1L, DungeonType.Boss); + existingProgress.ClearStage(3); + _progressRepository.FindByPlayerIdAndDungeonTypeAsync(Arg.Any(), DungeonType.Boss) + .Returns(existingProgress); + + // 보스 HP = 1 * 5 = 5 → DPS(100) > 5 → 클리어 + var stageData = StageData.Create(3, monsterHp: 1, monsterAtk: 1, xpPerSecond: 5, goldPerSecond: 10); + _gameDataCacheService.GetStageDataAsync(3).Returns(stageData); + _gameDataCacheService.GetJobBaseStatAsync(JobType.Warrior) + .Returns(JobBaseStat.Create(JobType.Warrior, 1000, 100, 0, 1.5, 10, 10)); + _gameDataCacheService.GetSkillDataByJobAsync(Arg.Any()).Returns([]); + _gameDataCacheService.GetWeaponDataByGradeAsync(WeaponGrade.A).Returns([]); + _levelUpService.ExecuteAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns([]); + _transactionRunner.ExecuteAsync(Arg.Any>()) + .Returns(callInfo => callInfo.Arg>()()); + } + + [Fact] + public async Task 자기_진행도_기준으로_보스_스테이지를_조회한다() + { + var sut = BuildSut( + playerRepo: _playerRepository, resourceRepo: _playerResourceRepository, + stageRepo: _playerStageRepository, sessionRepo: _playerSessionRepository, + weaponRepo: _playerWeaponRepository, skillRepo: _playerSkillRepository, + redisRepo: _playerRedisRepository, cache: _gameDataCacheService, + levelUpService: _levelUpService, progressRepo: _progressRepository, + txRunner: _transactionRunner, userProvider: _currentUserProvider); + + await sut.ExecuteAsync(); + + await _gameDataCacheService.Received(1).GetStageDataAsync(3); + } + + [Fact] + public async Task HighestClearedStage가_1_증가한다() + { + var sut = BuildSut( + playerRepo: _playerRepository, resourceRepo: _playerResourceRepository, + stageRepo: _playerStageRepository, sessionRepo: _playerSessionRepository, + weaponRepo: _playerWeaponRepository, skillRepo: _playerSkillRepository, + redisRepo: _playerRedisRepository, cache: _gameDataCacheService, + levelUpService: _levelUpService, progressRepo: _progressRepository, + txRunner: _transactionRunner, userProvider: _currentUserProvider); + + var result = await sut.ExecuteAsync(); + + result.HighestClearedStage.Should().Be(4); + } + + [Fact] + public async Task 진행도_UpdateAsync가_호출된다() + { + var sut = BuildSut( + playerRepo: _playerRepository, resourceRepo: _playerResourceRepository, + stageRepo: _playerStageRepository, sessionRepo: _playerSessionRepository, + weaponRepo: _playerWeaponRepository, skillRepo: _playerSkillRepository, + redisRepo: _playerRedisRepository, cache: _gameDataCacheService, + levelUpService: _levelUpService, progressRepo: _progressRepository, + txRunner: _transactionRunner, userProvider: _currentUserProvider); + + await sut.ExecuteAsync(); + + await _progressRepository.Received(1).UpdateAsync(Arg.Any()); + } + } + + public class 클리어_실패시_진행도가_변하지_않는다 + { + [Fact] + public async Task 기존_HighestClearedStage가_그대로_반환된다() + { + var playerRepository = Substitute.For(); + var resourceRepository = Substitute.For(); + var stageRepository = Substitute.For(); + var sessionRepository = Substitute.For(); + var weaponRepository = Substitute.For(); + var skillRepository = Substitute.For(); + var gameDataCacheService = Substitute.For(); + var progressRepository = Substitute.For(); + var currentUserProvider = Substitute.For(); + + currentUserProvider.GetAccountId().Returns(1L); + playerRepository.FindByAccountAsync(1L).Returns(PlayerEntity.Create(1L, JobType.Warrior)); + resourceRepository.FindByPlayerIdAsync(Arg.Any()).Returns(PlayerResource.Create(1L)); + stageRepository.FindByPlayerIdAsync(Arg.Any()).Returns(PlayerStage.Create(1L)); + sessionRepository.FindByPlayerIdAsync(Arg.Any()).Returns(PlayerSession.Create(1L)); + weaponRepository.FindAllByPlayerIdAsync(Arg.Any()).Returns([]); + skillRepository.FindAllByPlayerIdAsync(Arg.Any()).Returns([]); + + var existingProgress = PlayerDungeonProgress.Create(1L, DungeonType.Boss); + existingProgress.ClearStage(3); + progressRepository.FindByPlayerIdAndDungeonTypeAsync(Arg.Any(), DungeonType.Boss) + .Returns(existingProgress); + + // 몬스터 HP가 매우 높아 클리어 불가 + var stageData = StageData.Create(3, monsterHp: 10_000_000, monsterAtk: 999, xpPerSecond: 5, goldPerSecond: 10); + gameDataCacheService.GetStageDataAsync(3).Returns(stageData); + gameDataCacheService.GetJobBaseStatAsync(JobType.Warrior) + .Returns(JobBaseStat.Create(JobType.Warrior, 1000, 100, 0, 1.5, 10, 10)); + gameDataCacheService.GetSkillDataByJobAsync(Arg.Any()).Returns([]); + + var sut = BuildSut( + playerRepo: playerRepository, resourceRepo: resourceRepository, + stageRepo: stageRepository, sessionRepo: sessionRepository, + weaponRepo: weaponRepository, skillRepo: skillRepository, + cache: gameDataCacheService, progressRepo: progressRepository, + userProvider: currentUserProvider); + + var result = await sut.ExecuteAsync(); + + result.HighestClearedStage.Should().Be(3); + await progressRepository.DidNotReceive().UpdateAsync(Arg.Any()); + } + } } From 8d37ae4041e472b3c7edd26259f5343320709233 Mon Sep 17 00:00:00 2001 From: Sean-mn Date: Thu, 2 Jul 2026 13:33:17 +0900 Subject: [PATCH 5/9] =?UTF-8?q?feat:=20=EA=B3=A8=EB=93=9C=20=EB=8D=98?= =?UTF-8?q?=EC=A0=84=20claim=EC=97=90=20HighScore=20=EA=B0=B1=EC=8B=A0=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Dto/Response/GoldDungeonClaimResponse.cs | 1 + .../Service/GoldDungeonClaimService.cs | 22 ++- .../Service/GoldDungeonClaimServiceTest.cs | 155 ++++++++++++++++++ 3 files changed, 176 insertions(+), 2 deletions(-) diff --git a/Fantasy-server/Fantasy.Server/Domain/Dungeon/Dto/Response/GoldDungeonClaimResponse.cs b/Fantasy-server/Fantasy.Server/Domain/Dungeon/Dto/Response/GoldDungeonClaimResponse.cs index 9e9d0f9..c4b68f1 100644 --- a/Fantasy-server/Fantasy.Server/Domain/Dungeon/Dto/Response/GoldDungeonClaimResponse.cs +++ b/Fantasy-server/Fantasy.Server/Domain/Dungeon/Dto/Response/GoldDungeonClaimResponse.cs @@ -6,6 +6,7 @@ public record GoldDungeonClaimResponse( Guid RunId, long EarnedGold, int EarnedMithril, + long HighScore, ChangesDto Changes, PlayerDataResponse Player ); diff --git a/Fantasy-server/Fantasy.Server/Domain/Dungeon/Service/GoldDungeonClaimService.cs b/Fantasy-server/Fantasy.Server/Domain/Dungeon/Service/GoldDungeonClaimService.cs index bebf163..a14797e 100644 --- a/Fantasy-server/Fantasy.Server/Domain/Dungeon/Service/GoldDungeonClaimService.cs +++ b/Fantasy-server/Fantasy.Server/Domain/Dungeon/Service/GoldDungeonClaimService.cs @@ -1,5 +1,7 @@ using Fantasy.Server.Domain.Dungeon.Dto.Request; using Fantasy.Server.Domain.Dungeon.Dto.Response; +using Fantasy.Server.Domain.Dungeon.Entity; +using Fantasy.Server.Domain.Dungeon.Enum; using Fantasy.Server.Domain.Dungeon.Repository.Interface; using Fantasy.Server.Domain.Dungeon.Service.Interface; using Fantasy.Server.Domain.Player.Dto.Response; @@ -23,6 +25,7 @@ public class GoldDungeonClaimService : IGoldDungeonClaimService private readonly IPlayerSkillRepository _playerSkillRepository; private readonly IPlayerRedisRepository _playerRedisRepository; private readonly IGoldDungeonRunRepository _goldDungeonRunRepository; + private readonly IPlayerDungeonProgressRepository _playerDungeonProgressRepository; private readonly IRandomProvider _randomProvider; private readonly IAppDbTransactionRunner _transactionRunner; private readonly ICurrentUserProvider _currentUserProvider; @@ -37,6 +40,7 @@ public GoldDungeonClaimService( IPlayerSkillRepository playerSkillRepository, IPlayerRedisRepository playerRedisRepository, IGoldDungeonRunRepository goldDungeonRunRepository, + IPlayerDungeonProgressRepository playerDungeonProgressRepository, IRandomProvider randomProvider, IAppDbTransactionRunner transactionRunner, ICurrentUserProvider currentUserProvider, @@ -50,6 +54,7 @@ public GoldDungeonClaimService( _playerSkillRepository = playerSkillRepository; _playerRedisRepository = playerRedisRepository; _goldDungeonRunRepository = goldDungeonRunRepository; + _playerDungeonProgressRepository = playerDungeonProgressRepository; _randomProvider = randomProvider; _transactionRunner = transactionRunner; _currentUserProvider = currentUserProvider; @@ -85,6 +90,8 @@ public async Task ExecuteAsync(Guid runId, GoldDungeon var playerResponse = PlayerDataResponseBuilder.Build(player, resource, stage, session, weapons, skills); + var existingProgress = await _playerDungeonProgressRepository.FindByPlayerIdAndDungeonTypeAsync(player.Id, DungeonType.Gold); + var idempotentChanges = new ChangesDto( Gold: run.EarnedGold!.Value, Exp: 0, @@ -98,7 +105,8 @@ public async Task ExecuteAsync(Guid runId, GoldDungeon MaxStage: 0 ); - return new GoldDungeonClaimResponse(run.Id, run.EarnedGold!.Value, run.EarnedMithril!.Value, idempotentChanges, playerResponse); + return new GoldDungeonClaimResponse(run.Id, run.EarnedGold!.Value, run.EarnedMithril!.Value, + existingProgress?.HighScore ?? 0, idempotentChanges, playerResponse); } var now = _timeProvider.GetUtcNow().UtcDateTime; @@ -138,10 +146,20 @@ public async Task ExecuteAsync(Guid runId, GoldDungeon run.Claim(request.Clicks, earnedGold, mithrilDropped ? 1 : 0); + var progress = await _playerDungeonProgressRepository.FindByPlayerIdAndDungeonTypeAsync(claimPlayer.Id, DungeonType.Gold); + var isNewProgress = progress is null; + progress ??= PlayerDungeonProgress.Create(claimPlayer.Id, DungeonType.Gold); + progress.UpdateHighScore(earnedGold); + await _transactionRunner.ExecuteAsync(async () => { await _playerResourceRepository.UpdateAsync(claimResource); await _goldDungeonRunRepository.UpdateAsync(run); + + if (isNewProgress) + await _playerDungeonProgressRepository.SaveAsync(progress); + else + await _playerDungeonProgressRepository.UpdateAsync(progress); }); var claimPlayerResponse = PlayerDataResponseBuilder.Build(claimPlayer, claimResource, claimStage, claimSession, claimWeapons, claimSkills); @@ -160,6 +178,6 @@ await _transactionRunner.ExecuteAsync(async () => MaxStage: 0 ); - return new GoldDungeonClaimResponse(run.Id, earnedGold, mithrilDropped ? 1 : 0, changes, claimPlayerResponse); + return new GoldDungeonClaimResponse(run.Id, earnedGold, mithrilDropped ? 1 : 0, progress.HighScore, changes, claimPlayerResponse); } } diff --git a/Fantasy-server/Fantasy.Test/Dungeon/Service/GoldDungeonClaimServiceTest.cs b/Fantasy-server/Fantasy.Test/Dungeon/Service/GoldDungeonClaimServiceTest.cs index 6d75c84..639d2c9 100644 --- a/Fantasy-server/Fantasy.Test/Dungeon/Service/GoldDungeonClaimServiceTest.cs +++ b/Fantasy-server/Fantasy.Test/Dungeon/Service/GoldDungeonClaimServiceTest.cs @@ -1,6 +1,7 @@ using Fantasy.Server.Domain.Dungeon.Dto.Request; using Fantasy.Server.Domain.Dungeon.Dto.Response; using Fantasy.Server.Domain.Dungeon.Entity; +using Fantasy.Server.Domain.Dungeon.Enum; using Fantasy.Server.Domain.Dungeon.Repository.Interface; using Fantasy.Server.Domain.Dungeon.Service; using Fantasy.Server.Domain.Player.Dto.Response; @@ -28,6 +29,7 @@ private static GoldDungeonClaimService BuildSut( IPlayerSkillRepository? skillRepo = null, IPlayerRedisRepository? redisRepo = null, IGoldDungeonRunRepository? runRepo = null, + IPlayerDungeonProgressRepository? progressRepo = null, IRandomProvider? randomProvider = null, IAppDbTransactionRunner? txRunner = null, ICurrentUserProvider? userProvider = null, @@ -41,6 +43,7 @@ private static GoldDungeonClaimService BuildSut( skillRepo ?? Substitute.For(), redisRepo ?? Substitute.For(), runRepo ?? Substitute.For(), + progressRepo ?? Substitute.For(), randomProvider ?? Substitute.For(), txRunner ?? Substitute.For(), userProvider ?? Substitute.For(), @@ -447,4 +450,156 @@ public async Task 캐시가_갱신되지_않아_보상이_중복되지_않는다 await _redisRepository.DidNotReceive().SetPlayerDataAsync(Arg.Any(), Arg.Any()); } } + + public class 정상_클레임시_HighScore가_갱신될_때 + { + private readonly IPlayerRepository _playerRepository = Substitute.For(); + private readonly IPlayerResourceRepository _resourceRepository = Substitute.For(); + private readonly IPlayerStageRepository _stageRepository = Substitute.For(); + private readonly IPlayerSessionRepository _sessionRepository = Substitute.For(); + private readonly IPlayerWeaponRepository _weaponRepository = Substitute.For(); + private readonly IPlayerSkillRepository _skillRepository = Substitute.For(); + private readonly IGoldDungeonRunRepository _runRepository = Substitute.For(); + private readonly IPlayerDungeonProgressRepository _progressRepository = Substitute.For(); + private readonly IRandomProvider _randomProvider = Substitute.For(); + private readonly IAppDbTransactionRunner _txRunner = Substitute.For(); + private readonly ICurrentUserProvider _currentUserProvider = Substitute.For(); + private readonly GoldDungeonRun _run; + private readonly TimeProvider _timeProvider; + + public 정상_클레임시_HighScore가_갱신될_때() + { + _currentUserProvider.GetAccountId().Returns(1L); + _run = GoldDungeonRun.Create(accountId: 1L, durationSeconds: 30, maxClicksPerSecond: 15); + _runRepository.FindByIdAsync(_run.Id).Returns(_run); + _randomProvider.Next(0, 100).Returns(50); + _timeProvider = FixedTimeProvider(_run.StartedAt.AddSeconds(30)); + SetupPlayerData(_playerRepository, _resourceRepository, _stageRepository, + _sessionRepository, _weaponRepository, _skillRepository); + + var existingProgress = PlayerDungeonProgress.Create(1L, DungeonType.Gold); + existingProgress.UpdateHighScore(500L); + _progressRepository.FindByPlayerIdAndDungeonTypeAsync(Arg.Any(), DungeonType.Gold) + .Returns(existingProgress); + + _txRunner.ExecuteAsync(Arg.Any>()) + .Returns(callInfo => callInfo.Arg>()()); + } + + [Fact] + public async Task 기존_최고점수보다_높으면_HighScore가_갱신된다() + { + // 클릭 100 * 10골드 = 1000 > 기존 500 + var sut = BuildSut(playerRepo: _playerRepository, resourceRepo: _resourceRepository, + stageRepo: _stageRepository, sessionRepo: _sessionRepository, + weaponRepo: _weaponRepository, skillRepo: _skillRepository, + runRepo: _runRepository, progressRepo: _progressRepository, + randomProvider: _randomProvider, txRunner: _txRunner, userProvider: _currentUserProvider, + timeProvider: _timeProvider); + + var result = await sut.ExecuteAsync(_run.Id, new GoldDungeonClaimRequest(100)); + + result.HighScore.Should().Be(1000L); + } + + [Fact] + public async Task 진행도_UpdateAsync가_호출된다() + { + var sut = BuildSut(playerRepo: _playerRepository, resourceRepo: _resourceRepository, + stageRepo: _stageRepository, sessionRepo: _sessionRepository, + weaponRepo: _weaponRepository, skillRepo: _skillRepository, + runRepo: _runRepository, progressRepo: _progressRepository, + randomProvider: _randomProvider, txRunner: _txRunner, userProvider: _currentUserProvider, + timeProvider: _timeProvider); + + await sut.ExecuteAsync(_run.Id, new GoldDungeonClaimRequest(100)); + + await _progressRepository.Received(1).UpdateAsync(Arg.Any()); + } + } + + public class 정상_클레임시_기존_최고점수보다_낮을_때 + { + [Fact] + public async Task HighScore가_기존값으로_유지된다() + { + var playerRepository = Substitute.For(); + var resourceRepository = Substitute.For(); + var stageRepository = Substitute.For(); + var sessionRepository = Substitute.For(); + var weaponRepository = Substitute.For(); + var skillRepository = Substitute.For(); + var runRepository = Substitute.For(); + var progressRepository = Substitute.For(); + var randomProvider = Substitute.For(); + var txRunner = Substitute.For(); + var currentUserProvider = Substitute.For(); + + currentUserProvider.GetAccountId().Returns(1L); + var run = GoldDungeonRun.Create(accountId: 1L, durationSeconds: 30, maxClicksPerSecond: 15); + runRepository.FindByIdAsync(run.Id).Returns(run); + randomProvider.Next(0, 100).Returns(50); + SetupPlayerData(playerRepository, resourceRepository, stageRepository, + sessionRepository, weaponRepository, skillRepository); + + // 기존 최고점수 5000 > 이번 클릭 10 * 10골드 = 100 + var existingProgress = PlayerDungeonProgress.Create(1L, DungeonType.Gold); + existingProgress.UpdateHighScore(5000L); + progressRepository.FindByPlayerIdAndDungeonTypeAsync(Arg.Any(), DungeonType.Gold) + .Returns(existingProgress); + + txRunner.ExecuteAsync(Arg.Any>()) + .Returns(callInfo => callInfo.Arg>()()); + + var sut = BuildSut(playerRepo: playerRepository, resourceRepo: resourceRepository, + stageRepo: stageRepository, sessionRepo: sessionRepository, + weaponRepo: weaponRepository, skillRepo: skillRepository, + runRepo: runRepository, progressRepo: progressRepository, + randomProvider: randomProvider, txRunner: txRunner, userProvider: currentUserProvider, + timeProvider: FixedTimeProvider(run.StartedAt.AddSeconds(30))); + + var result = await sut.ExecuteAsync(run.Id, new GoldDungeonClaimRequest(10)); + + result.HighScore.Should().Be(5000L); + } + } + + public class 이미_클레임된_런_재조회시_HighScore가_포함될_때 + { + [Fact] + public async Task 저장된_HighScore가_반환된다() + { + var playerRepository = Substitute.For(); + var resourceRepository = Substitute.For(); + var stageRepository = Substitute.For(); + var sessionRepository = Substitute.For(); + var weaponRepository = Substitute.For(); + var skillRepository = Substitute.For(); + var runRepository = Substitute.For(); + var progressRepository = Substitute.For(); + var currentUserProvider = Substitute.For(); + + currentUserProvider.GetAccountId().Returns(1L); + var claimedRun = GoldDungeonRun.Create(accountId: 1L, durationSeconds: 30, maxClicksPerSecond: 15); + claimedRun.Claim(100, 1000L, 0); + runRepository.FindByIdAsync(claimedRun.Id).Returns(claimedRun); + SetupPlayerData(playerRepository, resourceRepository, stageRepository, + sessionRepository, weaponRepository, skillRepository); + + var existingProgress = PlayerDungeonProgress.Create(1L, DungeonType.Gold); + existingProgress.UpdateHighScore(2000L); + progressRepository.FindByPlayerIdAndDungeonTypeAsync(Arg.Any(), DungeonType.Gold) + .Returns(existingProgress); + + var sut = BuildSut(playerRepo: playerRepository, resourceRepo: resourceRepository, + stageRepo: stageRepository, sessionRepo: sessionRepository, + weaponRepo: weaponRepository, skillRepo: skillRepository, + runRepo: runRepository, progressRepo: progressRepository, + userProvider: currentUserProvider); + + var result = await sut.ExecuteAsync(claimedRun.Id, new GoldDungeonClaimRequest(50)); + + result.HighScore.Should().Be(2000L); + } + } } From 464c36d030ee3572d4ac9f71dfdfcb6c9817d0bb Mon Sep 17 00:00:00 2001 From: Sean-mn Date: Thu, 2 Jul 2026 13:33:32 +0900 Subject: [PATCH 6/9] =?UTF-8?q?chore:=20=EB=8D=98=EC=A0=84=20=ED=83=80?= =?UTF-8?q?=EC=9E=85=EB=B3=84=20=EC=A7=84=ED=96=89=EB=8F=84=20=EA=B3=84?= =?UTF-8?q?=ED=9A=8D=20=EB=B0=8F=20=EC=84=A4=EA=B3=84=20=EB=AC=B8=EC=84=9C?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../plans/2026-07-02-dungeon-type-progress.md | 1740 +++++++++++++++++ ...rver-authority-feature-expansion-design.md | 19 +- 2 files changed, 1751 insertions(+), 8 deletions(-) create mode 100644 docs/superpowers/plans/2026-07-02-dungeon-type-progress.md diff --git a/docs/superpowers/plans/2026-07-02-dungeon-type-progress.md b/docs/superpowers/plans/2026-07-02-dungeon-type-progress.md new file mode 100644 index 0000000..08be204 --- /dev/null +++ b/docs/superpowers/plans/2026-07-02-dungeon-type-progress.md @@ -0,0 +1,1740 @@ +# 던전 타입별 진행도 (Phase 2) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 무기/보스/골드 던전에 각자 독립된 `HighestClearedStage`(무기·보스) 또는 `HighScore`(골드)를 추가해, 세 던전이 더 이상 기본 던전의 `PlayerStage.MaxStage`에 종속되지 않고 자기 진행도를 갖도록 한다. + +**Architecture:** 신규 `PlayerDungeonProgress` 엔티티(`(player_id, dungeon_type)` 유니크)를 추가하고, `WeaponDungeonService`/`BossDungeonService`는 난이도 조회를 `PlayerStage.MaxStage` 대신 자기 `PlayerDungeonProgress.HighestClearedStage`로 바꾸며, `GoldDungeonClaimService`는 claim 시 `HighScore`를 갱신한다. 신규 레코드는 지연 생성(lazy-create)하며 별도 백필 마이그레이션은 두지 않는다. + +**Tech Stack:** ASP.NET Core Web API, EF Core(Npgsql), xUnit v3 + NSubstitute + FluentAssertions. + +## Global Constraints + +- 엔티티: 모든 setter `private set`, static `Create(...)` 팩토리. 단순 이벤트 타임스탬프(`LastClearedAt`)는 `Player.UpdateLevel`과 동일하게 엔티티 메서드 내부에서 직접 `DateTime.UtcNow`를 찍는다 — 외부에서 시간을 파라미터로 받지 않는다. +- EF: `IEntityTypeConfiguration` Fluent API만 사용. 테이블은 `dungeon.player_dungeon_progress`(스키마는 도메인 폴더명과 동일하게 `dungeon`). 컬럼명은 EF 기본값(C# 프로퍼티명 그대로, 예: `HighestClearedStage`)을 따르고 별도 `.HasColumnName(...)`을 지정하지 않는다 — 이 저장소의 기존 엔티티 전부가 이 방식이다. +- `DungeonType` enum은 `.HasConversion()`으로 DB에 문자열 저장(`JobType`/`WeaponGrade`와 동일 패턴). +- `xmin` 동시성 토큰을 다른 Player 하위 테이블(`PlayerStage`, `AccountDungeonTicket`, `GoldDungeonRun`)과 동일하게 적용한다. `AppDbTransactionRunner`가 `DbUpdateConcurrencyException`을 `ConflictException`으로 자동 변환하므로, 서비스 코드에서 별도 예외 처리는 필요 없다 — `_transactionRunner.ExecuteAsync(...)` 안에서 저장하기만 하면 된다. +- 리포지토리: 얇은 CRUD만 노출한다(`FindByPlayerIdAndDungeonTypeAsync`, `SaveAsync`, `UpdateAsync`). find-or-create 판단은 서비스가 하며(신규/기존 여부를 서비스가 알고 있어야 `SaveAsync` vs `UpdateAsync`를 선택할 수 있음), Phase 1의 `CompleteTutorialService`와 동일한 패턴을 따른다. +- 신규 유저와 기존 유저 모두 `PlayerDungeonProgress`를 지연 생성한다(1부터 시작). 별도 데이터 백필 스크립트를 만들지 않는다 — 2026-07-02 사용자 확정 사항. +- `DungeonTicketResponse`는 건드리지 않는다 — `HighScore`는 `GoldDungeonClaimResponse`에만 추가한다 — 2026-07-02 사용자 확정 사항. +- 테스트: `NSubstitute`로 리포지토리 인터페이스만 모킹. `IAppDbTransactionRunner`의 패스스루(`.Returns(callInfo => callInfo.Arg>()())`)는 트랜잭션 본문이 실제로 실행되어야 하는 테스트 클래스에서만 명시적으로 설정한다(이 저장소의 기존 컨벤션 — `BuildSut` 기본값에는 넣지 않는다). Arrange/Act/Assert 사이 빈 줄. +- 각 태스크가 끝나면 `/test <필터>`로 빌드+테스트를 확인한다. + +--- + +### Task 1: `DungeonType` enum + `PlayerDungeonProgress` 엔티티 + EF 설정 + 마이그레이션 + +**Files:** +- Create: `Fantasy-server/Fantasy.Server/Domain/Dungeon/Enum/DungeonType.cs` +- Create: `Fantasy-server/Fantasy.Server/Domain/Dungeon/Entity/PlayerDungeonProgress.cs` +- Create: `Fantasy-server/Fantasy.Server/Domain/Dungeon/Entity/Config/PlayerDungeonProgressConfig.cs` +- Modify: `Fantasy-server/Fantasy.Server/Global/Infrastructure/AppDbContext.cs` +- Create: `Fantasy-server/Fantasy.Server/Migrations/{timestamp}_AddPlayerDungeonProgress.cs` (자동 생성) + +**Interfaces:** +- Produces: `DungeonType { Basic, Gold, Weapon, Boss }`. `PlayerDungeonProgress.Create(long playerId, DungeonType dungeonType) : PlayerDungeonProgress`, 프로퍼티 `Id(long)`, `PlayerId(long)`, `DungeonType(DungeonType)`, `HighestClearedStage(long, 기본값 1)`, `HighScore(long, 기본값 0)`, `LastClearedAt(DateTime?)`. 인스턴스 메서드 `ClearStage(long stage)`, `UpdateHighScore(long score)`. `AppDbContext.PlayerDungeonProgresses : DbSet`. + +- [ ] **Step 1: `DungeonType` enum 작성** + +```csharp +// Fantasy-server/Fantasy.Server/Domain/Dungeon/Enum/DungeonType.cs +namespace Fantasy.Server.Domain.Dungeon.Enum; + +public enum DungeonType +{ + Basic, + Gold, + Weapon, + Boss +} +``` + +- [ ] **Step 2: 엔티티 작성** + +```csharp +// Fantasy-server/Fantasy.Server/Domain/Dungeon/Entity/PlayerDungeonProgress.cs +using Fantasy.Server.Domain.Dungeon.Enum; + +namespace Fantasy.Server.Domain.Dungeon.Entity; + +public class PlayerDungeonProgress +{ + public long Id { get; private set; } + public long PlayerId { get; private set; } + public DungeonType DungeonType { get; private set; } + public long HighestClearedStage { get; private set; } + public long HighScore { get; private set; } + public DateTime? LastClearedAt { get; private set; } + + public static PlayerDungeonProgress Create(long playerId, DungeonType dungeonType) => new() + { + PlayerId = playerId, + DungeonType = dungeonType, + HighestClearedStage = 1, + HighScore = 0, + LastClearedAt = null + }; + + public void ClearStage(long stage) + { + if (stage > HighestClearedStage) HighestClearedStage = stage; + LastClearedAt = DateTime.UtcNow; + } + + public void UpdateHighScore(long score) + { + if (score > HighScore) HighScore = score; + LastClearedAt = DateTime.UtcNow; + } +} +``` + +- [ ] **Step 3: EF Fluent 설정 작성** + +```csharp +// Fantasy-server/Fantasy.Server/Domain/Dungeon/Entity/Config/PlayerDungeonProgressConfig.cs +using Fantasy.Server.Domain.Dungeon.Entity; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using PlayerEntity = Fantasy.Server.Domain.Player.Entity.Player; + +namespace Fantasy.Server.Domain.Dungeon.Entity.Config; + +public class PlayerDungeonProgressConfig : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("player_dungeon_progress", "dungeon"); + + builder.HasKey(p => p.Id); + + builder.Property(p => p.Id) + .ValueGeneratedOnAdd(); + + builder.Property(p => p.PlayerId) + .IsRequired(); + + builder.Property(p => p.DungeonType) + .HasConversion() + .IsRequired(); + + builder.Property(p => p.HighestClearedStage) + .IsRequired() + .HasDefaultValue(1L); + + builder.Property(p => p.HighScore) + .IsRequired() + .HasDefaultValue(0L); + + builder.Property(p => p.LastClearedAt) + .IsRequired(false); + + builder.HasIndex(p => new { p.PlayerId, p.DungeonType }) + .IsUnique(); + + builder.HasOne() + .WithMany() + .HasForeignKey(p => p.PlayerId) + .OnDelete(DeleteBehavior.Cascade); + + builder.Property("xmin") + .HasColumnName("xmin") + .HasColumnType("xid") + .ValueGeneratedOnAddOrUpdate() + .IsConcurrencyToken(); + } +} +``` + +- [ ] **Step 4: `AppDbContext`에 `DbSet` 등록** + +`Fantasy-server/Fantasy.Server/Global/Infrastructure/AppDbContext.cs`의 `using Fantasy.Server.Domain.Dungeon.Entity;`는 이미 존재하므로 추가 using 불필요. `AccountDungeonTickets`/`GoldDungeonRuns` 선언 바로 아래에 추가: + +```csharp + public DbSet PlayerDungeonProgresses => Set(); +``` + +- [ ] **Step 5: 빌드 확인** + +Run: `/test PlayerDungeonProgress` +Expected: 아직 테스트가 없으므로 "일치하는 테스트 없음" 메시지와 함께 **빌드는 성공**해야 한다. + +- [ ] **Step 6: 마이그레이션 생성** + +Run: `/db-migrate add AddPlayerDungeonProgress` +Expected: `Fantasy-server/Fantasy.Server/Migrations/`에 `{timestamp}_AddPlayerDungeonProgress.cs`/`.Designer.cs`가 생성되고, `Up()`에 `player_dungeon_progress` 테이블 생성(컬럼: `Id`, `PlayerId`, `DungeonType`(text), `HighestClearedStage`(bigint, default 1), `HighScore`(bigint, default 0), `LastClearedAt`(nullable timestamp), `xmin`) + `(PlayerId, DungeonType)` unique index + `player.player`로의 FK cascade가 포함되어 있는지 확인한다. + +- [ ] **Step 7: 커밋** + +```bash +git add Fantasy-server/Fantasy.Server/Domain/Dungeon/Enum Fantasy-server/Fantasy.Server/Domain/Dungeon/Entity/PlayerDungeonProgress.cs Fantasy-server/Fantasy.Server/Domain/Dungeon/Entity/Config/PlayerDungeonProgressConfig.cs Fantasy-server/Fantasy.Server/Global/Infrastructure/AppDbContext.cs Fantasy-server/Fantasy.Server/Migrations +git commit -m "feat: PlayerDungeonProgress 엔티티 및 마이그레이션 추가" +``` + +--- + +### Task 2: `IPlayerDungeonProgressRepository` / `PlayerDungeonProgressRepository` + DI 등록 + +**Files:** +- Create: `Fantasy-server/Fantasy.Server/Domain/Dungeon/Repository/Interface/IPlayerDungeonProgressRepository.cs` +- Create: `Fantasy-server/Fantasy.Server/Domain/Dungeon/Repository/PlayerDungeonProgressRepository.cs` +- Modify: `Fantasy-server/Fantasy.Server/Domain/Dungeon/Config/DungeonServiceConfig.cs` +- Test: `Fantasy-server/Fantasy.Test/Dungeon/Repository/PlayerDungeonProgressRepositoryTests.cs` + +**Interfaces:** +- Consumes: `PlayerDungeonProgress.Create(long, DungeonType)`, `AppDbContext.PlayerDungeonProgresses` (Task 1). +- Produces: `IPlayerDungeonProgressRepository.FindByPlayerIdAndDungeonTypeAsync(long playerId, DungeonType dungeonType) : Task`, `SaveAsync(PlayerDungeonProgress progress) : Task`, `UpdateAsync(PlayerDungeonProgress progress) : Task`. + +- [ ] **Step 1: 실패하는 리포지토리 테스트 작성** + +```csharp +// Fantasy-server/Fantasy.Test/Dungeon/Repository/PlayerDungeonProgressRepositoryTests.cs +using Fantasy.Server.Domain.Dungeon.Entity; +using Fantasy.Server.Domain.Dungeon.Enum; +using Fantasy.Server.Domain.Dungeon.Repository; +using Fantasy.Server.Domain.GameData.Entity; +using Fantasy.Server.Global.Infrastructure; +using FluentAssertions; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Xunit; + +namespace Fantasy.Test.Dungeon.Repository; + +public class PlayerDungeonProgressRepositoryTests : IDisposable +{ + private readonly SqliteConnection _connection; + private readonly AppDbContext _dbContext; + private readonly PlayerDungeonProgressRepository _sut; + + public PlayerDungeonProgressRepositoryTests() + { + _connection = new SqliteConnection("DataSource=:memory:"); + _connection.Open(); + + var options = new DbContextOptionsBuilder() + .UseSqlite(_connection) + .Options; + + _dbContext = new TestAppDbContext(options); + _dbContext.Database.EnsureCreated(); + _sut = new PlayerDungeonProgressRepository(_dbContext); + } + + [Fact] + public async Task FindByPlayerIdAndDungeonTypeAsync_존재하지_않으면_null을_반환한다() + { + var result = await _sut.FindByPlayerIdAndDungeonTypeAsync(1L, DungeonType.Weapon); + + result.Should().BeNull(); + } + + [Fact] + public async Task SaveAsync_저장한_레코드를_FindByPlayerIdAndDungeonTypeAsync로_조회할_수_있다() + { + await _sut.SaveAsync(PlayerDungeonProgress.Create(1L, DungeonType.Weapon)); + + var result = await _sut.FindByPlayerIdAndDungeonTypeAsync(1L, DungeonType.Weapon); + + result.Should().NotBeNull(); + result!.HighestClearedStage.Should().Be(1); + } + + [Fact] + public async Task UpdateAsync_변경한_값이_반영된다() + { + var progress = PlayerDungeonProgress.Create(1L, DungeonType.Gold); + await _sut.SaveAsync(progress); + + progress.UpdateHighScore(500L); + await _sut.UpdateAsync(progress); + + var result = await _sut.FindByPlayerIdAndDungeonTypeAsync(1L, DungeonType.Gold); + result!.HighScore.Should().Be(500L); + } + + [Fact] + public async Task 다른_DungeonType은_독립적으로_저장된다() + { + await _sut.SaveAsync(PlayerDungeonProgress.Create(1L, DungeonType.Weapon)); + await _sut.SaveAsync(PlayerDungeonProgress.Create(1L, DungeonType.Boss)); + + var weapon = await _sut.FindByPlayerIdAndDungeonTypeAsync(1L, DungeonType.Weapon); + var boss = await _sut.FindByPlayerIdAndDungeonTypeAsync(1L, DungeonType.Boss); + + weapon.Should().NotBeNull(); + boss.Should().NotBeNull(); + weapon!.DungeonType.Should().Be(DungeonType.Weapon); + boss!.DungeonType.Should().Be(DungeonType.Boss); + } + + public void Dispose() + { + _dbContext.Dispose(); + _connection.Dispose(); + } + + private sealed class TestAppDbContext : AppDbContext + { + public TestAppDbContext(DbContextOptions options) + : base(options) + { + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Ignore(); + modelBuilder.Ignore(); + modelBuilder.Ignore(); + modelBuilder.Ignore(); + modelBuilder.Ignore(); + + modelBuilder.Entity(entity => + { + entity.ToTable("player_dungeon_progress"); + entity.HasKey(p => p.Id); + entity.Property(p => p.Id).ValueGeneratedOnAdd(); + entity.Property(p => p.PlayerId).IsRequired(); + entity.Property(p => p.DungeonType).HasConversion().IsRequired(); + entity.Property(p => p.HighestClearedStage).IsRequired(); + entity.Property(p => p.HighScore).IsRequired(); + entity.HasIndex(p => new { p.PlayerId, p.DungeonType }).IsUnique(); + }); + } + } +} +``` + +- [ ] **Step 2: 실패 확인** + +Run: `/test PlayerDungeonProgressRepositoryTests` +Expected: FAIL — `PlayerDungeonProgressRepository`/`IPlayerDungeonProgressRepository`가 없어 컴파일 에러. + +- [ ] **Step 3: 인터페이스 작성** + +```csharp +// Fantasy-server/Fantasy.Server/Domain/Dungeon/Repository/Interface/IPlayerDungeonProgressRepository.cs +using Fantasy.Server.Domain.Dungeon.Entity; +using Fantasy.Server.Domain.Dungeon.Enum; + +namespace Fantasy.Server.Domain.Dungeon.Repository.Interface; + +public interface IPlayerDungeonProgressRepository +{ + Task FindByPlayerIdAndDungeonTypeAsync(long playerId, DungeonType dungeonType); + Task SaveAsync(PlayerDungeonProgress progress); + Task UpdateAsync(PlayerDungeonProgress progress); +} +``` + +- [ ] **Step 4: 구현 작성** + +```csharp +// Fantasy-server/Fantasy.Server/Domain/Dungeon/Repository/PlayerDungeonProgressRepository.cs +using Fantasy.Server.Domain.Dungeon.Entity; +using Fantasy.Server.Domain.Dungeon.Enum; +using Fantasy.Server.Domain.Dungeon.Repository.Interface; +using Fantasy.Server.Global.Infrastructure; +using Microsoft.EntityFrameworkCore; + +namespace Fantasy.Server.Domain.Dungeon.Repository; + +public class PlayerDungeonProgressRepository : IPlayerDungeonProgressRepository +{ + private readonly AppDbContext _db; + + public PlayerDungeonProgressRepository(AppDbContext db) => _db = db; + + public async Task FindByPlayerIdAndDungeonTypeAsync(long playerId, DungeonType dungeonType) + => await _db.PlayerDungeonProgresses + .AsNoTracking() + .FirstOrDefaultAsync(p => p.PlayerId == playerId && p.DungeonType == dungeonType); + + public async Task SaveAsync(PlayerDungeonProgress progress) + { + await _db.PlayerDungeonProgresses.AddAsync(progress); + await _db.SaveChangesAsync(); + } + + public async Task UpdateAsync(PlayerDungeonProgress progress) + { + _db.PlayerDungeonProgresses.Update(progress); + await _db.SaveChangesAsync(); + } +} +``` + +- [ ] **Step 5: 통과 확인** + +Run: `/test PlayerDungeonProgressRepositoryTests` +Expected: PASS (4 tests) + +- [ ] **Step 6: DI 등록** + +`Fantasy-server/Fantasy.Server/Domain/Dungeon/Config/DungeonServiceConfig.cs`의 `services.AddScoped();` 바로 아래 줄에 추가: + +```csharp + services.AddScoped(); +``` + +- [ ] **Step 7: 전체 빌드 확인** + +Run: `/test` +Expected: 전체 빌드 성공 + 전체 테스트 통과(회귀 없음). + +- [ ] **Step 8: 커밋** + +```bash +git add Fantasy-server/Fantasy.Server/Domain/Dungeon/Repository Fantasy-server/Fantasy.Server/Domain/Dungeon/Config/DungeonServiceConfig.cs Fantasy-server/Fantasy.Test/Dungeon/Repository +git commit -m "feat: PlayerDungeonProgressRepository 추가 및 DI 등록" +``` + +--- + +### Task 3: `WeaponDungeonService` — 독립 진행도 적용 + +**Files:** +- Modify: `Fantasy-server/Fantasy.Server/Domain/Dungeon/Dto/Response/WeaponDungeonResponse.cs` +- Modify: `Fantasy-server/Fantasy.Server/Domain/Dungeon/Service/WeaponDungeonService.cs` +- Modify: `Fantasy-server/Fantasy.Test/Dungeon/Service/WeaponDungeonServiceTests.cs` + +**Interfaces:** +- Consumes: `IPlayerDungeonProgressRepository`(Task 2), 기존 `IAppDbTransactionRunner`(신규로 이 서비스에 주입). +- Produces: `WeaponDungeonResponse(bool Cleared, List DroppedWeapons, long DroppedScrolls, long HighestClearedStage)`. + +**중요한 구조 변경:** 기존 코드는 드랍(`droppedWeapons.Count > 0 || droppedScrolls > 0`)이 있을 때만 저장 블록을 실행했다. 이 태스크부터는 **클리어만 해도**(드랍이 전혀 없어도) `HighestClearedStage`가 갱신되어야 하므로, 저장 조건을 `if (cleared)`로 바꾼다. 단, Redis 캐시 무효화는 `PlayerDataResponse`에 포함되는 무기/재화가 실제로 바뀌었을 때만 필요하므로 기존처럼 드랍 여부에 조건부로 남긴다. 이 서비스는 지금까지 `IAppDbTransactionRunner`가 없었으므로(무기 던전만 유일하게 여러 리포지토리를 트랜잭션 없이 갱신하던 예외였다) 이번에 처음 주입한다 — 신규로 추가하는 세 번째 쓰기(진행도 갱신)를 다른 형제 서비스처럼 트랜잭션으로 묶기 위한 자연스러운 정리다. + +- [ ] **Step 1: `WeaponDungeonResponse`에 필드 추가** + +```csharp +// Fantasy-server/Fantasy.Server/Domain/Dungeon/Dto/Response/WeaponDungeonResponse.cs +namespace Fantasy.Server.Domain.Dungeon.Dto.Response; + +public record WeaponDungeonResponse( + bool Cleared, + List DroppedWeapons, + long DroppedScrolls, + long HighestClearedStage +); +``` + +- [ ] **Step 2: 실패하는 서비스 테스트 작성** + +`Fantasy-server/Fantasy.Test/Dungeon/Service/WeaponDungeonServiceTests.cs`의 `BuildSut` 헬퍼를 아래로 교체(새 파라미터 2개 추가, 기존 파라미터는 순서·이름 그대로 유지): + +```csharp + private static WeaponDungeonService BuildSut( + IPlayerRepository? playerRepo = null, + IPlayerResourceRepository? resourceRepo = null, + IPlayerStageRepository? stageRepo = null, + IPlayerSessionRepository? sessionRepo = null, + IPlayerWeaponRepository? weaponRepo = null, + IPlayerSkillRepository? skillRepo = null, + IPlayerRedisRepository? redisRepo = null, + IGameDataCacheService? cache = null, + IPlayerDungeonProgressRepository? progressRepo = null, + IAppDbTransactionRunner? txRunner = null, + ICurrentUserProvider? userProvider = null, + ICombatStatCalculator? calculator = null) + { + playerRepo ??= Substitute.For(); + resourceRepo ??= Substitute.For(); + stageRepo ??= Substitute.For(); + sessionRepo ??= Substitute.For(); + weaponRepo ??= Substitute.For(); + skillRepo ??= Substitute.For(); + redisRepo ??= Substitute.For(); + cache ??= Substitute.For(); + progressRepo ??= Substitute.For(); + txRunner ??= Substitute.For(); + userProvider ??= Substitute.For(); + calculator ??= new CombatStatCalculator(); + + return new WeaponDungeonService( + playerRepo, resourceRepo, stageRepo, sessionRepo, + weaponRepo, skillRepo, redisRepo, cache, + progressRepo, txRunner, userProvider, calculator); + } +``` + +상단 using에 추가: + +```csharp +using Fantasy.Server.Domain.Dungeon.Entity; +using Fantasy.Server.Domain.Dungeon.Enum; +using Fantasy.Server.Domain.Dungeon.Repository.Interface; +using Fantasy.Server.Global.Infrastructure; +``` + +기존 `전투력이_부족해서_클리어_실패할_때`, `전투력이_충분해서_클리어_성공할_때`, `DPS_곱하기_30이_몬스터HP와_정확히_같을_때` 세 클래스는 전부 `_gameDataCacheService.GetStageDataAsync(1)`을 이미 스텁하고 있으므로(진행도가 없을 때 기본값 1을 조회한다는 이번 설계와 일치) 그대로 컴파일·통과해야 한다. 여기에 신규 테스트 클래스를 추가한다: + +```csharp + public class 진행도가_있고_클리어에_성공할_때 + { + private readonly IPlayerRepository _playerRepository = Substitute.For(); + private readonly IPlayerResourceRepository _playerResourceRepository = Substitute.For(); + private readonly IPlayerStageRepository _playerStageRepository = Substitute.For(); + private readonly IPlayerSessionRepository _playerSessionRepository = Substitute.For(); + private readonly IPlayerWeaponRepository _playerWeaponRepository = Substitute.For(); + private readonly IPlayerSkillRepository _playerSkillRepository = Substitute.For(); + private readonly IPlayerRedisRepository _playerRedisRepository = Substitute.For(); + private readonly IGameDataCacheService _gameDataCacheService = Substitute.For(); + private readonly IPlayerDungeonProgressRepository _progressRepository = Substitute.For(); + private readonly IAppDbTransactionRunner _transactionRunner = Substitute.For(); + private readonly ICurrentUserProvider _currentUserProvider = Substitute.For(); + + public 진행도가_있고_클리어에_성공할_때() + { + _currentUserProvider.GetAccountId().Returns(1L); + _playerRepository.FindByAccountAsync(1L) + .Returns(PlayerEntity.Create(1L, JobType.Warrior)); + _playerResourceRepository.FindByPlayerIdAsync(Arg.Any()) + .Returns(PlayerResource.Create(1L)); + _playerStageRepository.FindByPlayerIdAsync(Arg.Any()) + .Returns(PlayerStage.Create(1L)); + _playerSessionRepository.FindByPlayerIdAsync(Arg.Any()) + .Returns(PlayerSession.Create(1L)); + _playerWeaponRepository.FindAllByPlayerIdAsync(Arg.Any()).Returns([]); + _playerSkillRepository.FindAllByPlayerIdAsync(Arg.Any()).Returns([]); + + // 기존 진행도: HighestClearedStage = 5 + var existingProgress = PlayerDungeonProgress.Create(1L, DungeonType.Weapon); + existingProgress.ClearStage(5); + _progressRepository.FindByPlayerIdAndDungeonTypeAsync(1L, DungeonType.Weapon) + .Returns(existingProgress); + + // 몬스터 HP = 1 → 항상 클리어 + var stageData = StageData.Create(5, monsterHp: 1, monsterAtk: 1, xpPerSecond: 5, goldPerSecond: 10); + _gameDataCacheService.GetStageDataAsync(5).Returns(stageData); + _gameDataCacheService.GetJobBaseStatAsync(JobType.Warrior) + .Returns(JobBaseStat.Create(JobType.Warrior, 1000, 100, 0, 1.5, 10, 10)); + _gameDataCacheService.GetSkillDataByJobAsync(Arg.Any()).Returns([]); + _gameDataCacheService.GetWeaponDataByGradeAsync(Arg.Any()).Returns([]); + + _transactionRunner.ExecuteAsync(Arg.Any>()) + .Returns(callInfo => callInfo.Arg>()()); + } + + [Fact] + public async Task 자기_진행도_기준으로_스테이지를_조회한다() + { + var sut = BuildSut( + playerRepo: _playerRepository, resourceRepo: _playerResourceRepository, + stageRepo: _playerStageRepository, sessionRepo: _playerSessionRepository, + weaponRepo: _playerWeaponRepository, skillRepo: _playerSkillRepository, + redisRepo: _playerRedisRepository, cache: _gameDataCacheService, + progressRepo: _progressRepository, txRunner: _transactionRunner, + userProvider: _currentUserProvider); + + await sut.ExecuteAsync(); + + await _gameDataCacheService.Received(1).GetStageDataAsync(5); + } + + [Fact] + public async Task HighestClearedStage가_1_증가한다() + { + var sut = BuildSut( + playerRepo: _playerRepository, resourceRepo: _playerResourceRepository, + stageRepo: _playerStageRepository, sessionRepo: _playerSessionRepository, + weaponRepo: _playerWeaponRepository, skillRepo: _playerSkillRepository, + redisRepo: _playerRedisRepository, cache: _gameDataCacheService, + progressRepo: _progressRepository, txRunner: _transactionRunner, + userProvider: _currentUserProvider); + + var result = await sut.ExecuteAsync(); + + result.HighestClearedStage.Should().Be(6); + } + + [Fact] + public async Task 진행도_UpdateAsync가_호출된다() + { + var sut = BuildSut( + playerRepo: _playerRepository, resourceRepo: _playerResourceRepository, + stageRepo: _playerStageRepository, sessionRepo: _playerSessionRepository, + weaponRepo: _playerWeaponRepository, skillRepo: _playerSkillRepository, + redisRepo: _playerRedisRepository, cache: _gameDataCacheService, + progressRepo: _progressRepository, txRunner: _transactionRunner, + userProvider: _currentUserProvider); + + await sut.ExecuteAsync(); + + await _progressRepository.Received(1).UpdateAsync(Arg.Any()); + } + + [Fact] + public async Task 드랍이_없으면_Redis_캐시가_무효화되지_않는다() + { + var sut = BuildSut( + playerRepo: _playerRepository, resourceRepo: _playerResourceRepository, + stageRepo: _playerStageRepository, sessionRepo: _playerSessionRepository, + weaponRepo: _playerWeaponRepository, skillRepo: _playerSkillRepository, + redisRepo: _playerRedisRepository, cache: _gameDataCacheService, + progressRepo: _progressRepository, txRunner: _transactionRunner, + userProvider: _currentUserProvider); + + await sut.ExecuteAsync(); + + await _playerRedisRepository.DidNotReceive().DeleteAsync(Arg.Any()); + } + } + + public class 진행도가_없을_때_신규_생성후_클리어에_성공하면 + { + private readonly IPlayerRepository _playerRepository = Substitute.For(); + private readonly IPlayerResourceRepository _playerResourceRepository = Substitute.For(); + private readonly IPlayerStageRepository _playerStageRepository = Substitute.For(); + private readonly IPlayerSessionRepository _playerSessionRepository = Substitute.For(); + private readonly IPlayerWeaponRepository _playerWeaponRepository = Substitute.For(); + private readonly IPlayerSkillRepository _playerSkillRepository = Substitute.For(); + private readonly IGameDataCacheService _gameDataCacheService = Substitute.For(); + private readonly IPlayerDungeonProgressRepository _progressRepository = Substitute.For(); + private readonly IAppDbTransactionRunner _transactionRunner = Substitute.For(); + private readonly ICurrentUserProvider _currentUserProvider = Substitute.For(); + + public 진행도가_없을_때_신규_생성후_클리어에_성공하면() + { + _currentUserProvider.GetAccountId().Returns(1L); + _playerRepository.FindByAccountAsync(1L) + .Returns(PlayerEntity.Create(1L, JobType.Warrior)); + _playerResourceRepository.FindByPlayerIdAsync(Arg.Any()) + .Returns(PlayerResource.Create(1L)); + _playerStageRepository.FindByPlayerIdAsync(Arg.Any()) + .Returns(PlayerStage.Create(1L)); + _playerSessionRepository.FindByPlayerIdAsync(Arg.Any()) + .Returns(PlayerSession.Create(1L)); + _playerWeaponRepository.FindAllByPlayerIdAsync(Arg.Any()).Returns([]); + _playerSkillRepository.FindAllByPlayerIdAsync(Arg.Any()).Returns([]); + + _progressRepository.FindByPlayerIdAndDungeonTypeAsync(1L, DungeonType.Weapon) + .Returns((PlayerDungeonProgress?)null); + + var stageData = StageData.Create(1, monsterHp: 1, monsterAtk: 1, xpPerSecond: 5, goldPerSecond: 10); + _gameDataCacheService.GetStageDataAsync(1).Returns(stageData); + _gameDataCacheService.GetJobBaseStatAsync(JobType.Warrior) + .Returns(JobBaseStat.Create(JobType.Warrior, 1000, 100, 0, 1.5, 10, 10)); + _gameDataCacheService.GetSkillDataByJobAsync(Arg.Any()).Returns([]); + _gameDataCacheService.GetWeaponDataByGradeAsync(Arg.Any()).Returns([]); + + _transactionRunner.ExecuteAsync(Arg.Any>()) + .Returns(callInfo => callInfo.Arg>()()); + } + + [Fact] + public async Task HighestClearedStage가_2로_반환된다() + { + var sut = BuildSut( + playerRepo: _playerRepository, resourceRepo: _playerResourceRepository, + stageRepo: _playerStageRepository, sessionRepo: _playerSessionRepository, + weaponRepo: _playerWeaponRepository, skillRepo: _playerSkillRepository, + cache: _gameDataCacheService, progressRepo: _progressRepository, + txRunner: _transactionRunner, userProvider: _currentUserProvider); + + var result = await sut.ExecuteAsync(); + + result.HighestClearedStage.Should().Be(2); + } + + [Fact] + public async Task 진행도_SaveAsync가_호출된다() + { + var sut = BuildSut( + playerRepo: _playerRepository, resourceRepo: _playerResourceRepository, + stageRepo: _playerStageRepository, sessionRepo: _playerSessionRepository, + weaponRepo: _playerWeaponRepository, skillRepo: _playerSkillRepository, + cache: _gameDataCacheService, progressRepo: _progressRepository, + txRunner: _transactionRunner, userProvider: _currentUserProvider); + + await sut.ExecuteAsync(); + + await _progressRepository.Received(1).SaveAsync(Arg.Any()); + } + } +``` + +- [ ] **Step 3: 실패 확인** + +Run: `/test WeaponDungeonServiceTests` +Expected: FAIL — `WeaponDungeonService` 생성자 시그니처 불일치 및 `WeaponDungeonResponse` 필드 불일치로 컴파일 에러. + +- [ ] **Step 4: `WeaponDungeonService` 구현** + +`Fantasy-server/Fantasy.Server/Domain/Dungeon/Service/WeaponDungeonService.cs` 전체를 아래로 교체: + +```csharp +using Fantasy.Server.Domain.Dungeon.Dto.Response; +using Fantasy.Server.Domain.Dungeon.Entity; +using Fantasy.Server.Domain.Dungeon.Enum; +using Fantasy.Server.Domain.Dungeon.Repository.Interface; +using Fantasy.Server.Domain.Dungeon.Service.Interface; +using Fantasy.Server.Domain.GameData.Entity; +using Fantasy.Server.Domain.GameData.Enum; +using Fantasy.Server.Domain.GameData.Service.Interface; +using Fantasy.Server.Domain.Player.Dto.Request; +using Fantasy.Server.Domain.Player.Repository.Interface; +using Fantasy.Server.Global.Infrastructure; +using Fantasy.Server.Global.Security.Provider; +using Gamism.SDK.Extensions.AspNetCore.Exceptions; + +namespace Fantasy.Server.Domain.Dungeon.Service; + +public class WeaponDungeonService : IWeaponDungeonService +{ + private const int BGradeDropRatePercent = 20; + private const int CGradeDropRatePercent = 70; + private const int ScrollDropRatePercent = 30; + + private readonly IPlayerRepository _playerRepository; + private readonly IPlayerResourceRepository _playerResourceRepository; + private readonly IPlayerStageRepository _playerStageRepository; + private readonly IPlayerSessionRepository _playerSessionRepository; + private readonly IPlayerWeaponRepository _playerWeaponRepository; + private readonly IPlayerSkillRepository _playerSkillRepository; + private readonly IPlayerRedisRepository _playerRedisRepository; + private readonly IGameDataCacheService _gameDataCacheService; + private readonly IPlayerDungeonProgressRepository _playerDungeonProgressRepository; + private readonly IAppDbTransactionRunner _transactionRunner; + private readonly ICurrentUserProvider _currentUserProvider; + private readonly ICombatStatCalculator _calculator; + + public WeaponDungeonService( + IPlayerRepository playerRepository, + IPlayerResourceRepository playerResourceRepository, + IPlayerStageRepository playerStageRepository, + IPlayerSessionRepository playerSessionRepository, + IPlayerWeaponRepository playerWeaponRepository, + IPlayerSkillRepository playerSkillRepository, + IPlayerRedisRepository playerRedisRepository, + IGameDataCacheService gameDataCacheService, + IPlayerDungeonProgressRepository playerDungeonProgressRepository, + IAppDbTransactionRunner transactionRunner, + ICurrentUserProvider currentUserProvider, + ICombatStatCalculator calculator) + { + _playerRepository = playerRepository; + _playerResourceRepository = playerResourceRepository; + _playerStageRepository = playerStageRepository; + _playerSessionRepository = playerSessionRepository; + _playerWeaponRepository = playerWeaponRepository; + _playerSkillRepository = playerSkillRepository; + _playerRedisRepository = playerRedisRepository; + _gameDataCacheService = gameDataCacheService; + _playerDungeonProgressRepository = playerDungeonProgressRepository; + _transactionRunner = transactionRunner; + _currentUserProvider = currentUserProvider; + _calculator = calculator; + } + + public async Task ExecuteAsync() + { + var accountId = _currentUserProvider.GetAccountId(); + + var player = await _playerRepository.FindByAccountAsync(accountId) + ?? throw new NotFoundException("플레이어 데이터를 찾을 수 없습니다."); + + var jobType = player.JobType; + + var resource = await _playerResourceRepository.FindByPlayerIdAsync(player.Id) + ?? throw new NotFoundException("플레이어 재화 데이터를 찾을 수 없습니다."); + + var stage = await _playerStageRepository.FindByPlayerIdAsync(player.Id) + ?? throw new NotFoundException("플레이어 스테이지 데이터를 찾을 수 없습니다."); + + var session = await _playerSessionRepository.FindByPlayerIdAsync(player.Id) + ?? throw new NotFoundException("플레이어 세션 데이터를 찾을 수 없습니다."); + + var weapons = await _playerWeaponRepository.FindAllByPlayerIdAsync(player.Id); + var skills = await _playerSkillRepository.FindAllByPlayerIdAsync(player.Id); + + var jobStat = await _gameDataCacheService.GetJobBaseStatAsync(player.JobType) + ?? throw new NotFoundException("직업 기본 스탯 데이터를 찾을 수 없습니다."); + + WeaponData? weaponData = null; + long weaponEnhancement = 0; + if (session.LastWeaponId.HasValue) + { + weaponData = await _gameDataCacheService.GetWeaponDataAsync(session.LastWeaponId.Value); + var equippedWeapon = weapons.FirstOrDefault(w => w.WeaponId == session.LastWeaponId.Value); + weaponEnhancement = equippedWeapon?.EnhancementLevel ?? 0; + } + + var jobSkillData = await _gameDataCacheService.GetSkillDataByJobAsync(player.JobType); + var unlockedPassiveSkills = skills + .Where(s => s.IsUnlocked) + .Select(s => jobSkillData.FirstOrDefault(sd => sd.SkillId == s.SkillId)) + .Where(sd => sd is not null && !sd.IsActive) + .Select(sd => (Skill: sd!, IsPassive: true)); + + var combatStat = _calculator.Calculate(player.Level, jobStat, weaponData, weaponEnhancement, unlockedPassiveSkills); + + var progress = await _playerDungeonProgressRepository.FindByPlayerIdAndDungeonTypeAsync(player.Id, DungeonType.Weapon); + var isNewProgress = progress is null; + var currentStage = progress?.HighestClearedStage ?? 1; + + var stageData = await _gameDataCacheService.GetStageDataAsync(currentStage) + ?? throw new NotFoundException("스테이지 데이터를 찾을 수 없습니다."); + + var dps = _calculator.CalculateDps(combatStat); + var cleared = dps * 30 >= stageData.MonsterHp; + + var droppedWeapons = new List(); + long droppedScrolls = 0; + + if (cleared) + { + // B등급 드랍 시도 + if (Random.Shared.Next(0, 100) < BGradeDropRatePercent) + { + var bWeapons = await _gameDataCacheService.GetWeaponDataByGradeAsync(WeaponGrade.B); + var bJobWeapons = bWeapons.Where(w => w.JobType == jobType).ToList(); + if (bJobWeapons.Count > 0) + { + var dropped = bJobWeapons[Random.Shared.Next(bJobWeapons.Count)]; + droppedWeapons.Add(new DroppedWeaponInfo(dropped.WeaponId, dropped.Name, dropped.Grade)); + } + } + // C등급 드랍 시도 + if (Random.Shared.Next(0, 100) < CGradeDropRatePercent) + { + var cWeapons = await _gameDataCacheService.GetWeaponDataByGradeAsync(WeaponGrade.C); + var cJobWeapons = cWeapons.Where(w => w.JobType == jobType).ToList(); + if (cJobWeapons.Count > 0) + { + var dropped = cJobWeapons[Random.Shared.Next(cJobWeapons.Count)]; + droppedWeapons.Add(new DroppedWeaponInfo(dropped.WeaponId, dropped.Name, dropped.Grade)); + } + } + + // 스크롤 드랍 시도 + if (Random.Shared.Next(0, 100) < ScrollDropRatePercent) + droppedScrolls = 1; + + progress ??= PlayerDungeonProgress.Create(player.Id, DungeonType.Weapon); + progress.ClearStage(currentStage + 1); + + var weaponChanges = droppedWeapons + .Select(w => + { + var existing = weapons.FirstOrDefault(pw => pw.WeaponId == w.WeaponId); + return new WeaponChangeItem(w.WeaponId, (existing?.Count ?? 0) + 1, + existing?.EnhancementLevel ?? 0, existing?.AwakeningCount ?? 0); + }) + .ToList(); + + await _transactionRunner.ExecuteAsync(async () => + { + if (weaponChanges.Count > 0) + await _playerWeaponRepository.UpsertRangeAsync(player.Id, weaponChanges); + + if (droppedScrolls > 0) + { + resource.UpdateChangeData(resource.EnhancementScroll + droppedScrolls, null, null); + await _playerResourceRepository.UpdateAsync(resource); + } + + if (isNewProgress) + await _playerDungeonProgressRepository.SaveAsync(progress); + else + await _playerDungeonProgressRepository.UpdateAsync(progress); + }); + + if (droppedWeapons.Count > 0 || droppedScrolls > 0) + await _playerRedisRepository.DeleteAsync(accountId); + } + + var highestClearedStage = progress?.HighestClearedStage ?? currentStage; + return new WeaponDungeonResponse(cleared, droppedWeapons, droppedScrolls, highestClearedStage); + } +} +``` + +- [ ] **Step 5: 통과 확인** + +Run: `/test WeaponDungeonServiceTests` +Expected: PASS (기존 6개 + 신규 6개 = 12개) + +- [ ] **Step 6: 전체 빌드 확인** + +Run: `/test` +Expected: 전체 통과. 다른 파일에서 `new WeaponDungeonResponse(...)`를 호출하는 곳이 없는지 확인(컨트롤러는 서비스 반환값을 그대로 전달하므로 수정 불필요). + +- [ ] **Step 7: 커밋** + +```bash +git add Fantasy-server/Fantasy.Server/Domain/Dungeon/Dto/Response/WeaponDungeonResponse.cs Fantasy-server/Fantasy.Server/Domain/Dungeon/Service/WeaponDungeonService.cs Fantasy-server/Fantasy.Test/Dungeon/Service/WeaponDungeonServiceTests.cs +git commit -m "feat: 무기 던전에 독립 진행도(HighestClearedStage) 적용" +``` + +--- + +### Task 4: `BossDungeonService` — 독립 진행도 적용 + +**Files:** +- Modify: `Fantasy-server/Fantasy.Server/Domain/Dungeon/Dto/Response/BossDungeonResponse.cs` +- Modify: `Fantasy-server/Fantasy.Server/Domain/Dungeon/Service/BossDungeonService.cs` +- Modify: `Fantasy-server/Fantasy.Test/Dungeon/Service/BossDungeonServiceTests.cs` + +**Interfaces:** +- Consumes: `IPlayerDungeonProgressRepository`(Task 2), 기존 `IAppDbTransactionRunner`(이미 주입되어 있음 — 새로 추가하지 않음). +- Produces: `BossDungeonResponse(bool Cleared, long EarnedMithril, DroppedWeaponInfo? DroppedWeapon, long EarnedXp, List LevelUps, long HighestClearedStage)`. + +**Weapon과의 차이:** `BossDungeonService`는 이미 `_transactionRunner`를 갖고 있고, 클리어 시(`!cleared`로 조기 반환하지 않는 한) 트랜잭션이 항상 실행되므로 조건 재구성이 필요 없다 — 기존 트랜잭션 람다 안에 진행도 저장 호출만 추가하면 된다. + +- [ ] **Step 1: `BossDungeonResponse`에 필드 추가** + +```csharp +// Fantasy-server/Fantasy.Server/Domain/Dungeon/Dto/Response/BossDungeonResponse.cs +using Fantasy.Server.Domain.LevelUp.Dto.Response; + +namespace Fantasy.Server.Domain.Dungeon.Dto.Response; + +public record BossDungeonResponse( + bool Cleared, + long EarnedMithril, + DroppedWeaponInfo? DroppedWeapon, + long EarnedXp, + List LevelUps, + long HighestClearedStage +); +``` + +- [ ] **Step 2: 실패하는 서비스 테스트 작성** + +`Fantasy-server/Fantasy.Test/Dungeon/Service/BossDungeonServiceTests.cs`의 `BuildSut` 헬퍼를 아래로 교체(새 파라미터 1개 추가): + +```csharp + private static BossDungeonService BuildSut( + IPlayerRepository? playerRepo = null, + IPlayerResourceRepository? resourceRepo = null, + IPlayerStageRepository? stageRepo = null, + IPlayerSessionRepository? sessionRepo = null, + IPlayerWeaponRepository? weaponRepo = null, + IPlayerSkillRepository? skillRepo = null, + IPlayerRedisRepository? redisRepo = null, + IGameDataCacheService? cache = null, + ILevelUpService? levelUpService = null, + IPlayerDungeonProgressRepository? progressRepo = null, + IAppDbTransactionRunner? txRunner = null, + ICurrentUserProvider? userProvider = null, + ICombatStatCalculator? calculator = null) + { + playerRepo ??= Substitute.For(); + resourceRepo ??= Substitute.For(); + stageRepo ??= Substitute.For(); + sessionRepo ??= Substitute.For(); + weaponRepo ??= Substitute.For(); + skillRepo ??= Substitute.For(); + redisRepo ??= Substitute.For(); + cache ??= Substitute.For(); + levelUpService ??= Substitute.For(); + progressRepo ??= Substitute.For(); + txRunner ??= Substitute.For(); + userProvider ??= Substitute.For(); + calculator ??= new CombatStatCalculator(); + + return new BossDungeonService( + playerRepo, resourceRepo, stageRepo, sessionRepo, + weaponRepo, skillRepo, redisRepo, cache, + levelUpService, progressRepo, txRunner, userProvider, calculator); + } +``` + +상단 using에 추가: + +```csharp +using Fantasy.Server.Domain.Dungeon.Entity; +using Fantasy.Server.Domain.Dungeon.Enum; +using Fantasy.Server.Domain.Dungeon.Repository.Interface; +``` + +기존 세 클래스(`플레이어가_없을_때`, `전투력이_부족해서_클리어_실패할_때`, `전투력이_충분해서_클리어_성공할_때`, `DPS_곱하기_30이_보스HP와_정확히_같을_때`)는 모두 `_gameDataCacheService.GetStageDataAsync(1)`을 스텁하므로 그대로 컴파일·통과해야 한다. 신규 테스트 클래스를 추가한다: + +```csharp + public class 진행도가_있고_클리어에_성공할_때 + { + private readonly IPlayerRepository _playerRepository = Substitute.For(); + private readonly IPlayerResourceRepository _playerResourceRepository = Substitute.For(); + private readonly IPlayerStageRepository _playerStageRepository = Substitute.For(); + private readonly IPlayerSessionRepository _playerSessionRepository = Substitute.For(); + private readonly IPlayerWeaponRepository _playerWeaponRepository = Substitute.For(); + private readonly IPlayerSkillRepository _playerSkillRepository = Substitute.For(); + private readonly IPlayerRedisRepository _playerRedisRepository = Substitute.For(); + private readonly IGameDataCacheService _gameDataCacheService = Substitute.For(); + private readonly ILevelUpService _levelUpService = Substitute.For(); + private readonly IPlayerDungeonProgressRepository _progressRepository = Substitute.For(); + private readonly IAppDbTransactionRunner _transactionRunner = Substitute.For(); + private readonly ICurrentUserProvider _currentUserProvider = Substitute.For(); + + public 진행도가_있고_클리어에_성공할_때() + { + _currentUserProvider.GetAccountId().Returns(1L); + _playerRepository.FindByAccountAsync(1L) + .Returns(PlayerEntity.Create(1L, JobType.Warrior)); + _playerResourceRepository.FindByPlayerIdAsync(Arg.Any()) + .Returns(PlayerResource.Create(1L)); + _playerStageRepository.FindByPlayerIdAsync(Arg.Any()) + .Returns(PlayerStage.Create(1L)); + _playerSessionRepository.FindByPlayerIdAsync(Arg.Any()) + .Returns(PlayerSession.Create(1L)); + _playerWeaponRepository.FindAllByPlayerIdAsync(Arg.Any()).Returns([]); + _playerSkillRepository.FindAllByPlayerIdAsync(Arg.Any()).Returns([]); + + var existingProgress = PlayerDungeonProgress.Create(1L, DungeonType.Boss); + existingProgress.ClearStage(3); + _progressRepository.FindByPlayerIdAndDungeonTypeAsync(1L, DungeonType.Boss) + .Returns(existingProgress); + + // 보스 HP = 1 * 5 = 5 → DPS(100) > 5 → 클리어 + var stageData = StageData.Create(3, monsterHp: 1, monsterAtk: 1, xpPerSecond: 5, goldPerSecond: 10); + _gameDataCacheService.GetStageDataAsync(3).Returns(stageData); + _gameDataCacheService.GetJobBaseStatAsync(JobType.Warrior) + .Returns(JobBaseStat.Create(JobType.Warrior, 1000, 100, 0, 1.5, 10, 10)); + _gameDataCacheService.GetSkillDataByJobAsync(Arg.Any()).Returns([]); + _gameDataCacheService.GetWeaponDataByGradeAsync(WeaponGrade.A).Returns([]); + _levelUpService.ExecuteAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns([]); + _transactionRunner.ExecuteAsync(Arg.Any>()) + .Returns(callInfo => callInfo.Arg>()()); + } + + [Fact] + public async Task 자기_진행도_기준으로_보스_스테이지를_조회한다() + { + var sut = BuildSut( + playerRepo: _playerRepository, resourceRepo: _playerResourceRepository, + stageRepo: _playerStageRepository, sessionRepo: _playerSessionRepository, + weaponRepo: _playerWeaponRepository, skillRepo: _playerSkillRepository, + redisRepo: _playerRedisRepository, cache: _gameDataCacheService, + levelUpService: _levelUpService, progressRepo: _progressRepository, + txRunner: _transactionRunner, userProvider: _currentUserProvider); + + await sut.ExecuteAsync(); + + await _gameDataCacheService.Received(1).GetStageDataAsync(3); + } + + [Fact] + public async Task HighestClearedStage가_1_증가한다() + { + var sut = BuildSut( + playerRepo: _playerRepository, resourceRepo: _playerResourceRepository, + stageRepo: _playerStageRepository, sessionRepo: _playerSessionRepository, + weaponRepo: _playerWeaponRepository, skillRepo: _playerSkillRepository, + redisRepo: _playerRedisRepository, cache: _gameDataCacheService, + levelUpService: _levelUpService, progressRepo: _progressRepository, + txRunner: _transactionRunner, userProvider: _currentUserProvider); + + var result = await sut.ExecuteAsync(); + + result.HighestClearedStage.Should().Be(4); + } + + [Fact] + public async Task 진행도_UpdateAsync가_호출된다() + { + var sut = BuildSut( + playerRepo: _playerRepository, resourceRepo: _playerResourceRepository, + stageRepo: _playerStageRepository, sessionRepo: _playerSessionRepository, + weaponRepo: _playerWeaponRepository, skillRepo: _playerSkillRepository, + redisRepo: _playerRedisRepository, cache: _gameDataCacheService, + levelUpService: _levelUpService, progressRepo: _progressRepository, + txRunner: _transactionRunner, userProvider: _currentUserProvider); + + await sut.ExecuteAsync(); + + await _progressRepository.Received(1).UpdateAsync(Arg.Any()); + } + } + + public class 클리어_실패시_진행도가_변하지_않는다 + { + [Fact] + public async Task 기존_HighestClearedStage가_그대로_반환된다() + { + var playerRepository = Substitute.For(); + var resourceRepository = Substitute.For(); + var stageRepository = Substitute.For(); + var sessionRepository = Substitute.For(); + var weaponRepository = Substitute.For(); + var skillRepository = Substitute.For(); + var gameDataCacheService = Substitute.For(); + var progressRepository = Substitute.For(); + var currentUserProvider = Substitute.For(); + + currentUserProvider.GetAccountId().Returns(1L); + playerRepository.FindByAccountAsync(1L).Returns(PlayerEntity.Create(1L, JobType.Warrior)); + resourceRepository.FindByPlayerIdAsync(Arg.Any()).Returns(PlayerResource.Create(1L)); + stageRepository.FindByPlayerIdAsync(Arg.Any()).Returns(PlayerStage.Create(1L)); + sessionRepository.FindByPlayerIdAsync(Arg.Any()).Returns(PlayerSession.Create(1L)); + weaponRepository.FindAllByPlayerIdAsync(Arg.Any()).Returns([]); + skillRepository.FindAllByPlayerIdAsync(Arg.Any()).Returns([]); + + var existingProgress = PlayerDungeonProgress.Create(1L, DungeonType.Boss); + existingProgress.ClearStage(3); + progressRepository.FindByPlayerIdAndDungeonTypeAsync(1L, DungeonType.Boss) + .Returns(existingProgress); + + // 몬스터 HP가 매우 높아 클리어 불가 + var stageData = StageData.Create(3, monsterHp: 10_000_000, monsterAtk: 999, xpPerSecond: 5, goldPerSecond: 10); + gameDataCacheService.GetStageDataAsync(3).Returns(stageData); + gameDataCacheService.GetJobBaseStatAsync(JobType.Warrior) + .Returns(JobBaseStat.Create(JobType.Warrior, 1000, 100, 0, 1.5, 10, 10)); + gameDataCacheService.GetSkillDataByJobAsync(Arg.Any()).Returns([]); + + var sut = BuildSut( + playerRepo: playerRepository, resourceRepo: resourceRepository, + stageRepo: stageRepository, sessionRepo: sessionRepository, + weaponRepo: weaponRepository, skillRepo: skillRepository, + cache: gameDataCacheService, progressRepo: progressRepository, + userProvider: currentUserProvider); + + var result = await sut.ExecuteAsync(); + + result.HighestClearedStage.Should().Be(3); + await progressRepository.DidNotReceive().UpdateAsync(Arg.Any()); + } + } +``` + +- [ ] **Step 3: 실패 확인** + +Run: `/test BossDungeonServiceTests` +Expected: FAIL — 생성자 시그니처 불일치 및 `BossDungeonResponse` 필드 불일치로 컴파일 에러. + +- [ ] **Step 4: `BossDungeonService` 구현** + +`Fantasy-server/Fantasy.Server/Domain/Dungeon/Service/BossDungeonService.cs`를 아래로 교체: + +```csharp +using Fantasy.Server.Domain.Dungeon.Dto.Response; +using Fantasy.Server.Domain.Dungeon.Entity; +using Fantasy.Server.Domain.Dungeon.Enum; +using Fantasy.Server.Domain.Dungeon.Repository.Interface; +using Fantasy.Server.Domain.Dungeon.Service.Interface; +using Fantasy.Server.Domain.GameData.Entity; +using Fantasy.Server.Domain.GameData.Enum; +using Fantasy.Server.Domain.GameData.Service.Interface; +using Fantasy.Server.Domain.LevelUp.Service.Interface; +using Fantasy.Server.Domain.Player.Dto.Request; +using Fantasy.Server.Domain.Player.Repository.Interface; +using Fantasy.Server.Global.Infrastructure; +using Fantasy.Server.Global.Security.Provider; +using Gamism.SDK.Extensions.AspNetCore.Exceptions; + +namespace Fantasy.Server.Domain.Dungeon.Service; + +public class BossDungeonService : IBossDungeonService +{ + private const long BossMithrilReward = 1; + private const long BossXpMultiplier = 10; + + private readonly IPlayerRepository _playerRepository; + private readonly IPlayerResourceRepository _playerResourceRepository; + private readonly IPlayerStageRepository _playerStageRepository; + private readonly IPlayerSessionRepository _playerSessionRepository; + private readonly IPlayerWeaponRepository _playerWeaponRepository; + private readonly IPlayerSkillRepository _playerSkillRepository; + private readonly IPlayerRedisRepository _playerRedisRepository; + private readonly IGameDataCacheService _gameDataCacheService; + private readonly ILevelUpService _levelUpService; + private readonly IPlayerDungeonProgressRepository _playerDungeonProgressRepository; + private readonly IAppDbTransactionRunner _transactionRunner; + private readonly ICurrentUserProvider _currentUserProvider; + private readonly ICombatStatCalculator _calculator; + + public BossDungeonService( + IPlayerRepository playerRepository, + IPlayerResourceRepository playerResourceRepository, + IPlayerStageRepository playerStageRepository, + IPlayerSessionRepository playerSessionRepository, + IPlayerWeaponRepository playerWeaponRepository, + IPlayerSkillRepository playerSkillRepository, + IPlayerRedisRepository playerRedisRepository, + IGameDataCacheService gameDataCacheService, + ILevelUpService levelUpService, + IPlayerDungeonProgressRepository playerDungeonProgressRepository, + IAppDbTransactionRunner transactionRunner, + ICurrentUserProvider currentUserProvider, + ICombatStatCalculator calculator) + { + _playerRepository = playerRepository; + _playerResourceRepository = playerResourceRepository; + _playerStageRepository = playerStageRepository; + _playerSessionRepository = playerSessionRepository; + _playerWeaponRepository = playerWeaponRepository; + _playerSkillRepository = playerSkillRepository; + _playerRedisRepository = playerRedisRepository; + _gameDataCacheService = gameDataCacheService; + _levelUpService = levelUpService; + _playerDungeonProgressRepository = playerDungeonProgressRepository; + _transactionRunner = transactionRunner; + _currentUserProvider = currentUserProvider; + _calculator = calculator; + } + + public async Task ExecuteAsync() + { + var accountId = _currentUserProvider.GetAccountId(); + + var player = await _playerRepository.FindByAccountAsync(accountId) + ?? throw new NotFoundException("플레이어 데이터를 찾을 수 없습니다."); + + var jobType = player.JobType; + + var resource = await _playerResourceRepository.FindByPlayerIdAsync(player.Id) + ?? throw new NotFoundException("플레이어 재화 데이터를 찾을 수 없습니다."); + + var stage = await _playerStageRepository.FindByPlayerIdAsync(player.Id) + ?? throw new NotFoundException("플레이어 스테이지 데이터를 찾을 수 없습니다."); + + var session = await _playerSessionRepository.FindByPlayerIdAsync(player.Id) + ?? throw new NotFoundException("플레이어 세션 데이터를 찾을 수 없습니다."); + + var weapons = await _playerWeaponRepository.FindAllByPlayerIdAsync(player.Id); + var skills = await _playerSkillRepository.FindAllByPlayerIdAsync(player.Id); + + var jobStat = await _gameDataCacheService.GetJobBaseStatAsync(player.JobType) + ?? throw new NotFoundException("직업 기본 스탯 데이터를 찾을 수 없습니다."); + + WeaponData? weaponData = null; + long weaponEnhancement = 0; + if (session.LastWeaponId.HasValue) + { + weaponData = await _gameDataCacheService.GetWeaponDataAsync(session.LastWeaponId.Value); + var equippedWeapon = weapons.FirstOrDefault(w => w.WeaponId == session.LastWeaponId.Value); + weaponEnhancement = equippedWeapon?.EnhancementLevel ?? 0; + } + + var jobSkillData = await _gameDataCacheService.GetSkillDataByJobAsync(player.JobType); + var unlockedPassiveSkills = skills + .Where(s => s.IsUnlocked) + .Select(s => jobSkillData.FirstOrDefault(sd => sd.SkillId == s.SkillId)) + .Where(sd => sd is not null && !sd.IsActive) + .Select(sd => (Skill: sd!, IsPassive: true)); + + var combatStat = _calculator.Calculate(player.Level, jobStat, weaponData, weaponEnhancement, unlockedPassiveSkills); + + var progress = await _playerDungeonProgressRepository.FindByPlayerIdAndDungeonTypeAsync(player.Id, DungeonType.Boss); + var currentStage = progress?.HighestClearedStage ?? 1; + + var stageData = await _gameDataCacheService.GetStageDataAsync(currentStage) + ?? throw new NotFoundException("스테이지 데이터를 찾을 수 없습니다."); + + // 보스는 일반 몬스터의 5배 체력 + var bossHp = stageData.MonsterHp * 5; + var dps = _calculator.CalculateDps(combatStat); + var cleared = dps * 30 >= bossHp; + + if (!cleared) + return new BossDungeonResponse(false, 0, null, 0, [], currentStage); + + var isNewProgress = progress is null; + progress ??= PlayerDungeonProgress.Create(player.Id, DungeonType.Boss); + progress.ClearStage(currentStage + 1); + + var earnedXp = stageData.XpPerSecond * BossXpMultiplier; + var levelUps = await _levelUpService.ExecuteAsync(player, resource, earnedXp); + resource.UpdateChangeData(null, resource.Mithril + BossMithrilReward, null); + + DroppedWeaponInfo? droppedWeapon = null; + var aWeapons = await _gameDataCacheService.GetWeaponDataByGradeAsync(WeaponGrade.A); + var aJobWeapons = aWeapons.Where(w => w.JobType == jobType).ToList(); + List weaponChanges = []; + + if (aJobWeapons.Count > 0) + { + var dropped = aJobWeapons[Random.Shared.Next(aJobWeapons.Count)]; + droppedWeapon = new DroppedWeaponInfo(dropped.WeaponId, dropped.Name, dropped.Grade); + + var existing = weapons.FirstOrDefault(w => w.WeaponId == dropped.WeaponId); + weaponChanges.Add(new WeaponChangeItem(dropped.WeaponId, (existing?.Count ?? 0) + 1, + existing?.EnhancementLevel ?? 0, existing?.AwakeningCount ?? 0)); + } + + await _transactionRunner.ExecuteAsync(async () => + { + await _playerRepository.UpdateAsync(player); + await _playerResourceRepository.UpdateAsync(resource); + if (weaponChanges.Count > 0) + await _playerWeaponRepository.UpsertRangeAsync(player.Id, weaponChanges); + + if (isNewProgress) + await _playerDungeonProgressRepository.SaveAsync(progress); + else + await _playerDungeonProgressRepository.UpdateAsync(progress); + }); + + await _playerRedisRepository.DeleteAsync(accountId); + + return new BossDungeonResponse(true, BossMithrilReward, droppedWeapon, earnedXp, levelUps, progress.HighestClearedStage); + } +} +``` + +- [ ] **Step 5: 통과 확인** + +Run: `/test BossDungeonServiceTests` +Expected: PASS (기존 7개 + 신규 4개 = 11개) + +- [ ] **Step 6: 전체 빌드 확인** + +Run: `/test` +Expected: 전체 통과. + +- [ ] **Step 7: 커밋** + +```bash +git add Fantasy-server/Fantasy.Server/Domain/Dungeon/Dto/Response/BossDungeonResponse.cs Fantasy-server/Fantasy.Server/Domain/Dungeon/Service/BossDungeonService.cs Fantasy-server/Fantasy.Test/Dungeon/Service/BossDungeonServiceTests.cs +git commit -m "feat: 보스 던전에 독립 진행도(HighestClearedStage) 적용" +``` + +--- + +### Task 5: `GoldDungeonClaimService` — `HighScore` 갱신 + +**Files:** +- Modify: `Fantasy-server/Fantasy.Server/Domain/Dungeon/Dto/Response/GoldDungeonClaimResponse.cs` +- Modify: `Fantasy-server/Fantasy.Server/Domain/Dungeon/Service/GoldDungeonClaimService.cs` +- Modify: `Fantasy-server/Fantasy.Test/Dungeon/Service/GoldDungeonClaimServiceTest.cs` + +**Interfaces:** +- Consumes: `IPlayerDungeonProgressRepository`(Task 2). +- Produces: `GoldDungeonClaimResponse(Guid RunId, long EarnedGold, int EarnedMithril, long HighScore, ChangesDto Changes, PlayerDataResponse Player)`. + +**설계:** 정상 claim 시 `earnedGold`로 `PlayerDungeonProgress(Gold).UpdateHighScore`를 호출해 기존 트랜잭션 블록 안에서 저장한다(`HighScore`는 이미 더 크지 않으면 내부적으로 값이 바뀌지 않으므로 항상 호출해도 안전). 이미 claim된 run을 재호출하는 멱등 분기에서는 저장하지 않고 현재 `PlayerDungeonProgress(Gold)`를 읽기만 해서 응답에 포함한다. + +- [ ] **Step 1: `GoldDungeonClaimResponse`에 필드 추가** + +```csharp +// Fantasy-server/Fantasy.Server/Domain/Dungeon/Dto/Response/GoldDungeonClaimResponse.cs +using Fantasy.Server.Domain.Player.Dto.Response; + +namespace Fantasy.Server.Domain.Dungeon.Dto.Response; + +public record GoldDungeonClaimResponse( + Guid RunId, + long EarnedGold, + int EarnedMithril, + long HighScore, + ChangesDto Changes, + PlayerDataResponse Player +); +``` + +- [ ] **Step 2: 실패하는 서비스 테스트 작성** + +`Fantasy-server/Fantasy.Test/Dungeon/Service/GoldDungeonClaimServiceTest.cs`의 `BuildSut` 헬퍼를 아래로 교체(새 파라미터 1개 추가): + +```csharp + private static GoldDungeonClaimService BuildSut( + IPlayerRepository? playerRepo = null, + IPlayerResourceRepository? resourceRepo = null, + IPlayerStageRepository? stageRepo = null, + IPlayerSessionRepository? sessionRepo = null, + IPlayerWeaponRepository? weaponRepo = null, + IPlayerSkillRepository? skillRepo = null, + IPlayerRedisRepository? redisRepo = null, + IGoldDungeonRunRepository? runRepo = null, + IPlayerDungeonProgressRepository? progressRepo = null, + IRandomProvider? randomProvider = null, + IAppDbTransactionRunner? txRunner = null, + ICurrentUserProvider? userProvider = null, + TimeProvider? timeProvider = null) => + new( + playerRepo ?? Substitute.For(), + resourceRepo ?? Substitute.For(), + stageRepo ?? Substitute.For(), + sessionRepo ?? Substitute.For(), + weaponRepo ?? Substitute.For(), + skillRepo ?? Substitute.For(), + redisRepo ?? Substitute.For(), + runRepo ?? Substitute.For(), + progressRepo ?? Substitute.For(), + randomProvider ?? Substitute.For(), + txRunner ?? Substitute.For(), + userProvider ?? Substitute.For(), + timeProvider ?? FixedTimeProvider(DateTimeOffset.UtcNow)); +``` + +상단 using에 추가: + +```csharp +using Fantasy.Server.Domain.Dungeon.Enum; +``` + +기존 `이미_클레임된_런일_때` 클래스는 **수정하지 않는다** — `BuildSut`의 새 `progressRepo` 파라미터가 옵션이라 전달하지 않으면 자동으로 빈 `Substitute.For()`가 주입되고, `FindByPlayerIdAndDungeonTypeAsync`가 기본으로 `null`을 반환해 `existingProgress?.HighScore ?? 0`이 `0`이 될 뿐이다. 이 클래스는 `EarnedGold`/`EarnedMithril`만 검증하므로 그대로 컴파일·통과한다. 신규 테스트 클래스만 추가한다: + +```csharp + public class 정상_클레임시_HighScore가_갱신될_때 + { + private readonly IPlayerRepository _playerRepository = Substitute.For(); + private readonly IPlayerResourceRepository _resourceRepository = Substitute.For(); + private readonly IPlayerStageRepository _stageRepository = Substitute.For(); + private readonly IPlayerSessionRepository _sessionRepository = Substitute.For(); + private readonly IPlayerWeaponRepository _weaponRepository = Substitute.For(); + private readonly IPlayerSkillRepository _skillRepository = Substitute.For(); + private readonly IGoldDungeonRunRepository _runRepository = Substitute.For(); + private readonly IPlayerDungeonProgressRepository _progressRepository = Substitute.For(); + private readonly IRandomProvider _randomProvider = Substitute.For(); + private readonly IAppDbTransactionRunner _txRunner = Substitute.For(); + private readonly ICurrentUserProvider _currentUserProvider = Substitute.For(); + private readonly GoldDungeonRun _run; + private readonly TimeProvider _timeProvider; + + public 정상_클레임시_HighScore가_갱신될_때() + { + _currentUserProvider.GetAccountId().Returns(1L); + _run = GoldDungeonRun.Create(accountId: 1L, durationSeconds: 30, maxClicksPerSecond: 15); + _runRepository.FindByIdAsync(_run.Id).Returns(_run); + _randomProvider.Next(0, 100).Returns(50); + _timeProvider = FixedTimeProvider(_run.StartedAt.AddSeconds(30)); + SetupPlayerData(_playerRepository, _resourceRepository, _stageRepository, + _sessionRepository, _weaponRepository, _skillRepository); + + var existingProgress = PlayerDungeonProgress.Create(1L, DungeonType.Gold); + existingProgress.UpdateHighScore(500L); + _progressRepository.FindByPlayerIdAndDungeonTypeAsync(1L, DungeonType.Gold) + .Returns(existingProgress); + + _txRunner.ExecuteAsync(Arg.Any>()) + .Returns(callInfo => callInfo.Arg>()()); + } + + [Fact] + public async Task 기존_최고점수보다_높으면_HighScore가_갱신된다() + { + // 클릭 100 * 10골드 = 1000 > 기존 500 + var sut = BuildSut(playerRepo: _playerRepository, resourceRepo: _resourceRepository, + stageRepo: _stageRepository, sessionRepo: _sessionRepository, + weaponRepo: _weaponRepository, skillRepo: _skillRepository, + runRepo: _runRepository, progressRepo: _progressRepository, + randomProvider: _randomProvider, txRunner: _txRunner, userProvider: _currentUserProvider, + timeProvider: _timeProvider); + + var result = await sut.ExecuteAsync(_run.Id, new GoldDungeonClaimRequest(100)); + + result.HighScore.Should().Be(1000L); + } + + [Fact] + public async Task 진행도_UpdateAsync가_호출된다() + { + var sut = BuildSut(playerRepo: _playerRepository, resourceRepo: _resourceRepository, + stageRepo: _stageRepository, sessionRepo: _sessionRepository, + weaponRepo: _weaponRepository, skillRepo: _skillRepository, + runRepo: _runRepository, progressRepo: _progressRepository, + randomProvider: _randomProvider, txRunner: _txRunner, userProvider: _currentUserProvider, + timeProvider: _timeProvider); + + await sut.ExecuteAsync(_run.Id, new GoldDungeonClaimRequest(100)); + + await _progressRepository.Received(1).UpdateAsync(Arg.Any()); + } + } + + public class 정상_클레임시_기존_최고점수보다_낮을_때 + { + [Fact] + public async Task HighScore가_기존값으로_유지된다() + { + var playerRepository = Substitute.For(); + var resourceRepository = Substitute.For(); + var stageRepository = Substitute.For(); + var sessionRepository = Substitute.For(); + var weaponRepository = Substitute.For(); + var skillRepository = Substitute.For(); + var runRepository = Substitute.For(); + var progressRepository = Substitute.For(); + var randomProvider = Substitute.For(); + var txRunner = Substitute.For(); + var currentUserProvider = Substitute.For(); + + currentUserProvider.GetAccountId().Returns(1L); + var run = GoldDungeonRun.Create(accountId: 1L, durationSeconds: 30, maxClicksPerSecond: 15); + runRepository.FindByIdAsync(run.Id).Returns(run); + randomProvider.Next(0, 100).Returns(50); + SetupPlayerData(playerRepository, resourceRepository, stageRepository, + sessionRepository, weaponRepository, skillRepository); + + // 기존 최고점수 5000 > 이번 클릭 10 * 10골드 = 100 + var existingProgress = PlayerDungeonProgress.Create(1L, DungeonType.Gold); + existingProgress.UpdateHighScore(5000L); + progressRepository.FindByPlayerIdAndDungeonTypeAsync(1L, DungeonType.Gold) + .Returns(existingProgress); + + txRunner.ExecuteAsync(Arg.Any>()) + .Returns(callInfo => callInfo.Arg>()()); + + var sut = BuildSut(playerRepo: playerRepository, resourceRepo: resourceRepository, + stageRepo: stageRepository, sessionRepo: sessionRepository, + weaponRepo: weaponRepository, skillRepo: skillRepository, + runRepo: runRepository, progressRepo: progressRepository, + randomProvider: randomProvider, txRunner: txRunner, userProvider: currentUserProvider, + timeProvider: FixedTimeProvider(run.StartedAt.AddSeconds(30))); + + var result = await sut.ExecuteAsync(run.Id, new GoldDungeonClaimRequest(10)); + + result.HighScore.Should().Be(5000L); + } + } + + public class 이미_클레임된_런_재조회시_HighScore가_포함될_때 + { + [Fact] + public async Task 저장된_HighScore가_반환된다() + { + var playerRepository = Substitute.For(); + var resourceRepository = Substitute.For(); + var stageRepository = Substitute.For(); + var sessionRepository = Substitute.For(); + var weaponRepository = Substitute.For(); + var skillRepository = Substitute.For(); + var runRepository = Substitute.For(); + var progressRepository = Substitute.For(); + var currentUserProvider = Substitute.For(); + + currentUserProvider.GetAccountId().Returns(1L); + var claimedRun = GoldDungeonRun.Create(accountId: 1L, durationSeconds: 30, maxClicksPerSecond: 15); + claimedRun.Claim(100, 1000L, 0); + runRepository.FindByIdAsync(claimedRun.Id).Returns(claimedRun); + SetupPlayerData(playerRepository, resourceRepository, stageRepository, + sessionRepository, weaponRepository, skillRepository); + + var existingProgress = PlayerDungeonProgress.Create(1L, DungeonType.Gold); + existingProgress.UpdateHighScore(2000L); + progressRepository.FindByPlayerIdAndDungeonTypeAsync(1L, DungeonType.Gold) + .Returns(existingProgress); + + var sut = BuildSut(playerRepo: playerRepository, resourceRepo: resourceRepository, + stageRepo: stageRepository, sessionRepo: sessionRepository, + weaponRepo: weaponRepository, skillRepo: skillRepository, + runRepo: runRepository, progressRepo: progressRepository, + userProvider: currentUserProvider); + + var result = await sut.ExecuteAsync(claimedRun.Id, new GoldDungeonClaimRequest(50)); + + result.HighScore.Should().Be(2000L); + } + } +``` + +- [ ] **Step 3: 실패 확인** + +Run: `/test GoldDungeonClaimServiceTest` +Expected: FAIL — 생성자 시그니처 불일치 및 `GoldDungeonClaimResponse` 필드 불일치로 컴파일 에러. + +- [ ] **Step 4: `GoldDungeonClaimService` 구현** + +`Fantasy-server/Fantasy.Server/Domain/Dungeon/Service/GoldDungeonClaimService.cs`를 아래로 교체: + +```csharp +using Fantasy.Server.Domain.Dungeon.Dto.Request; +using Fantasy.Server.Domain.Dungeon.Dto.Response; +using Fantasy.Server.Domain.Dungeon.Entity; +using Fantasy.Server.Domain.Dungeon.Enum; +using Fantasy.Server.Domain.Dungeon.Repository.Interface; +using Fantasy.Server.Domain.Dungeon.Service.Interface; +using Fantasy.Server.Domain.Player.Dto.Response; +using Fantasy.Server.Domain.Player.Repository.Interface; +using Fantasy.Server.Global.Infrastructure; +using Fantasy.Server.Global.Security.Provider; +using Gamism.SDK.Extensions.AspNetCore.Exceptions; + +namespace Fantasy.Server.Domain.Dungeon.Service; + +public class GoldDungeonClaimService : IGoldDungeonClaimService +{ + private const long GoldPerClick = 10L; + private const int MithrilDropRatePercent = 2; + + private readonly IPlayerRepository _playerRepository; + private readonly IPlayerResourceRepository _playerResourceRepository; + private readonly IPlayerStageRepository _playerStageRepository; + private readonly IPlayerSessionRepository _playerSessionRepository; + private readonly IPlayerWeaponRepository _playerWeaponRepository; + private readonly IPlayerSkillRepository _playerSkillRepository; + private readonly IPlayerRedisRepository _playerRedisRepository; + private readonly IGoldDungeonRunRepository _goldDungeonRunRepository; + private readonly IPlayerDungeonProgressRepository _playerDungeonProgressRepository; + private readonly IRandomProvider _randomProvider; + private readonly IAppDbTransactionRunner _transactionRunner; + private readonly ICurrentUserProvider _currentUserProvider; + private readonly TimeProvider _timeProvider; + + public GoldDungeonClaimService( + IPlayerRepository playerRepository, + IPlayerResourceRepository playerResourceRepository, + IPlayerStageRepository playerStageRepository, + IPlayerSessionRepository playerSessionRepository, + IPlayerWeaponRepository playerWeaponRepository, + IPlayerSkillRepository playerSkillRepository, + IPlayerRedisRepository playerRedisRepository, + IGoldDungeonRunRepository goldDungeonRunRepository, + IPlayerDungeonProgressRepository playerDungeonProgressRepository, + IRandomProvider randomProvider, + IAppDbTransactionRunner transactionRunner, + ICurrentUserProvider currentUserProvider, + TimeProvider timeProvider) + { + _playerRepository = playerRepository; + _playerResourceRepository = playerResourceRepository; + _playerStageRepository = playerStageRepository; + _playerSessionRepository = playerSessionRepository; + _playerWeaponRepository = playerWeaponRepository; + _playerSkillRepository = playerSkillRepository; + _playerRedisRepository = playerRedisRepository; + _goldDungeonRunRepository = goldDungeonRunRepository; + _playerDungeonProgressRepository = playerDungeonProgressRepository; + _randomProvider = randomProvider; + _transactionRunner = transactionRunner; + _currentUserProvider = currentUserProvider; + _timeProvider = timeProvider; + } + + public async Task ExecuteAsync(Guid runId, GoldDungeonClaimRequest request) + { + var accountId = _currentUserProvider.GetAccountId(); + + var run = await _goldDungeonRunRepository.FindByIdAsync(runId) + ?? throw new NotFoundException("골드 던전 런을 찾을 수 없습니다."); + + if (run.AccountId != accountId) + throw new ForbiddenException("접근 권한이 없습니다."); + + if (run.IsClaimed) + { + var player = await _playerRepository.FindByAccountAsync(accountId) + ?? throw new NotFoundException("플레이어 데이터를 찾을 수 없습니다."); + + var resource = await _playerResourceRepository.FindByPlayerIdAsync(player.Id) + ?? throw new NotFoundException("플레이어 재화 데이터를 찾을 수 없습니다."); + + var stage = await _playerStageRepository.FindByPlayerIdAsync(player.Id) + ?? throw new NotFoundException("플레이어 스테이지 데이터를 찾을 수 없습니다."); + + var session = await _playerSessionRepository.FindByPlayerIdAsync(player.Id) + ?? throw new NotFoundException("플레이어 세션 데이터를 찾을 수 없습니다."); + + var weapons = await _playerWeaponRepository.FindAllByPlayerIdAsync(player.Id); + var skills = await _playerSkillRepository.FindAllByPlayerIdAsync(player.Id); + + var playerResponse = PlayerDataResponseBuilder.Build(player, resource, stage, session, weapons, skills); + + var existingProgress = await _playerDungeonProgressRepository.FindByPlayerIdAndDungeonTypeAsync(player.Id, DungeonType.Gold); + + var idempotentChanges = new ChangesDto( + Gold: run.EarnedGold!.Value, + Exp: 0, + Sp: 0, + Mithril: run.EarnedMithril!.Value, + EnhancementScroll: 0, + DungeonTickets: 0, + LevelUps: [], + UnlockedSkillIds: [], + AcquiredWeaponIds: [], + MaxStage: 0 + ); + + return new GoldDungeonClaimResponse(run.Id, run.EarnedGold!.Value, run.EarnedMithril!.Value, + existingProgress?.HighScore ?? 0, idempotentChanges, playerResponse); + } + + var now = _timeProvider.GetUtcNow().UtcDateTime; + + if (now > run.ExpiresAt) + throw new BadRequestException("골드 던전 제한 시간이 초과되었습니다."); + + if (request.Clicks > run.MaxClicks) + throw new BadRequestException("비정상적인 클릭 횟수입니다."); + + var elapsedSeconds = Math.Clamp((now - run.StartedAt).TotalSeconds, 0, run.DurationSeconds); + var maxAllowedClicks = (int)Math.Ceiling(elapsedSeconds * run.MaxClicks / run.DurationSeconds); + if (request.Clicks > maxAllowedClicks) + throw new BadRequestException("경과 시간 대비 비정상적인 클릭 횟수입니다."); + + var earnedGold = request.Clicks * GoldPerClick; + var mithrilDropped = _randomProvider.Next(0, 100) < MithrilDropRatePercent; + + var claimPlayer = await _playerRepository.FindByAccountAsync(accountId) + ?? throw new NotFoundException("플레이어 데이터를 찾을 수 없습니다."); + + var claimResource = await _playerResourceRepository.FindByPlayerIdAsync(claimPlayer.Id) + ?? throw new NotFoundException("플레이어 재화 데이터를 찾을 수 없습니다."); + + var claimStage = await _playerStageRepository.FindByPlayerIdAsync(claimPlayer.Id) + ?? throw new NotFoundException("플레이어 스테이지 데이터를 찾을 수 없습니다."); + + var claimSession = await _playerSessionRepository.FindByPlayerIdAsync(claimPlayer.Id) + ?? throw new NotFoundException("플레이어 세션 데이터를 찾을 수 없습니다."); + + var claimWeapons = await _playerWeaponRepository.FindAllByPlayerIdAsync(claimPlayer.Id); + var claimSkills = await _playerSkillRepository.FindAllByPlayerIdAsync(claimPlayer.Id); + + claimResource.UpdateGold(claimResource.Gold + earnedGold); + if (mithrilDropped) + claimResource.UpdateChangeData(null, claimResource.Mithril + 1, null); + + run.Claim(request.Clicks, earnedGold, mithrilDropped ? 1 : 0); + + var progress = await _playerDungeonProgressRepository.FindByPlayerIdAndDungeonTypeAsync(claimPlayer.Id, DungeonType.Gold); + var isNewProgress = progress is null; + progress ??= PlayerDungeonProgress.Create(claimPlayer.Id, DungeonType.Gold); + progress.UpdateHighScore(earnedGold); + + await _transactionRunner.ExecuteAsync(async () => + { + await _playerResourceRepository.UpdateAsync(claimResource); + await _goldDungeonRunRepository.UpdateAsync(run); + + if (isNewProgress) + await _playerDungeonProgressRepository.SaveAsync(progress); + else + await _playerDungeonProgressRepository.UpdateAsync(progress); + }); + + var claimPlayerResponse = PlayerDataResponseBuilder.Build(claimPlayer, claimResource, claimStage, claimSession, claimWeapons, claimSkills); + await _playerRedisRepository.SetPlayerDataAsync(accountId, claimPlayerResponse); + + var changes = new ChangesDto( + Gold: earnedGold, + Exp: 0, + Sp: 0, + Mithril: mithrilDropped ? 1 : 0, + EnhancementScroll: 0, + DungeonTickets: 0, + LevelUps: [], + UnlockedSkillIds: [], + AcquiredWeaponIds: [], + MaxStage: 0 + ); + + return new GoldDungeonClaimResponse(run.Id, earnedGold, mithrilDropped ? 1 : 0, progress.HighScore, changes, claimPlayerResponse); + } +} +``` + +- [ ] **Step 5: 통과 확인** + +Run: `/test GoldDungeonClaimServiceTest` +Expected: PASS (기존 12개 + 신규 4개 = 16개) + +- [ ] **Step 6: 전체 빌드 확인** + +Run: `/test` +Expected: 전체 통과. + +- [ ] **Step 7: 커밋** + +```bash +git add Fantasy-server/Fantasy.Server/Domain/Dungeon/Dto/Response/GoldDungeonClaimResponse.cs Fantasy-server/Fantasy.Server/Domain/Dungeon/Service/GoldDungeonClaimService.cs Fantasy-server/Fantasy.Test/Dungeon/Service/GoldDungeonClaimServiceTest.cs +git commit -m "feat: 골드 던전 claim에 HighScore 갱신 적용" +``` + +--- + +## 완료 후 문서 업데이트 (커밋 범위 밖, 별도 확인) + +- `docs/client-integration-guide.md`의 6.2/6.3절에 `WeaponDungeonResponse`/`BossDungeonResponse`/`GoldDungeonClaimResponse`의 신규 필드(`HighestClearedStage`, `HighScore`) 반영. +- `docs/superpowers/specs/2026-07-02-server-authority-feature-expansion-design.md`의 Phase 2 상태를 "구현 완료"로 갱신. +- 무기/보스 던전이 이제 기본 던전과 별개로 진행된다는 점을 클라이언트 팀에 공유(밸런싱 영향, 2026-07-02 확정 사항 참고). diff --git a/docs/superpowers/specs/2026-07-02-server-authority-feature-expansion-design.md b/docs/superpowers/specs/2026-07-02-server-authority-feature-expansion-design.md index 59143a5..380f055 100644 --- a/docs/superpowers/specs/2026-07-02-server-authority-feature-expansion-design.md +++ b/docs/superpowers/specs/2026-07-02-server-authority-feature-expansion-design.md @@ -157,20 +157,22 @@ public class PlayerDungeonProgress LastClearedAt = null }; - public void ClearStage(long stage, DateTime clearedAt) + public void ClearStage(long stage) { if (stage > HighestClearedStage) HighestClearedStage = stage; - LastClearedAt = clearedAt; + LastClearedAt = DateTime.UtcNow; } - public void UpdateHighScore(long score, DateTime achievedAt) + public void UpdateHighScore(long score) { if (score > HighScore) HighScore = score; - LastClearedAt = achievedAt; + LastClearedAt = DateTime.UtcNow; } } ``` +- `ClearStage`/`UpdateHighScore`는 `Player.UpdateLevel`/`UpdateExp`와 동일하게 외부 시간 파라미터 없이 엔티티 내부에서 직접 `DateTime.UtcNow`를 찍는다. `TimeProvider` 주입은 경과 시간 계산(골드 던전 만료 판정 등)에만 예약되어 있고, 단순 "이 이벤트가 언제 발생했는지 기록"하는 타임스탬프는 엔티티가 직접 처리하는 것이 기존 컨벤션이다. + - 테이블: `dungeon.player_dungeon_progress` — `(player_id, dungeon_type)` 유니크 인덱스. - `xmin` 동시성 토큰 적용(다른 Player 하위 테이블과 동일). @@ -191,12 +193,13 @@ public class PlayerDungeonProgress 신규 GET 엔드포인트를 추가하지 않고, 기존 응답 DTO에 필드를 확장한다(API 표면 최소화). - `WeaponDungeonResponse`, `BossDungeonResponse`에 `HighestClearedStage` 추가. -- `GoldDungeonClaimResponse`, `DungeonTicketResponse`에 `HighScore` 추가. +- `GoldDungeonClaimResponse`에 `HighScore` 추가. +- `DungeonTicketResponse`는 건드리지 않는다 — 이 엔드포인트는 계정 기준으로만 동작하고 Player 조회가 없으므로, 새로 Player 의존성을 넣지 않기로 결정(2026-07-02 확정). -### 4.5 확인 필요 사항 (Phase 착수 전 재확인) +### 4.5 확인 필요 사항 — 확정됨 (2026-07-02) -- 무기/보스 던전이 "독자 진행도"로 바뀌면 기존에 기본 던전 스테이지에 종속되어 있던 난이도 곡선이 사실상 초기화(둘 다 1부터 시작)되는 셈이라 밸런싱 재검토가 필요하다. -- 기존 플레이어(마이그레이션 시점 기존 유저)의 초기 `HighestClearedStage`를 1로 리셋할지, 기존 `PlayerStage.MaxStage`로 백필할지 결정 필요. +- 무기/보스 던전이 "독자 진행도"로 바뀌면 기존에 기본 던전 스테이지에 종속되어 있던 난이도 곡선이 사실상 초기화(둘 다 1부터 시작)되는 셈이라 밸런싱에 영향을 준다는 점을 사용자에게 명시적으로 확인했다. +- 결정: **백필하지 않는다.** 기존 유저 포함 전원 지연 생성(lazy-create)으로 1부터 시작한다. 기존 `AccountDungeonTicket`/`GoldDungeonRun`과 동일한 지연 생성 패턴을 따르며, 별도 데이터 마이그레이션/백필 스크립트를 두지 않는다. 이는 앞서 확정한 "완전 독립, 각자 1부터 시작" 원칙과 일치한다. ## 5. Phase 3 — 무기 강화/합성/각성 From 12761cb85db86ccfe2678a6d3a39b82c6ad0a815 Mon Sep 17 00:00:00 2001 From: Sean-mn Date: Thu, 2 Jul 2026 13:36:23 +0900 Subject: [PATCH 7/9] =?UTF-8?q?chore:=20=EB=8D=98=EC=A0=84=20=ED=83=80?= =?UTF-8?q?=EC=9E=85=EB=B3=84=20=EC=A7=84=ED=96=89=EB=8F=84=20=EB=AC=B8?= =?UTF-8?q?=EC=84=9C=20=EA=B0=B1=EC=8B=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/client-integration-guide.md | 3 +++ .../2026-07-02-server-authority-feature-expansion-design.md | 4 ++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/docs/client-integration-guide.md b/docs/client-integration-guide.md index 28fb4ca..2cd6335 100644 --- a/docs/client-integration-guide.md +++ b/docs/client-integration-guide.md @@ -147,6 +147,8 @@ - 무기 던전 드랍 확률(클리어 시): B등급 20%, C등급 70%, 강화 스크롤 30%. - 보스 클리어 시: 미스릴 +1, 경험치 = `스테이지 초당경험치 × 10`, **A등급 무기 확정 드랍**, 레벨업 가능. 미클리어면 보상 없음. +- **독립 진행도**: 무기/보스 던전은 각자 자기 진행도(`HighestClearedStage`)를 갖습니다. 기본(방치) 던전의 `maxStage`와 **무관하게 스테이지 1부터 독립적으로** 진행하며, 클리어 시 +1 올라갑니다(신규 유저·기존 유저 모두 1부터 시작). +- 응답에 `cleared`, 드랍 정보와 함께 `highestClearedStage`(현재 진행도)가 포함됩니다. 미클리어 시에도 현재 진행도가 그대로 반환됩니다. ### 6.3 골드 던전 (클릭형, 티켓 소모) @@ -172,6 +174,7 @@ - `clicks > maxClicks` 또는 **경과 시간 대비 과다 클릭** → 400 (어뷰징 방지) 4. 보상: 클릭당 **10골드**, 2% 확률로 미스릴 1. 5. 이미 정산된 런을 다시 호출하면 **동일 보상으로 멱등 응답**(중복 지급 없음). +6. claim 응답에 `highScore`(골드 던전 역대 최고 획득 골드)가 포함됩니다. 이번 획득 골드가 기존 최고 기록을 넘으면 갱신되고, 넘지 못하면 기존 값이 유지됩니다. 멱등 재호출 시에도 저장된 최고 기록을 반환합니다. ## 7. 공통 데이터 구조 diff --git a/docs/superpowers/specs/2026-07-02-server-authority-feature-expansion-design.md b/docs/superpowers/specs/2026-07-02-server-authority-feature-expansion-design.md index 380f055..f336bc5 100644 --- a/docs/superpowers/specs/2026-07-02-server-authority-feature-expansion-design.md +++ b/docs/superpowers/specs/2026-07-02-server-authority-feature-expansion-design.md @@ -1,7 +1,7 @@ # 서버 권위 기능 확장 — 설계 문서 - 날짜: 2026-07-02 -- 상태: 승인됨 (구현 전, 단계별 PR) +- 상태: 단계별 구현 중 — Phase 1(Tutorial) 완료, Phase 2(던전 타입별 진행도) 구현 완료, Phase 3~4 미착수 - 범위: Tutorial 신규 도메인, 던전 타입별 진행도 분리, 무기 강화/합성/각성, RewardTransaction 감사 로그 - 비범위: 무기/스킬 ID 전면 문자열 전환, 스킬·무기 장착 API 재설계, 다중 캐릭터/직업 전환, 전 던전 공통 Run 모델 강제 @@ -124,7 +124,7 @@ public record CompletedTutorialsResponse(List CompletedTutorialIds); - `GetCompletedTutorialsServiceTest`: 완료 기록 있음/없음, 플레이어 없음(404). - `TutorialControllerRouteTests`: 라우트 존재 확인(기존 `PlayerControllerRouteTests` 패턴 재사용). -## 4. Phase 2 — 던전 타입별 진행도 +## 4. Phase 2 — 던전 타입별 진행도 ✅ 구현 완료 (2026-07-02) ### 4.1 `DungeonType` From 0c09be43258893d7ac634db3991e04a0f67bf644 Mon Sep 17 00:00:00 2001 From: Sean-mn Date: Thu, 2 Jul 2026 13:57:46 +0900 Subject: [PATCH 8/9] =?UTF-8?q?fix:=20PlayerDungeonProgress=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20=EC=8B=9C=20AsNoTracking=20=EC=A0=9C=EA=B1=B0?= =?UTF-8?q?=EB=A1=9C=20xmin=20=EB=8F=99=EC=8B=9C=EC=84=B1=20=ED=86=A0?= =?UTF-8?q?=ED=81=B0=20=EC=9C=A0=EC=8B=A4=20=EB=B0=A9=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Domain/Dungeon/Repository/PlayerDungeonProgressRepository.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/Fantasy-server/Fantasy.Server/Domain/Dungeon/Repository/PlayerDungeonProgressRepository.cs b/Fantasy-server/Fantasy.Server/Domain/Dungeon/Repository/PlayerDungeonProgressRepository.cs index 890ae4a..db947fa 100644 --- a/Fantasy-server/Fantasy.Server/Domain/Dungeon/Repository/PlayerDungeonProgressRepository.cs +++ b/Fantasy-server/Fantasy.Server/Domain/Dungeon/Repository/PlayerDungeonProgressRepository.cs @@ -14,7 +14,6 @@ public class PlayerDungeonProgressRepository : IPlayerDungeonProgressRepository public async Task FindByPlayerIdAndDungeonTypeAsync(long playerId, DungeonType dungeonType) => await _db.PlayerDungeonProgresses - .AsNoTracking() .FirstOrDefaultAsync(p => p.PlayerId == playerId && p.DungeonType == dungeonType); public async Task SaveAsync(PlayerDungeonProgress progress) From 70781eb71f923904c0770af72d758f7af656c6e8 Mon Sep 17 00:00:00 2001 From: Sean-mn Date: Thu, 2 Jul 2026 14:02:22 +0900 Subject: [PATCH 9/9] =?UTF-8?q?fix:=20xmin=20=EB=8F=99=EC=8B=9C=EC=84=B1?= =?UTF-8?q?=20=ED=86=A0=ED=81=B0=20=EC=97=94=ED=8B=B0=ED=8B=B0=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20=EC=8B=9C=20AsNoTracking=20=EC=A0=9C=EA=B1=B0?= =?UTF-8?q?=EB=A1=9C=20UpdateAsync=20=EC=8B=A4=ED=8C=A8=20=EB=B0=A9?= =?UTF-8?q?=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Domain/Dungeon/Repository/AccountDungeonTicketRepository.cs | 1 - .../Domain/Dungeon/Repository/GoldDungeonRunRepository.cs | 1 - .../Domain/Player/Repository/PlayerResourceRepository.cs | 1 - .../Domain/Player/Repository/PlayerSessionRepository.cs | 1 - .../Domain/Player/Repository/PlayerStageRepository.cs | 1 - 5 files changed, 5 deletions(-) diff --git a/Fantasy-server/Fantasy.Server/Domain/Dungeon/Repository/AccountDungeonTicketRepository.cs b/Fantasy-server/Fantasy.Server/Domain/Dungeon/Repository/AccountDungeonTicketRepository.cs index 32a0b9e..9dcee17 100644 --- a/Fantasy-server/Fantasy.Server/Domain/Dungeon/Repository/AccountDungeonTicketRepository.cs +++ b/Fantasy-server/Fantasy.Server/Domain/Dungeon/Repository/AccountDungeonTicketRepository.cs @@ -16,7 +16,6 @@ public AccountDungeonTicketRepository(AppDbContext dbContext) public async Task FindByAccountIdAsync(long accountId) => await _dbContext.AccountDungeonTickets - .AsNoTracking() .FirstOrDefaultAsync(t => t.AccountId == accountId); public async Task SaveAsync(AccountDungeonTicket ticket) diff --git a/Fantasy-server/Fantasy.Server/Domain/Dungeon/Repository/GoldDungeonRunRepository.cs b/Fantasy-server/Fantasy.Server/Domain/Dungeon/Repository/GoldDungeonRunRepository.cs index f66756f..9044cec 100644 --- a/Fantasy-server/Fantasy.Server/Domain/Dungeon/Repository/GoldDungeonRunRepository.cs +++ b/Fantasy-server/Fantasy.Server/Domain/Dungeon/Repository/GoldDungeonRunRepository.cs @@ -16,7 +16,6 @@ public GoldDungeonRunRepository(AppDbContext dbContext) public async Task FindByIdAsync(Guid runId) => await _dbContext.GoldDungeonRuns - .AsNoTracking() .FirstOrDefaultAsync(r => r.Id == runId); public async Task SaveAsync(GoldDungeonRun run) diff --git a/Fantasy-server/Fantasy.Server/Domain/Player/Repository/PlayerResourceRepository.cs b/Fantasy-server/Fantasy.Server/Domain/Player/Repository/PlayerResourceRepository.cs index f9e8b50..e40f6ae 100644 --- a/Fantasy-server/Fantasy.Server/Domain/Player/Repository/PlayerResourceRepository.cs +++ b/Fantasy-server/Fantasy.Server/Domain/Player/Repository/PlayerResourceRepository.cs @@ -13,7 +13,6 @@ public class PlayerResourceRepository : IPlayerResourceRepository public async Task FindByPlayerIdAsync(long playerId) => await _db.PlayerResources - .AsNoTracking() .FirstOrDefaultAsync(r => r.PlayerId == playerId); public async Task SaveAsync(PlayerResource resource) diff --git a/Fantasy-server/Fantasy.Server/Domain/Player/Repository/PlayerSessionRepository.cs b/Fantasy-server/Fantasy.Server/Domain/Player/Repository/PlayerSessionRepository.cs index 70e2e6a..670536c 100644 --- a/Fantasy-server/Fantasy.Server/Domain/Player/Repository/PlayerSessionRepository.cs +++ b/Fantasy-server/Fantasy.Server/Domain/Player/Repository/PlayerSessionRepository.cs @@ -13,7 +13,6 @@ public class PlayerSessionRepository : IPlayerSessionRepository public async Task FindByPlayerIdAsync(long playerId) => await _db.PlayerSessions - .AsNoTracking() .FirstOrDefaultAsync(s => s.PlayerId == playerId); public async Task SaveAsync(PlayerSession session) diff --git a/Fantasy-server/Fantasy.Server/Domain/Player/Repository/PlayerStageRepository.cs b/Fantasy-server/Fantasy.Server/Domain/Player/Repository/PlayerStageRepository.cs index dc0199a..509c116 100644 --- a/Fantasy-server/Fantasy.Server/Domain/Player/Repository/PlayerStageRepository.cs +++ b/Fantasy-server/Fantasy.Server/Domain/Player/Repository/PlayerStageRepository.cs @@ -13,7 +13,6 @@ public class PlayerStageRepository : IPlayerStageRepository public async Task FindByPlayerIdAsync(long playerId) => await _db.PlayerStages - .AsNoTracking() .FirstOrDefaultAsync(s => s.PlayerId == playerId); public async Task SaveAsync(PlayerStage stage)