diff --git a/Assets/_Game/Scripts/Combat/Simulation.meta b/Assets/_Game/Scripts/Combat/Simulation.meta
new file mode 100644
index 00000000..3d9d21ca
--- /dev/null
+++ b/Assets/_Game/Scripts/Combat/Simulation.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: afad57d9ec73a1740803c20de7b89392
+folderAsset: yes
+DefaultImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/_Game/Scripts/Combat/Simulation/BuildSimulationEngine.cs b/Assets/_Game/Scripts/Combat/Simulation/BuildSimulationEngine.cs
new file mode 100644
index 00000000..4367eb53
--- /dev/null
+++ b/Assets/_Game/Scripts/Combat/Simulation/BuildSimulationEngine.cs
@@ -0,0 +1,607 @@
+using System;
+using System.Collections.Generic;
+
+using UnityEngine;
+
+using Colosseum.Passives;
+using Colosseum.Skills;
+using Colosseum.Skills.Effects;
+using Colosseum.Stats;
+using Colosseum.Weapons;
+
+namespace Colosseum.Combat.Simulation
+{
+ ///
+ /// 허수아비 계산 시뮬레이터의 단일 대상 피해 계산 엔진입니다.
+ ///
+ public static class BuildSimulationEngine
+ {
+ private const float MinimumActionDuration = 0.01f;
+ private const string UnsupportedEffectsWarning = "현재 MVP는 DamageEffect만 계산합니다.";
+
+ private sealed class SimulationContext
+ {
+ public GameObject actor;
+ public CharacterStats stats;
+ public PassiveRuntimeController passiveController;
+ public float currentMana;
+ public float maxMana;
+ public float totalDamage;
+ public float totalManaUsed;
+ public float currentTime;
+ public float firstCycleEndTime = -1f;
+ }
+
+ private sealed class SkillRuntimeSnapshot
+ {
+ public int slotIndex;
+ public SkillLoadoutEntry loadoutEntry;
+ public SkillData skill;
+ public string skillName;
+ public float castDuration;
+ public float cooldown;
+ public float manaCost;
+ public List damageEvents = new List();
+ public bool containsUnsupportedEffects;
+ }
+
+ private sealed class DamageEventSnapshot
+ {
+ public float relativeTime;
+ public float damageAmount;
+ }
+
+ private sealed class SkillMetrics
+ {
+ public string skillName;
+ public int castCount;
+ public float totalDamage;
+ }
+
+ ///
+ /// 입력 조건에 따라 허수아비 계산 시뮬레이션을 실행합니다.
+ ///
+ public static SimulationResult Run(
+ BuildSimulationInput input,
+ SimulationRuleSet ruleSet,
+ RotationPolicy rotationPolicy)
+ {
+ SimulationResult result = new SimulationResult();
+ List warnings = new List();
+
+ if (input == null)
+ {
+ warnings.Add("BuildSimulationInput이 없습니다.");
+ result.FinalizeResult("입력 없음", string.Empty, string.Empty, 0f, 0f, 0f, -1f, new List(), warnings);
+ return result;
+ }
+
+ IReadOnlyList resolvedEntries = input.ResolveLoadoutEntries();
+ if (resolvedEntries == null || resolvedEntries.Count == 0 || !input.HasAnyResolvedSkill())
+ {
+ warnings.Add("유효한 스킬 슬롯이 없습니다.");
+ result.FinalizeResult(input.BuildLabel, ruleSet != null ? ruleSet.RuleName : string.Empty, rotationPolicy != null ? rotationPolicy.PolicyName : string.Empty, 0f, 0f, 0f, -1f, new List(), warnings);
+ return result;
+ }
+
+ ruleSet ??= new SimulationRuleSet();
+ rotationPolicy ??= new RotationPolicy();
+
+ SimulationContext context = CreateContext(input, warnings);
+ try
+ {
+ SkillRuntimeSnapshot[] skillSnapshots = BuildSnapshots(input, context, ruleSet, warnings);
+ Dictionary cooldownReadyTimes = new Dictionary();
+ Dictionary metricsBySlot = new Dictionary();
+ HashSet cycleCompletedSlots = new HashSet();
+ List validPrioritySlots = CollectValidPrioritySlots(rotationPolicy, skillSnapshots);
+
+ while (context.currentTime < ruleSet.DurationSeconds)
+ {
+ SkillRuntimeSnapshot nextSkill = TrySelectSkill(skillSnapshots, cooldownReadyTimes, context, rotationPolicy);
+ if (nextSkill == null)
+ {
+ if (!TryAdvanceIdleTime(skillSnapshots, cooldownReadyTimes, context, ruleSet, rotationPolicy, warnings))
+ break;
+
+ continue;
+ }
+
+ float castStartTime = context.currentTime;
+ context.currentMana = Mathf.Max(0f, context.currentMana - nextSkill.manaCost);
+ context.totalManaUsed += nextSkill.manaCost;
+ cooldownReadyTimes[nextSkill.slotIndex] = castStartTime + nextSkill.cooldown;
+
+ if (!metricsBySlot.TryGetValue(nextSkill.slotIndex, out SkillMetrics skillMetrics))
+ {
+ skillMetrics = new SkillMetrics
+ {
+ skillName = nextSkill.skillName,
+ };
+ metricsBySlot.Add(nextSkill.slotIndex, skillMetrics);
+ }
+
+ skillMetrics.castCount++;
+
+ for (int i = 0; i < nextSkill.damageEvents.Count; i++)
+ {
+ DamageEventSnapshot damageEvent = nextSkill.damageEvents[i];
+ float eventTime = castStartTime + damageEvent.relativeTime;
+ if (eventTime > ruleSet.DurationSeconds)
+ continue;
+
+ context.totalDamage += damageEvent.damageAmount;
+ skillMetrics.totalDamage += damageEvent.damageAmount;
+ }
+
+ if (validPrioritySlots.Contains(nextSkill.slotIndex))
+ {
+ cycleCompletedSlots.Add(nextSkill.slotIndex);
+ if (context.firstCycleEndTime < 0f && cycleCompletedSlots.Count >= validPrioritySlots.Count)
+ {
+ context.firstCycleEndTime = Mathf.Min(ruleSet.DurationSeconds, castStartTime + nextSkill.castDuration);
+ }
+ }
+
+ AdvanceTime(context, Mathf.Min(ruleSet.DurationSeconds, castStartTime + nextSkill.castDuration), ruleSet);
+ }
+
+ result.FinalizeResult(
+ input.BuildLabel,
+ ruleSet.RuleName,
+ rotationPolicy.PolicyName,
+ ruleSet.DurationSeconds,
+ context.totalDamage,
+ context.totalManaUsed,
+ context.firstCycleEndTime,
+ BuildBreakdowns(metricsBySlot),
+ warnings);
+
+ return result;
+ }
+ finally
+ {
+ DestroyContext(context);
+ }
+ }
+
+ private static SimulationContext CreateContext(BuildSimulationInput input, List warnings)
+ {
+ SimulationContext context = new SimulationContext
+ {
+ actor = new GameObject("BuildSimulationActor")
+ {
+ hideFlags = HideFlags.HideAndDontSave,
+ },
+ };
+
+ context.stats = context.actor.AddComponent();
+ context.passiveController = context.actor.AddComponent();
+ context.passiveController.Initialize(context.stats);
+
+ ApplyBaseStats(context.stats, input);
+ ApplyWeaponStats(context.stats, input.Weapon);
+
+ PassiveTreeData resolvedTree = input.ResolvePassiveTree();
+ List selectedNodeIds = input.BuildSelectedPassiveNodeIdList();
+ if (resolvedTree != null && selectedNodeIds.Count > 0)
+ {
+ bool applied = context.passiveController.TryApplySelection(
+ resolvedTree,
+ selectedNodeIds,
+ input.PassivePreset != null ? input.PassivePreset.PresetName : "직접구성",
+ out string reason);
+
+ if (!applied && !string.IsNullOrWhiteSpace(reason))
+ warnings.Add($"패시브 적용 실패: {reason}");
+ }
+
+ context.maxMana = context.stats.MaxMana;
+ context.currentMana = context.maxMana;
+ return context;
+ }
+
+ private static void ApplyBaseStats(CharacterStats stats, BuildSimulationInput input)
+ {
+ stats.Strength.BaseValue = input.Strength;
+ stats.Dexterity.BaseValue = input.Dexterity;
+ stats.Intelligence.BaseValue = input.Intelligence;
+ stats.Vitality.BaseValue = input.Vitality;
+ stats.Wisdom.BaseValue = input.Wisdom;
+ stats.Spirit.BaseValue = input.Spirit;
+ }
+
+ private static void ApplyWeaponStats(CharacterStats stats, WeaponData weapon)
+ {
+ if (stats == null || weapon == null)
+ return;
+
+ foreach (StatType statType in Enum.GetValues(typeof(StatType)))
+ {
+ int bonus = weapon.GetStatBonus(statType);
+ if (bonus == 0)
+ continue;
+
+ CharacterStat stat = stats.GetStat(statType);
+ stat?.AddModifier(new StatModifier(bonus, StatModType.Flat, weapon));
+ }
+ }
+
+ private static SkillRuntimeSnapshot[] BuildSnapshots(
+ BuildSimulationInput input,
+ SimulationContext context,
+ SimulationRuleSet ruleSet,
+ List warnings)
+ {
+ IReadOnlyList slots = input.ResolveLoadoutEntries();
+ SkillRuntimeSnapshot[] snapshots = new SkillRuntimeSnapshot[slots.Count];
+ float weaponDamageMultiplier = input.Weapon != null ? input.Weapon.DamageMultiplier : 1f;
+ float weaponManaMultiplier = input.Weapon != null ? input.Weapon.ManaCostMultiplier : 1f;
+
+ for (int slotIndex = 0; slotIndex < slots.Count; slotIndex++)
+ {
+ SkillLoadoutEntry sourceEntry = slots[slotIndex];
+ SkillData skill = sourceEntry != null ? sourceEntry.BaseSkill : null;
+ if (sourceEntry == null || skill == null)
+ continue;
+
+ SkillLoadoutEntry loadoutEntry = sourceEntry.CreateCopy();
+ SkillRuntimeSnapshot snapshot = new SkillRuntimeSnapshot
+ {
+ slotIndex = slotIndex,
+ loadoutEntry = loadoutEntry,
+ skill = skill,
+ skillName = string.IsNullOrWhiteSpace(skill.SkillName) ? $"Slot {slotIndex}" : skill.SkillName,
+ cooldown = loadoutEntry.GetResolvedCooldown(),
+ manaCost = loadoutEntry.GetResolvedManaCost() * weaponManaMultiplier * context.passiveController.GetManaCostMultiplier(skill),
+ };
+
+ float resolvedAnimationSpeed = loadoutEntry.GetResolvedAnimationSpeed();
+ float mainClipDuration = ResolveClipDuration(skill.SkillClip, resolvedAnimationSpeed);
+ float endClipDuration = ResolveClipDuration(skill.EndClip, 1f);
+ int repeatCount = loadoutEntry.GetResolvedRepeatCount();
+ snapshot.castDuration = Mathf.Max(MinimumActionDuration, (mainClipDuration * repeatCount) + endClipDuration + ruleSet.MovementLossSecondsPerCast);
+
+ Dictionary> effectMap = new Dictionary>();
+ loadoutEntry.CollectTriggeredEffects(effectMap);
+
+ BuildDamageEvents(snapshot, effectMap, context, weaponDamageMultiplier, ruleSet, mainClipDuration, resolvedAnimationSpeed, repeatCount, warnings);
+ snapshots[slotIndex] = snapshot;
+ }
+
+ return snapshots;
+ }
+
+ private static void BuildDamageEvents(
+ SkillRuntimeSnapshot snapshot,
+ Dictionary> effectMap,
+ SimulationContext context,
+ float weaponDamageMultiplier,
+ SimulationRuleSet ruleSet,
+ float mainClipDuration,
+ float resolvedAnimationSpeed,
+ int repeatCount,
+ List warnings)
+ {
+ if (snapshot == null || effectMap == null || effectMap.Count == 0)
+ return;
+
+ List effectEvents = new List();
+ AnimationClip clip = snapshot.skill.SkillClip;
+ if (clip != null)
+ {
+ AnimationEvent[] clipEvents = clip.events;
+ for (int i = 0; i < clipEvents.Length; i++)
+ {
+ if (string.Equals(clipEvents[i].functionName, "OnEffect", StringComparison.Ordinal))
+ effectEvents.Add(clipEvents[i]);
+ }
+ }
+
+ effectEvents.Sort((left, right) => left.time.CompareTo(right.time));
+ float passiveDamageMultiplier = context.passiveController.GetDamageMultiplier(snapshot.skill);
+
+ for (int iteration = 0; iteration < repeatCount; iteration++)
+ {
+ float iterationOffset = mainClipDuration * iteration;
+
+ for (int eventIndex = 0; eventIndex < effectEvents.Count; eventIndex++)
+ {
+ AnimationEvent animationEvent = effectEvents[eventIndex];
+ if (!effectMap.TryGetValue(animationEvent.intParameter, out List effectsAtIndex) || effectsAtIndex == null)
+ continue;
+
+ float relativeTime = iterationOffset + (animationEvent.time / Mathf.Max(0.05f, resolvedAnimationSpeed));
+
+ for (int effectListIndex = 0; effectListIndex < effectsAtIndex.Count; effectListIndex++)
+ {
+ SkillEffect effect = effectsAtIndex[effectListIndex];
+ if (effect is DamageEffect damageEffect)
+ {
+ float damageAmount = CalculateDamage(damageEffect, context.stats, snapshot.loadoutEntry, passiveDamageMultiplier, weaponDamageMultiplier);
+ damageAmount *= ResolveTargetCount(effect, ruleSet);
+
+ snapshot.damageEvents.Add(new DamageEventSnapshot
+ {
+ relativeTime = relativeTime,
+ damageAmount = damageAmount,
+ });
+ }
+ else
+ {
+ snapshot.containsUnsupportedEffects = true;
+ }
+ }
+ }
+ }
+
+ if (snapshot.containsUnsupportedEffects)
+ warnings.Add($"{snapshot.skillName}: {UnsupportedEffectsWarning}");
+
+ if (effectEvents.Count == 0 && effectMap.Count > 0)
+ warnings.Add($"{snapshot.skillName}: OnEffect 애니메이션 이벤트가 없어 트리거 효과를 계산하지 못했습니다.");
+ }
+
+ private static float CalculateDamage(
+ DamageEffect effect,
+ CharacterStats stats,
+ SkillLoadoutEntry loadoutEntry,
+ float passiveDamageMultiplier,
+ float weaponDamageMultiplier)
+ {
+ if (effect == null || stats == null)
+ return 0f;
+
+ if (effect.DamageKind == DamageType.True)
+ {
+ return effect.BaseDamage * loadoutEntry.GetResolvedDamageMultiplier() * passiveDamageMultiplier;
+ }
+
+ float statDamage = effect.DamageKind switch
+ {
+ DamageType.Physical => stats.PhysicalDamage,
+ DamageType.Magical => stats.MagicDamage,
+ DamageType.Ranged => stats.Dexterity.FinalValue * 2f,
+ _ => 0f,
+ };
+
+ float baseTotal = effect.BaseDamage + (statDamage * effect.StatScaling);
+ return baseTotal * weaponDamageMultiplier * loadoutEntry.GetResolvedDamageMultiplier() * passiveDamageMultiplier;
+ }
+
+ private static int ResolveTargetCount(SkillEffect effect, SimulationRuleSet ruleSet)
+ {
+ if (effect == null)
+ return 1;
+
+ return effect.TargetType == TargetType.Area ? ruleSet.TargetCount : 1;
+ }
+
+ private static SkillRuntimeSnapshot TrySelectSkill(
+ SkillRuntimeSnapshot[] snapshots,
+ Dictionary cooldownReadyTimes,
+ SimulationContext context,
+ RotationPolicy rotationPolicy)
+ {
+ SkillRuntimeSnapshot snapshot = TrySelectFromSlots(rotationPolicy.PrioritySlots, snapshots, cooldownReadyTimes, context, rotationPolicy);
+ if (snapshot != null)
+ return snapshot;
+
+ if (!rotationPolicy.UseFallbackSlot)
+ return null;
+
+ return TrySelectFromSlots(new[] { rotationPolicy.FallbackSlotIndex }, snapshots, cooldownReadyTimes, context, rotationPolicy);
+ }
+
+ private static SkillRuntimeSnapshot TrySelectFromSlots(
+ int[] slotOrder,
+ SkillRuntimeSnapshot[] snapshots,
+ Dictionary cooldownReadyTimes,
+ SimulationContext context,
+ RotationPolicy rotationPolicy)
+ {
+ if (slotOrder == null || snapshots == null)
+ return null;
+
+ for (int i = 0; i < slotOrder.Length; i++)
+ {
+ int slotIndex = slotOrder[i];
+ if (slotIndex < 0 || slotIndex >= snapshots.Length)
+ continue;
+
+ SkillRuntimeSnapshot snapshot = snapshots[slotIndex];
+ if (snapshot == null)
+ continue;
+
+ if (rotationPolicy.DelayHighPowerSkillUntilTime &&
+ slotIndex == rotationPolicy.HighPowerSlotIndex &&
+ context.currentTime < rotationPolicy.HighPowerFirstUseTime &&
+ !cooldownReadyTimes.ContainsKey(slotIndex))
+ {
+ continue;
+ }
+
+ if (cooldownReadyTimes.TryGetValue(slotIndex, out float readyTime) && readyTime > context.currentTime)
+ continue;
+
+ if (snapshot.manaCost > context.currentMana)
+ continue;
+
+ return snapshot;
+ }
+
+ return null;
+ }
+
+ private static bool TryAdvanceIdleTime(
+ SkillRuntimeSnapshot[] snapshots,
+ Dictionary cooldownReadyTimes,
+ SimulationContext context,
+ SimulationRuleSet ruleSet,
+ RotationPolicy rotationPolicy,
+ List warnings)
+ {
+ float nextReadyTime = float.PositiveInfinity;
+ bool hasActionCandidate = false;
+
+ for (int i = 0; i < snapshots.Length; i++)
+ {
+ SkillRuntimeSnapshot snapshot = snapshots[i];
+ if (snapshot == null || !IsSlotRelevantToRotation(snapshot.slotIndex, rotationPolicy))
+ continue;
+
+ hasActionCandidate = true;
+ if (rotationPolicy.DelayHighPowerSkillUntilTime &&
+ snapshot.slotIndex == rotationPolicy.HighPowerSlotIndex &&
+ context.currentTime < rotationPolicy.HighPowerFirstUseTime &&
+ !cooldownReadyTimes.ContainsKey(snapshot.slotIndex))
+ {
+ nextReadyTime = Mathf.Min(nextReadyTime, rotationPolicy.HighPowerFirstUseTime);
+ continue;
+ }
+
+ if (cooldownReadyTimes.TryGetValue(snapshot.slotIndex, out float readyTime))
+ {
+ if (readyTime > context.currentTime)
+ nextReadyTime = Mathf.Min(nextReadyTime, readyTime);
+ }
+ else if (snapshot.manaCost <= context.currentMana)
+ {
+ warnings.Add("대기 없이 사용할 수 있는 스킬을 찾지 못해 시뮬레이션을 중단했습니다.");
+ return false;
+ }
+ }
+
+ if (!hasActionCandidate)
+ {
+ warnings.Add("유효한 스킬이 없어 시뮬레이션을 종료했습니다.");
+ return false;
+ }
+
+ if (ruleSet.ManaRegenPerSecond > 0f)
+ {
+ float nextManaReadyTime = ResolveNextManaReadyTime(snapshots, context, ruleSet, rotationPolicy);
+ nextReadyTime = Mathf.Min(nextReadyTime, nextManaReadyTime);
+ }
+
+ if (float.IsPositiveInfinity(nextReadyTime))
+ {
+ warnings.Add("더 이상 실행 가능한 스킬이 없어 시뮬레이션을 조기 종료했습니다.");
+ return false;
+ }
+
+ if (nextReadyTime <= context.currentTime + 0.0001f)
+ {
+ warnings.Add("다음 행동 시점을 계산하지 못해 시뮬레이션을 중단했습니다.");
+ return false;
+ }
+
+ AdvanceTime(context, Mathf.Min(ruleSet.DurationSeconds, nextReadyTime), ruleSet);
+ return context.currentTime < ruleSet.DurationSeconds;
+ }
+
+ private static float ResolveNextManaReadyTime(
+ SkillRuntimeSnapshot[] snapshots,
+ SimulationContext context,
+ SimulationRuleSet ruleSet,
+ RotationPolicy rotationPolicy)
+ {
+ float nextTime = float.PositiveInfinity;
+ for (int i = 0; i < snapshots.Length; i++)
+ {
+ SkillRuntimeSnapshot snapshot = snapshots[i];
+ if (snapshot == null ||
+ !IsSlotRelevantToRotation(snapshot.slotIndex, rotationPolicy) ||
+ snapshot.manaCost <= context.currentMana)
+ {
+ continue;
+ }
+
+ float shortage = snapshot.manaCost - context.currentMana;
+ nextTime = Mathf.Min(nextTime, context.currentTime + (shortage / ruleSet.ManaRegenPerSecond));
+ }
+
+ return nextTime;
+ }
+
+ private static void AdvanceTime(SimulationContext context, float nextTime, SimulationRuleSet ruleSet)
+ {
+ float clampedNextTime = Mathf.Max(context.currentTime, nextTime);
+ float deltaTime = clampedNextTime - context.currentTime;
+ if (deltaTime <= 0f)
+ return;
+
+ context.currentTime = clampedNextTime;
+ if (ruleSet.ManaRegenPerSecond > 0f)
+ {
+ context.currentMana = Mathf.Min(context.maxMana, context.currentMana + (ruleSet.ManaRegenPerSecond * deltaTime));
+ }
+ }
+
+ private static float ResolveClipDuration(AnimationClip clip, float speed)
+ {
+ if (clip == null)
+ return 0f;
+
+ return clip.length / Mathf.Max(0.05f, speed);
+ }
+
+ private static List CollectValidPrioritySlots(RotationPolicy rotationPolicy, SkillRuntimeSnapshot[] snapshots)
+ {
+ List validSlots = new List();
+ int[] prioritySlots = rotationPolicy.PrioritySlots;
+ for (int i = 0; i < prioritySlots.Length; i++)
+ {
+ int slotIndex = prioritySlots[i];
+ if (slotIndex < 0 || slotIndex >= snapshots.Length)
+ continue;
+
+ if (snapshots[slotIndex] == null || validSlots.Contains(slotIndex))
+ continue;
+
+ validSlots.Add(slotIndex);
+ }
+
+ return validSlots;
+ }
+
+ private static bool IsSlotRelevantToRotation(int slotIndex, RotationPolicy rotationPolicy)
+ {
+ int[] prioritySlots = rotationPolicy.PrioritySlots;
+ for (int i = 0; i < prioritySlots.Length; i++)
+ {
+ if (prioritySlots[i] == slotIndex)
+ return true;
+ }
+
+ return rotationPolicy.UseFallbackSlot && rotationPolicy.FallbackSlotIndex == slotIndex;
+ }
+
+ private static List BuildBreakdowns(Dictionary metricsBySlot)
+ {
+ List breakdowns = new List();
+ foreach (KeyValuePair pair in metricsBySlot)
+ {
+ SkillMetrics metrics = pair.Value;
+ breakdowns.Add(new SimulationSkillBreakdown(metrics.skillName, metrics.castCount, metrics.totalDamage));
+ }
+
+ breakdowns.Sort((left, right) => right.TotalDamage.CompareTo(left.TotalDamage));
+ return breakdowns;
+ }
+
+ private static void DestroyContext(SimulationContext context)
+ {
+ if (context == null || context.actor == null)
+ return;
+
+ if (Application.isPlaying)
+ {
+ UnityEngine.Object.Destroy(context.actor);
+ return;
+ }
+
+ UnityEngine.Object.DestroyImmediate(context.actor);
+ }
+ }
+}
diff --git a/Assets/_Game/Scripts/Combat/Simulation/BuildSimulationEngine.cs.meta b/Assets/_Game/Scripts/Combat/Simulation/BuildSimulationEngine.cs.meta
new file mode 100644
index 00000000..5b1e55d6
--- /dev/null
+++ b/Assets/_Game/Scripts/Combat/Simulation/BuildSimulationEngine.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 67edd6a2a08d58646a03a1b1c45e5a5c
\ No newline at end of file
diff --git a/Assets/_Game/Scripts/Combat/Simulation/BuildSimulationInput.cs b/Assets/_Game/Scripts/Combat/Simulation/BuildSimulationInput.cs
new file mode 100644
index 00000000..593cad79
--- /dev/null
+++ b/Assets/_Game/Scripts/Combat/Simulation/BuildSimulationInput.cs
@@ -0,0 +1,338 @@
+using System.Collections.Generic;
+using System.Text;
+
+using UnityEngine;
+
+using Colosseum.Passives;
+using Colosseum.Skills;
+using Colosseum.Weapons;
+
+namespace Colosseum.Combat.Simulation
+{
+ ///
+ /// 허수아비 계산 시뮬레이터가 사용할 완성형 빌드 입력값입니다.
+ /// 프리셋 없이 직접 구성한 빌드와 기존 프리셋 기반 입력을 모두 지원합니다.
+ ///
+ [System.Serializable]
+ public class BuildSimulationInput
+ {
+ private const int DefaultSlotCount = 7;
+
+ [Header("Label")]
+ [SerializeField] private string buildName = "새 빌드";
+
+ [Header("Stats")]
+ [Min(0f)] [SerializeField] private float strength = 10f;
+ [Min(0f)] [SerializeField] private float dexterity = 10f;
+ [Min(0f)] [SerializeField] private float intelligence = 10f;
+ [Min(0f)] [SerializeField] private float vitality = 10f;
+ [Min(0f)] [SerializeField] private float wisdom = 10f;
+ [Min(0f)] [SerializeField] private float spirit = 10f;
+
+ [Header("Build Assets")]
+ [SerializeField] private WeaponData weapon;
+
+ [Header("Direct Build")]
+ [Tooltip("직접 구성한 스킬/젬 슬롯입니다. 비어 있으면 로드아웃 프리셋을 사용합니다.")]
+ [SerializeField] private SkillLoadoutEntry[] directSkillSlots = new SkillLoadoutEntry[DefaultSlotCount];
+ [Tooltip("직접 구성한 패시브 트리입니다. 비어 있으면 패시브 프리셋의 트리를 사용합니다.")]
+ [SerializeField] private PassiveTreeData passiveTree;
+ [Tooltip("직접 선택한 패시브 노드 목록입니다. 비어 있으면 패시브 프리셋의 선택 노드를 사용합니다.")]
+ [SerializeField] private List selectedPassiveNodes = new List();
+
+ [Header("Preset Fallback")]
+ [SerializeField] private PassivePresetData passivePreset;
+ [SerializeField] private PlayerLoadoutPreset loadoutPreset;
+
+ public string BuildName => string.IsNullOrWhiteSpace(buildName) ? "새 빌드" : buildName.Trim();
+ public float Strength => strength;
+ public float Dexterity => dexterity;
+ public float Intelligence => intelligence;
+ public float Vitality => vitality;
+ public float Wisdom => wisdom;
+ public float Spirit => spirit;
+ public WeaponData Weapon => weapon;
+ public PassiveTreeData PassiveTree => passiveTree;
+ public IReadOnlyList SelectedPassiveNodes => selectedPassiveNodes;
+ public PassivePresetData PassivePreset => passivePreset;
+ public PlayerLoadoutPreset LoadoutPreset => loadoutPreset;
+
+ ///
+ /// 입력 상태를 읽기 쉬운 라벨로 구성합니다.
+ ///
+ public string BuildLabel
+ {
+ get
+ {
+ string passiveName = ResolvePassiveLabel();
+ string loadoutName = ResolveLoadoutLabel();
+ return $"{BuildName} | Passive={passiveName} | Loadout={loadoutName}";
+ }
+ }
+
+ ///
+ /// 직접 구성 슬롯과 프리셋을 합쳐 실제 계산에 사용할 슬롯 배열을 반환합니다.
+ ///
+ public IReadOnlyList ResolveLoadoutEntries()
+ {
+ EnsureDirectSlotCapacity();
+
+ if (HasDirectSkillSetup())
+ return directSkillSlots;
+
+ return loadoutPreset != null ? loadoutPreset.Slots : System.Array.Empty();
+ }
+
+ ///
+ /// 실제 계산 가능한 기반 스킬이 하나 이상 있는지 반환합니다.
+ ///
+ public bool HasAnyResolvedSkill()
+ {
+ IReadOnlyList entries = ResolveLoadoutEntries();
+ for (int i = 0; i < entries.Count; i++)
+ {
+ if (entries[i] != null && entries[i].BaseSkill != null)
+ return true;
+ }
+
+ return false;
+ }
+
+ ///
+ /// 실제 계산에 사용할 패시브 트리를 반환합니다.
+ ///
+ public PassiveTreeData ResolvePassiveTree()
+ {
+ if (passiveTree != null)
+ return passiveTree;
+
+ return passivePreset != null ? passivePreset.Tree : null;
+ }
+
+ ///
+ /// 실제 계산에 사용할 패시브 노드 ID 목록을 반환합니다.
+ ///
+ public List BuildSelectedPassiveNodeIdList()
+ {
+ List nodeIds = new List();
+
+ IReadOnlyList resolvedNodes = ResolveSelectedPassiveNodes();
+ for (int i = 0; i < resolvedNodes.Count; i++)
+ {
+ PassiveNodeData node = resolvedNodes[i];
+ if (node == null || string.IsNullOrWhiteSpace(node.NodeId))
+ continue;
+
+ nodeIds.Add(node.NodeId);
+ }
+ return nodeIds;
+ }
+
+ ///
+ /// 실제 계산에 사용할 패시브 노드 목록을 반환합니다.
+ ///
+ public IReadOnlyList ResolveSelectedPassiveNodes()
+ {
+ if (selectedPassiveNodes != null && selectedPassiveNodes.Count > 0)
+ return selectedPassiveNodes;
+
+ return passivePreset != null ? passivePreset.SelectedNodes : System.Array.Empty();
+ }
+
+ ///
+ /// 직접 구성 슬롯을 덮어씁니다.
+ ///
+ public void SetDirectSkillSlots(IReadOnlyList entries)
+ {
+ EnsureDirectSlotCapacity();
+
+ for (int i = 0; i < directSkillSlots.Length; i++)
+ {
+ directSkillSlots[i] = null;
+ }
+
+ if (entries == null)
+ return;
+
+ int copyCount = Mathf.Min(entries.Count, directSkillSlots.Length);
+ for (int i = 0; i < copyCount; i++)
+ {
+ SkillLoadoutEntry entry = entries[i];
+ directSkillSlots[i] = entry != null ? entry.CreateCopy() : null;
+ }
+ }
+
+ ///
+ /// 직접 구성 패시브를 덮어씁니다.
+ ///
+ public void SetDirectPassiveSelection(PassiveTreeData tree, IReadOnlyList nodes)
+ {
+ passiveTree = tree;
+ selectedPassiveNodes.Clear();
+
+ if (nodes == null)
+ return;
+
+ for (int i = 0; i < nodes.Count; i++)
+ {
+ if (nodes[i] != null)
+ selectedPassiveNodes.Add(nodes[i]);
+ }
+ }
+
+ ///
+ /// 빌드 이름을 갱신합니다.
+ ///
+ public void SetBuildName(string value)
+ {
+ buildName = value ?? string.Empty;
+ }
+
+ ///
+ /// 현재 입력값의 복사본을 생성합니다.
+ ///
+ public BuildSimulationInput CreateCopy()
+ {
+ EnsureDirectSlotCapacity();
+
+ BuildSimulationInput copy = new BuildSimulationInput
+ {
+ buildName = buildName,
+ strength = strength,
+ dexterity = dexterity,
+ intelligence = intelligence,
+ vitality = vitality,
+ wisdom = wisdom,
+ spirit = spirit,
+ weapon = weapon,
+ passiveTree = passiveTree,
+ passivePreset = passivePreset,
+ loadoutPreset = loadoutPreset,
+ };
+
+ copy.directSkillSlots = new SkillLoadoutEntry[directSkillSlots.Length];
+ for (int i = 0; i < directSkillSlots.Length; i++)
+ {
+ copy.directSkillSlots[i] = directSkillSlots[i] != null ? directSkillSlots[i].CreateCopy() : null;
+ }
+
+ copy.selectedPassiveNodes = new List(selectedPassiveNodes.Count);
+ for (int i = 0; i < selectedPassiveNodes.Count; i++)
+ {
+ if (selectedPassiveNodes[i] != null)
+ copy.selectedPassiveNodes.Add(selectedPassiveNodes[i]);
+ }
+
+ return copy;
+ }
+
+ private bool HasDirectSkillSetup()
+ {
+ if (directSkillSlots == null)
+ return false;
+
+ for (int i = 0; i < directSkillSlots.Length; i++)
+ {
+ if (directSkillSlots[i] != null && directSkillSlots[i].BaseSkill != null)
+ return true;
+ }
+
+ return false;
+ }
+
+ private void EnsureDirectSlotCapacity()
+ {
+ if (directSkillSlots != null && directSkillSlots.Length == DefaultSlotCount)
+ return;
+
+ SkillLoadoutEntry[] resized = new SkillLoadoutEntry[DefaultSlotCount];
+ if (directSkillSlots != null)
+ {
+ int copyCount = Mathf.Min(directSkillSlots.Length, resized.Length);
+ for (int i = 0; i < copyCount; i++)
+ {
+ resized[i] = directSkillSlots[i];
+ }
+ }
+
+ directSkillSlots = resized;
+ }
+
+ private string ResolvePassiveLabel()
+ {
+ if (selectedPassiveNodes != null && selectedPassiveNodes.Count > 0)
+ return BuildPassiveNodeSummary(selectedPassiveNodes);
+
+ if (passivePreset != null && !string.IsNullOrWhiteSpace(passivePreset.PresetName))
+ return passivePreset.PresetName;
+
+ return "패시브 없음";
+ }
+
+ private string ResolveLoadoutLabel()
+ {
+ if (HasDirectSkillSetup())
+ return BuildLoadoutSummary(directSkillSlots);
+
+ if (loadoutPreset != null && !string.IsNullOrWhiteSpace(loadoutPreset.PresetName))
+ return loadoutPreset.PresetName;
+
+ return "로드아웃 없음";
+ }
+
+ private static string BuildPassiveNodeSummary(IReadOnlyList nodes)
+ {
+ StringBuilder builder = new StringBuilder();
+ for (int i = 0; i < nodes.Count; i++)
+ {
+ PassiveNodeData node = nodes[i];
+ if (node == null)
+ continue;
+
+ if (builder.Length > 0)
+ builder.Append('+');
+
+ builder.Append(string.IsNullOrWhiteSpace(node.DisplayName) ? node.name : node.DisplayName);
+ }
+
+ return builder.Length > 0 ? builder.ToString() : "패시브 없음";
+ }
+
+ private static string BuildLoadoutSummary(IReadOnlyList entries)
+ {
+ StringBuilder builder = new StringBuilder();
+ for (int i = 0; i < entries.Count; i++)
+ {
+ SkillLoadoutEntry entry = entries[i];
+ if (entry == null || entry.BaseSkill == null)
+ continue;
+
+ if (builder.Length > 0)
+ builder.Append(" / ");
+
+ builder.Append('S');
+ builder.Append(i);
+ builder.Append('=');
+ builder.Append(entry.BaseSkill.SkillName);
+
+ bool hasGem = false;
+ IReadOnlyList gems = entry.SocketedGems;
+ for (int gemIndex = 0; gemIndex < gems.Count; gemIndex++)
+ {
+ SkillGemData gem = gems[gemIndex];
+ if (gem == null)
+ continue;
+
+ builder.Append(hasGem ? '+' : '[');
+ builder.Append(gem.GemName);
+ hasGem = true;
+ }
+
+ if (hasGem)
+ builder.Append(']');
+ }
+
+ return builder.Length > 0 ? builder.ToString() : "직접구성";
+ }
+ }
+}
diff --git a/Assets/_Game/Scripts/Combat/Simulation/BuildSimulationInput.cs.meta b/Assets/_Game/Scripts/Combat/Simulation/BuildSimulationInput.cs.meta
new file mode 100644
index 00000000..c320fd92
--- /dev/null
+++ b/Assets/_Game/Scripts/Combat/Simulation/BuildSimulationInput.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: f38a3a26c057b2f459b8d2bb89bc5482
\ No newline at end of file
diff --git a/Assets/_Game/Scripts/Combat/Simulation/RotationPolicy.cs b/Assets/_Game/Scripts/Combat/Simulation/RotationPolicy.cs
new file mode 100644
index 00000000..327b06e2
--- /dev/null
+++ b/Assets/_Game/Scripts/Combat/Simulation/RotationPolicy.cs
@@ -0,0 +1,56 @@
+using UnityEngine;
+
+namespace Colosseum.Combat.Simulation
+{
+ ///
+ /// 허수아비 계산 시뮬레이터의 회전 규칙입니다.
+ ///
+ [System.Serializable]
+ public class RotationPolicy
+ {
+ [Header("Label")]
+ [SerializeField] private string policyName = "기본 우선순위";
+
+ [Header("Priority")]
+ [Tooltip("앞에서부터 우선 적용할 슬롯 순서입니다. 0 기반 인덱스를 사용합니다.")]
+ [SerializeField] private int[] prioritySlots = new[] { 0, 1, 2, 3, 4, 5 };
+
+ [Header("Fallback")]
+ [SerializeField] private bool useFallbackSlot = true;
+ [Min(0)] [SerializeField] private int fallbackSlotIndex = 0;
+
+ [Header("High Power Skill")]
+ [SerializeField] private bool delayHighPowerSkillUntilTime;
+ [Min(0)] [SerializeField] private int highPowerSlotIndex = 5;
+ [Min(0f)] [SerializeField] private float highPowerFirstUseTime = 0f;
+
+ public string PolicyName => string.IsNullOrWhiteSpace(policyName) ? "Rotation" : policyName.Trim();
+ public int[] PrioritySlots => prioritySlots ?? System.Array.Empty();
+ public bool UseFallbackSlot => useFallbackSlot;
+ public int FallbackSlotIndex => Mathf.Max(0, fallbackSlotIndex);
+ public bool DelayHighPowerSkillUntilTime => delayHighPowerSkillUntilTime;
+ public int HighPowerSlotIndex => Mathf.Max(0, highPowerSlotIndex);
+ public float HighPowerFirstUseTime => Mathf.Max(0f, highPowerFirstUseTime);
+
+ ///
+ /// 회전 정책 값을 한 번에 설정합니다.
+ ///
+ public void Configure(
+ string policyName,
+ int[] prioritySlots,
+ bool useFallbackSlot,
+ int fallbackSlotIndex,
+ bool delayHighPowerSkillUntilTime,
+ int highPowerSlotIndex,
+ float highPowerFirstUseTime)
+ {
+ this.policyName = policyName ?? string.Empty;
+ this.prioritySlots = prioritySlots ?? System.Array.Empty();
+ this.useFallbackSlot = useFallbackSlot;
+ this.fallbackSlotIndex = Mathf.Max(0, fallbackSlotIndex);
+ this.delayHighPowerSkillUntilTime = delayHighPowerSkillUntilTime;
+ this.highPowerSlotIndex = Mathf.Max(0, highPowerSlotIndex);
+ this.highPowerFirstUseTime = Mathf.Max(0f, highPowerFirstUseTime);
+ }
+ }
+}
diff --git a/Assets/_Game/Scripts/Combat/Simulation/RotationPolicy.cs.meta b/Assets/_Game/Scripts/Combat/Simulation/RotationPolicy.cs.meta
new file mode 100644
index 00000000..d433cb54
--- /dev/null
+++ b/Assets/_Game/Scripts/Combat/Simulation/RotationPolicy.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: ef4440d129fca8442bdca9bdafdec626
\ No newline at end of file
diff --git a/Assets/_Game/Scripts/Combat/Simulation/SimulationBatchReportUtility.cs b/Assets/_Game/Scripts/Combat/Simulation/SimulationBatchReportUtility.cs
new file mode 100644
index 00000000..36c0b734
--- /dev/null
+++ b/Assets/_Game/Scripts/Combat/Simulation/SimulationBatchReportUtility.cs
@@ -0,0 +1,162 @@
+using System.Text;
+
+namespace Colosseum.Combat.Simulation
+{
+ ///
+ /// 배치 시뮬레이션 결과를 추출용 문자열로 변환합니다.
+ ///
+ public static class SimulationBatchReportUtility
+ {
+ ///
+ /// Markdown 리포트를 생성합니다.
+ ///
+ public static string BuildMarkdown(SimulationBatchResult result)
+ {
+ if (result == null)
+ return string.Empty;
+
+ StringBuilder builder = new StringBuilder();
+ builder.Append("# 허수아비 배치 시뮬레이션 결과");
+ builder.AppendLine();
+ builder.AppendLine();
+ builder.Append("- Batch: ");
+ builder.Append(result.BatchName);
+ builder.AppendLine();
+ builder.Append("- Generated Builds: ");
+ builder.Append(result.GeneratedBuildCount);
+ builder.AppendLine();
+ builder.Append("- Truncated: ");
+ builder.Append(result.Truncated ? "Yes" : "No");
+ builder.AppendLine();
+ builder.AppendLine();
+ builder.AppendLine("| 순위 | 빌드 | DPS | 총 피해 | 총 마나 | 첫 사이클 |");
+ builder.AppendLine("| --- | --- | ---: | ---: | ---: | ---: |");
+
+ for (int i = 0; i < result.Entries.Count; i++)
+ {
+ SimulationBatchEntry entry = result.Entries[i];
+ SimulationResult simulation = entry != null ? entry.Result : null;
+ if (simulation == null)
+ continue;
+
+ builder.Append("| ");
+ builder.Append(i + 1);
+ builder.Append(" | ");
+ builder.Append(entry.BuildLabel);
+ builder.Append(" | ");
+ builder.Append(simulation.AverageDps.ToString("0.##"));
+ builder.Append(" | ");
+ builder.Append(simulation.TotalDamage.ToString("0.##"));
+ builder.Append(" | ");
+ builder.Append(simulation.TotalManaUsed.ToString("0.##"));
+ builder.Append(" | ");
+ builder.Append(simulation.FirstCycleEndTime >= 0f ? simulation.FirstCycleEndTime.ToString("0.##") + "s" : "미완료");
+ builder.AppendLine(" |");
+ }
+
+ if (result.Warnings.Count > 0)
+ {
+ builder.AppendLine();
+ builder.AppendLine("## 경고");
+ builder.AppendLine();
+ for (int i = 0; i < result.Warnings.Count; i++)
+ {
+ builder.Append("- ");
+ builder.Append(result.Warnings[i]);
+ builder.AppendLine();
+ }
+ }
+
+ return builder.ToString().TrimEnd();
+ }
+
+ ///
+ /// CSV 리포트를 생성합니다.
+ ///
+ public static string BuildCsv(SimulationBatchResult result)
+ {
+ if (result == null)
+ return string.Empty;
+
+ StringBuilder builder = new StringBuilder();
+ builder.AppendLine("Rank,BuildLabel,RuleName,RotationName,DurationSeconds,TotalDamage,AverageDps,TotalManaUsed,AverageManaPerSecond,FirstCycleEndTime,TopSkill,TopSkillDamage,Warnings");
+
+ for (int i = 0; i < result.Entries.Count; i++)
+ {
+ SimulationBatchEntry entry = result.Entries[i];
+ SimulationResult simulation = entry != null ? entry.Result : null;
+ if (simulation == null)
+ continue;
+
+ SimulationSkillBreakdown topSkill = simulation.SkillBreakdowns.Count > 0 ? simulation.SkillBreakdowns[0] : null;
+ string warnings = string.Join(" / ", simulation.Warnings);
+
+ builder.Append(i + 1);
+ builder.Append(',');
+ builder.Append(Escape(entry.BuildLabel));
+ builder.Append(',');
+ builder.Append(Escape(simulation.RuleName));
+ builder.Append(',');
+ builder.Append(Escape(simulation.RotationName));
+ builder.Append(',');
+ builder.Append(simulation.DurationSeconds.ToString("0.##"));
+ builder.Append(',');
+ builder.Append(simulation.TotalDamage.ToString("0.##"));
+ builder.Append(',');
+ builder.Append(simulation.AverageDps.ToString("0.##"));
+ builder.Append(',');
+ builder.Append(simulation.TotalManaUsed.ToString("0.##"));
+ builder.Append(',');
+ builder.Append(simulation.AverageManaPerSecond.ToString("0.##"));
+ builder.Append(',');
+ builder.Append(simulation.FirstCycleEndTime >= 0f ? simulation.FirstCycleEndTime.ToString("0.##") : string.Empty);
+ builder.Append(',');
+ builder.Append(Escape(topSkill != null ? topSkill.SkillName : string.Empty));
+ builder.Append(',');
+ builder.Append(topSkill != null ? topSkill.TotalDamage.ToString("0.##") : string.Empty);
+ builder.Append(',');
+ builder.Append(Escape(warnings));
+ builder.AppendLine();
+ }
+
+ return builder.ToString().TrimEnd();
+ }
+
+ ///
+ /// 기본 파일 이름을 생성합니다.
+ ///
+ public static string BuildDefaultFileName(SimulationBatchResult result, bool csv)
+ {
+ string batchName = result != null && !string.IsNullOrWhiteSpace(result.BatchName) ? result.BatchName : "BuildSimulationBatch";
+ string extension = csv ? "csv" : "md";
+ return $"{Sanitize(batchName)}.{extension}";
+ }
+
+ private static string Escape(string value)
+ {
+ if (string.IsNullOrEmpty(value))
+ return string.Empty;
+
+ bool needsQuotes = value.Contains(",") || value.Contains("\"") || value.Contains("\n") || value.Contains("\r");
+ if (!needsQuotes)
+ return value;
+
+ return $"\"{value.Replace("\"", "\"\"")}\"";
+ }
+
+ private static string Sanitize(string value)
+ {
+ if (string.IsNullOrWhiteSpace(value))
+ return "BuildSimulationBatch";
+
+ string sanitized = value;
+ char[] invalidChars = System.IO.Path.GetInvalidFileNameChars();
+ for (int i = 0; i < invalidChars.Length; i++)
+ {
+ sanitized = sanitized.Replace(invalidChars[i], '_');
+ }
+
+ return sanitized.Replace(' ', '_');
+ }
+ }
+}
diff --git a/Assets/_Game/Scripts/Combat/Simulation/SimulationBatchReportUtility.cs.meta b/Assets/_Game/Scripts/Combat/Simulation/SimulationBatchReportUtility.cs.meta
new file mode 100644
index 00000000..29d40897
--- /dev/null
+++ b/Assets/_Game/Scripts/Combat/Simulation/SimulationBatchReportUtility.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: d741ba4f455e909469d52f22bad21c8b
\ No newline at end of file
diff --git a/Assets/_Game/Scripts/Combat/Simulation/SimulationBatchResult.cs b/Assets/_Game/Scripts/Combat/Simulation/SimulationBatchResult.cs
new file mode 100644
index 00000000..132efa9c
--- /dev/null
+++ b/Assets/_Game/Scripts/Combat/Simulation/SimulationBatchResult.cs
@@ -0,0 +1,53 @@
+using System.Collections.Generic;
+
+using UnityEngine;
+
+namespace Colosseum.Combat.Simulation
+{
+ ///
+ /// 배치 시뮬레이션의 단일 결과 엔트리입니다.
+ ///
+ [System.Serializable]
+ public sealed class SimulationBatchEntry
+ {
+ [SerializeField] private string buildLabel;
+ [SerializeField] private SimulationResult result;
+
+ public string BuildLabel => buildLabel;
+ public SimulationResult Result => result;
+
+ public SimulationBatchEntry(string buildLabel, SimulationResult result)
+ {
+ this.buildLabel = buildLabel ?? string.Empty;
+ this.result = result;
+ }
+ }
+
+ ///
+ /// 배치 시뮬레이션의 전체 결과입니다.
+ ///
+ [System.Serializable]
+ public sealed class SimulationBatchResult
+ {
+ [SerializeField] private string batchName = string.Empty;
+ [SerializeField] private int generatedBuildCount;
+ [SerializeField] private bool truncated;
+ [SerializeField] private List entries = new List();
+ [SerializeField] private List warnings = new List();
+
+ public string BatchName => batchName;
+ public int GeneratedBuildCount => generatedBuildCount;
+ public bool Truncated => truncated;
+ public IReadOnlyList Entries => entries;
+ public IReadOnlyList Warnings => warnings;
+
+ public void Initialize(string batchName, int generatedBuildCount, bool truncated, List entries, List warnings)
+ {
+ this.batchName = batchName ?? string.Empty;
+ this.generatedBuildCount = Mathf.Max(0, generatedBuildCount);
+ this.truncated = truncated;
+ this.entries = entries ?? new List();
+ this.warnings = warnings ?? new List();
+ }
+ }
+}
diff --git a/Assets/_Game/Scripts/Combat/Simulation/SimulationBatchResult.cs.meta b/Assets/_Game/Scripts/Combat/Simulation/SimulationBatchResult.cs.meta
new file mode 100644
index 00000000..a75b4966
--- /dev/null
+++ b/Assets/_Game/Scripts/Combat/Simulation/SimulationBatchResult.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 6b13c6fe196ef0343942b700dbc02414
\ No newline at end of file
diff --git a/Assets/_Game/Scripts/Combat/Simulation/SimulationBatchRunner.cs b/Assets/_Game/Scripts/Combat/Simulation/SimulationBatchRunner.cs
new file mode 100644
index 00000000..db62ee9d
--- /dev/null
+++ b/Assets/_Game/Scripts/Combat/Simulation/SimulationBatchRunner.cs
@@ -0,0 +1,58 @@
+using System.Collections.Generic;
+
+namespace Colosseum.Combat.Simulation
+{
+ ///
+ /// 여러 빌드를 순회하며 배치 시뮬레이션을 실행합니다.
+ ///
+ public static class SimulationBatchRunner
+ {
+ ///
+ /// 생성된 빌드 목록을 순회 실행하고 결과를 묶어 반환합니다.
+ ///
+ public static SimulationBatchResult Run(
+ string batchName,
+ IReadOnlyList builds,
+ SimulationRuleSet ruleSet,
+ RotationPolicy rotationPolicy,
+ IReadOnlyList generationWarnings,
+ bool truncated)
+ {
+ List entries = new List();
+ List warnings = new List();
+
+ if (generationWarnings != null)
+ {
+ for (int i = 0; i < generationWarnings.Count; i++)
+ {
+ if (!string.IsNullOrWhiteSpace(generationWarnings[i]))
+ warnings.Add(generationWarnings[i]);
+ }
+ }
+
+ if (builds != null)
+ {
+ for (int i = 0; i < builds.Count; i++)
+ {
+ BuildSimulationInput build = builds[i];
+ if (build == null)
+ continue;
+
+ SimulationResult result = BuildSimulationEngine.Run(build, ruleSet, rotationPolicy);
+ entries.Add(new SimulationBatchEntry(build.BuildLabel, result));
+ }
+ }
+
+ entries.Sort((left, right) =>
+ {
+ float leftDps = left != null && left.Result != null ? left.Result.AverageDps : 0f;
+ float rightDps = right != null && right.Result != null ? right.Result.AverageDps : 0f;
+ return rightDps.CompareTo(leftDps);
+ });
+
+ SimulationBatchResult batchResult = new SimulationBatchResult();
+ batchResult.Initialize(batchName, builds != null ? builds.Count : 0, truncated, entries, warnings);
+ return batchResult;
+ }
+ }
+}
diff --git a/Assets/_Game/Scripts/Combat/Simulation/SimulationBatchRunner.cs.meta b/Assets/_Game/Scripts/Combat/Simulation/SimulationBatchRunner.cs.meta
new file mode 100644
index 00000000..9e02df4a
--- /dev/null
+++ b/Assets/_Game/Scripts/Combat/Simulation/SimulationBatchRunner.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 886744cabe7fc084e884d2dbd297ed8e
\ No newline at end of file
diff --git a/Assets/_Game/Scripts/Combat/Simulation/SimulationCombinationGenerator.cs b/Assets/_Game/Scripts/Combat/Simulation/SimulationCombinationGenerator.cs
new file mode 100644
index 00000000..a312db67
--- /dev/null
+++ b/Assets/_Game/Scripts/Combat/Simulation/SimulationCombinationGenerator.cs
@@ -0,0 +1,424 @@
+using System.Collections.Generic;
+
+using Colosseum.Passives;
+using Colosseum.Skills;
+
+namespace Colosseum.Combat.Simulation
+{
+ ///
+ /// 스킬/젬/패시브 자산 풀에서 유효한 빌드 조합을 생성합니다.
+ ///
+ public static class SimulationCombinationGenerator
+ {
+ ///
+ /// 조합 조건에 따라 실제 시뮬레이션 입력 빌드를 생성합니다.
+ ///
+ public static List GenerateBuilds(
+ BuildSimulationInput template,
+ SimulationCombinationSpec spec,
+ IReadOnlyList skillPool,
+ IReadOnlyList gemPool,
+ IReadOnlyList passiveNodePool,
+ List warnings,
+ out bool truncated)
+ {
+ warnings ??= new List();
+ truncated = false;
+
+ BuildSimulationInput baseTemplate = template != null ? template.CreateCopy() : new BuildSimulationInput();
+ List results = new List();
+ SimulationCombinationSpec safeSpec = spec ?? new SimulationCombinationSpec();
+
+ List> passiveSelections = BuildPassiveSelections(baseTemplate, safeSpec, passiveNodePool, warnings);
+ List activeSlots = BuildActiveSlots(safeSpec);
+ Dictionary> loadoutCache = new Dictionary>();
+ SkillLoadoutEntry[] workingSlots = new SkillLoadoutEntry[7];
+
+ for (int i = 0; i < workingSlots.Length; i++)
+ {
+ IReadOnlyList templateSlots = baseTemplate.ResolveLoadoutEntries();
+ if (i < templateSlots.Count && templateSlots[i] != null)
+ workingSlots[i] = templateSlots[i].CreateCopy();
+ }
+
+ int buildIndex = 0;
+ for (int passiveIndex = 0; passiveIndex < passiveSelections.Count; passiveIndex++)
+ {
+ if (results.Count >= safeSpec.MaxBuildCount)
+ {
+ truncated = true;
+ break;
+ }
+
+ List passiveSelection = passiveSelections[passiveIndex];
+ GenerateSkillAssignments(
+ baseTemplate,
+ safeSpec,
+ skillPool,
+ gemPool,
+ activeSlots,
+ loadoutCache,
+ workingSlots,
+ 0,
+ new HashSet(),
+ passiveSelection,
+ results,
+ warnings,
+ ref buildIndex,
+ ref truncated);
+ }
+
+ if (truncated)
+ warnings.Add($"조합 생성이 상한에 도달해 중단되었습니다. MaxBuildCount={safeSpec.MaxBuildCount}");
+
+ return results;
+ }
+
+ private static void GenerateSkillAssignments(
+ BuildSimulationInput template,
+ SimulationCombinationSpec spec,
+ IReadOnlyList skillPool,
+ IReadOnlyList gemPool,
+ IReadOnlyList activeSlots,
+ Dictionary> loadoutCache,
+ SkillLoadoutEntry[] workingSlots,
+ int slotCursor,
+ HashSet usedSkills,
+ IReadOnlyList passiveSelection,
+ List results,
+ List warnings,
+ ref int buildIndex,
+ ref bool truncated)
+ {
+ if (truncated || results.Count >= spec.MaxBuildCount)
+ {
+ truncated = true;
+ return;
+ }
+
+ if (slotCursor >= activeSlots.Count)
+ {
+ BuildSimulationInput variant = template.CreateCopy();
+ variant.SetDirectSkillSlots(workingSlots);
+ variant.SetDirectPassiveSelection(spec.CombinePassives ? spec.PassiveTree : template.ResolvePassiveTree(), passiveSelection);
+ variant.SetBuildName($"{spec.BatchName}_{buildIndex + 1:D4}");
+ results.Add(variant);
+ buildIndex++;
+ return;
+ }
+
+ int slotIndex = activeSlots[slotCursor];
+ if (!spec.CombineSkills)
+ {
+ GenerateFromTemplateSlot(
+ template,
+ spec,
+ gemPool,
+ activeSlots,
+ loadoutCache,
+ workingSlots,
+ slotCursor,
+ passiveSelection,
+ results,
+ warnings,
+ ref buildIndex,
+ ref truncated);
+ return;
+ }
+
+ if (skillPool == null || skillPool.Count == 0)
+ {
+ warnings.Add("스킬 풀에 자산이 없어 조합 생성을 진행하지 못했습니다.");
+ truncated = true;
+ return;
+ }
+
+ for (int i = 0; i < skillPool.Count; i++)
+ {
+ SkillData skill = skillPool[i];
+ if (skill == null)
+ continue;
+
+ if (!spec.AllowDuplicateSkills && usedSkills.Contains(skill))
+ continue;
+
+ List variants = GetLoadoutVariants(skill, spec, gemPool, loadoutCache);
+ for (int variantIndex = 0; variantIndex < variants.Count; variantIndex++)
+ {
+ workingSlots[slotIndex] = variants[variantIndex].CreateCopy();
+
+ bool added = false;
+ if (!spec.AllowDuplicateSkills)
+ {
+ added = usedSkills.Add(skill);
+ }
+
+ GenerateSkillAssignments(
+ template,
+ spec,
+ skillPool,
+ gemPool,
+ activeSlots,
+ loadoutCache,
+ workingSlots,
+ slotCursor + 1,
+ usedSkills,
+ passiveSelection,
+ results,
+ warnings,
+ ref buildIndex,
+ ref truncated);
+
+ if (added)
+ usedSkills.Remove(skill);
+
+ if (truncated)
+ return;
+ }
+ }
+ }
+
+ private static void GenerateFromTemplateSlot(
+ BuildSimulationInput template,
+ SimulationCombinationSpec spec,
+ IReadOnlyList gemPool,
+ IReadOnlyList activeSlots,
+ Dictionary> loadoutCache,
+ SkillLoadoutEntry[] workingSlots,
+ int slotCursor,
+ IReadOnlyList passiveSelection,
+ List results,
+ List warnings,
+ ref int buildIndex,
+ ref bool truncated)
+ {
+ int slotIndex = activeSlots[slotCursor];
+ IReadOnlyList templateSlots = template.ResolveLoadoutEntries();
+ SkillLoadoutEntry templateEntry = slotIndex < templateSlots.Count ? templateSlots[slotIndex] : null;
+ SkillData skill = templateEntry != null ? templateEntry.BaseSkill : null;
+ if (skill == null)
+ {
+ warnings.Add($"템플릿 슬롯 {slotIndex}에 기반 스킬이 없어 배치 생성이 중단되었습니다.");
+ truncated = true;
+ return;
+ }
+
+ List variants = spec.CombineGems
+ ? GetLoadoutVariants(skill, spec, gemPool, loadoutCache)
+ : new List { templateEntry.CreateCopy() };
+
+ for (int i = 0; i < variants.Count; i++)
+ {
+ workingSlots[slotIndex] = variants[i].CreateCopy();
+ GenerateSkillAssignments(
+ template,
+ spec,
+ null,
+ gemPool,
+ activeSlots,
+ loadoutCache,
+ workingSlots,
+ slotCursor + 1,
+ new HashSet(),
+ passiveSelection,
+ results,
+ warnings,
+ ref buildIndex,
+ ref truncated);
+
+ if (truncated)
+ return;
+ }
+ }
+
+ private static List GetLoadoutVariants(
+ SkillData skill,
+ SimulationCombinationSpec spec,
+ IReadOnlyList gemPool,
+ Dictionary> loadoutCache)
+ {
+ if (loadoutCache.TryGetValue(skill, out List cachedVariants))
+ return cachedVariants;
+
+ List variants = new List();
+ int gemSlotCount = skill != null ? skill.MaxGemSlotCount : 0;
+
+ if (skill == null)
+ return variants;
+
+ List compatibleGems = new List();
+ if (gemPool != null)
+ {
+ for (int i = 0; i < gemPool.Count; i++)
+ {
+ SkillGemData gem = gemPool[i];
+ if (gem != null && gem.CanAttachToSkill(skill))
+ compatibleGems.Add(gem);
+ }
+ }
+
+ if (!spec.CombineGems || gemSlotCount <= 0 || compatibleGems.Count == 0)
+ {
+ variants.Add(SkillLoadoutEntry.CreateTemporary(skill));
+ loadoutCache.Add(skill, variants);
+ return variants;
+ }
+
+ List selected = new List();
+ if (spec.IncludeEmptyGemSet)
+ variants.Add(SkillLoadoutEntry.CreateTemporary(skill));
+
+ CollectGemVariants(skill, compatibleGems, selected, 0, gemSlotCount, variants);
+
+ if (variants.Count == 0)
+ variants.Add(SkillLoadoutEntry.CreateTemporary(skill));
+
+ loadoutCache.Add(skill, variants);
+ return variants;
+ }
+
+ private static void CollectGemVariants(
+ SkillData skill,
+ IReadOnlyList compatibleGems,
+ List selected,
+ int startIndex,
+ int maxGemCount,
+ List variants)
+ {
+ if (selected.Count > 0)
+ {
+ SkillLoadoutEntry entry = SkillLoadoutEntry.CreateTemporary(skill);
+ bool valid = true;
+ for (int i = 0; i < selected.Count; i++)
+ {
+ if (!entry.TrySetGem(i, selected[i], out _))
+ {
+ valid = false;
+ break;
+ }
+ }
+
+ if (valid)
+ variants.Add(entry.CreateCopy());
+ }
+
+ if (selected.Count >= maxGemCount)
+ return;
+
+ for (int i = startIndex; i < compatibleGems.Count; i++)
+ {
+ selected.Add(compatibleGems[i]);
+ CollectGemVariants(skill, compatibleGems, selected, i + 1, maxGemCount, variants);
+ selected.RemoveAt(selected.Count - 1);
+ }
+ }
+
+ private static List> BuildPassiveSelections(
+ BuildSimulationInput template,
+ SimulationCombinationSpec spec,
+ IReadOnlyList passiveNodePool,
+ List warnings)
+ {
+ List> selections = new List>();
+
+ if (!spec.CombinePassives)
+ {
+ List currentSelection = new List();
+ IReadOnlyList templateNodes = template.ResolveSelectedPassiveNodes();
+ for (int i = 0; i < templateNodes.Count; i++)
+ {
+ if (templateNodes[i] != null)
+ currentSelection.Add(templateNodes[i]);
+ }
+
+ selections.Add(currentSelection);
+ return selections;
+ }
+
+ PassiveTreeData tree = spec.PassiveTree;
+ if (tree == null)
+ {
+ warnings.Add("패시브 트리가 없어 패시브 조합 생성은 빈 선택만 사용합니다.");
+ selections.Add(new List());
+ return selections;
+ }
+
+ List candidates = new List();
+ IReadOnlyList sourcePool = passiveNodePool != null && passiveNodePool.Count > 0 ? passiveNodePool : tree.Nodes;
+ for (int i = 0; i < sourcePool.Count; i++)
+ {
+ PassiveNodeData node = sourcePool[i];
+ if (node != null && tree.GetNodeById(node.NodeId) != null)
+ candidates.Add(node);
+ }
+
+ List selected = new List();
+ if (spec.IncludeEmptyPassiveSelection)
+ selections.Add(new List());
+
+ CollectPassiveSelections(tree, spec, candidates, selected, 0, selections);
+
+ if (selections.Count == 0)
+ selections.Add(new List());
+
+ return selections;
+ }
+
+ private static void CollectPassiveSelections(
+ PassiveTreeData tree,
+ SimulationCombinationSpec spec,
+ IReadOnlyList candidates,
+ List selected,
+ int startIndex,
+ List> results)
+ {
+ if (spec.MaxPassiveNodeCount > 0 && selected.Count >= spec.MaxPassiveNodeCount)
+ return;
+
+ for (int i = startIndex; i < candidates.Count; i++)
+ {
+ PassiveNodeData node = candidates[i];
+ selected.Add(node);
+
+ if (TryValidatePassiveSelection(tree, selected))
+ {
+ results.Add(new List(selected));
+ CollectPassiveSelections(tree, spec, candidates, selected, i + 1, results);
+ }
+
+ selected.RemoveAt(selected.Count - 1);
+ }
+ }
+
+ private static bool TryValidatePassiveSelection(PassiveTreeData tree, IReadOnlyList selected)
+ {
+ List ids = new List(selected.Count);
+ for (int i = 0; i < selected.Count; i++)
+ {
+ if (selected[i] != null && !string.IsNullOrWhiteSpace(selected[i].NodeId))
+ ids.Add(selected[i].NodeId);
+ }
+
+ return tree.TryResolveSelection(ids, out _, out _);
+ }
+
+ private static List BuildActiveSlots(SimulationCombinationSpec spec)
+ {
+ List activeSlots = new List();
+ int[] slotIndices = spec.ActiveSlotIndices;
+ for (int i = 0; i < slotIndices.Length; i++)
+ {
+ int slotIndex = slotIndices[i];
+ if (slotIndex < 0 || slotIndex >= 7 || activeSlots.Contains(slotIndex))
+ continue;
+
+ activeSlots.Add(slotIndex);
+ }
+
+ if (activeSlots.Count == 0)
+ activeSlots.Add(0);
+
+ return activeSlots;
+ }
+ }
+}
diff --git a/Assets/_Game/Scripts/Combat/Simulation/SimulationCombinationGenerator.cs.meta b/Assets/_Game/Scripts/Combat/Simulation/SimulationCombinationGenerator.cs.meta
new file mode 100644
index 00000000..e4edbf33
--- /dev/null
+++ b/Assets/_Game/Scripts/Combat/Simulation/SimulationCombinationGenerator.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 68001ec95567cad4bb70cfc9863c469a
\ No newline at end of file
diff --git a/Assets/_Game/Scripts/Combat/Simulation/SimulationCombinationSpec.cs b/Assets/_Game/Scripts/Combat/Simulation/SimulationCombinationSpec.cs
new file mode 100644
index 00000000..9a94f08e
--- /dev/null
+++ b/Assets/_Game/Scripts/Combat/Simulation/SimulationCombinationSpec.cs
@@ -0,0 +1,78 @@
+using UnityEngine;
+
+using Colosseum.Passives;
+
+namespace Colosseum.Combat.Simulation
+{
+ ///
+ /// 전수 점검용 조합 생성 조건입니다.
+ ///
+ [System.Serializable]
+ public class SimulationCombinationSpec
+ {
+ [Header("Label")]
+ [SerializeField] private string batchName = "전체 조합";
+
+ [Header("Dimensions")]
+ [SerializeField] private bool combineSkills = true;
+ [SerializeField] private bool combineGems = true;
+ [SerializeField] private bool combinePassives = true;
+
+ [Header("Slots")]
+ [Tooltip("조합 생성 대상 슬롯 인덱스입니다. 0 기반입니다.")]
+ [SerializeField] private int[] activeSlotIndices = new[] { 0, 1, 2, 3, 4, 5 };
+ [SerializeField] private bool allowDuplicateSkills;
+ [SerializeField] private bool includeEmptyGemSet = true;
+
+ [Header("Passive")]
+ [SerializeField] private PassiveTreeData passiveTree;
+ [SerializeField] private bool includeEmptyPassiveSelection = true;
+ [Tooltip("0이면 포인트 허용 범위 안에서 제한 없이 생성합니다.")]
+ [Min(0)] [SerializeField] private int maxPassiveNodeCount = 0;
+
+ [Header("Safety")]
+ [Tooltip("생성할 최대 빌드 수입니다. 조합 폭발을 막기 위한 안전장치입니다.")]
+ [Min(1)] [SerializeField] private int maxBuildCount = 500;
+
+ public string BatchName => string.IsNullOrWhiteSpace(batchName) ? "전체 조합" : batchName.Trim();
+ public bool CombineSkills => combineSkills;
+ public bool CombineGems => combineGems;
+ public bool CombinePassives => combinePassives;
+ public int[] ActiveSlotIndices => activeSlotIndices ?? System.Array.Empty();
+ public bool AllowDuplicateSkills => allowDuplicateSkills;
+ public bool IncludeEmptyGemSet => includeEmptyGemSet;
+ public PassiveTreeData PassiveTree => passiveTree;
+ public bool IncludeEmptyPassiveSelection => includeEmptyPassiveSelection;
+ public int MaxPassiveNodeCount => Mathf.Max(0, maxPassiveNodeCount);
+ public int MaxBuildCount => Mathf.Max(1, maxBuildCount);
+
+ ///
+ /// 조합 생성 조건을 한 번에 설정합니다.
+ ///
+ public void Configure(
+ string batchName,
+ bool combineSkills,
+ bool combineGems,
+ bool combinePassives,
+ int[] activeSlotIndices,
+ bool allowDuplicateSkills,
+ bool includeEmptyGemSet,
+ PassiveTreeData passiveTree,
+ bool includeEmptyPassiveSelection,
+ int maxPassiveNodeCount,
+ int maxBuildCount)
+ {
+ this.batchName = batchName ?? string.Empty;
+ this.combineSkills = combineSkills;
+ this.combineGems = combineGems;
+ this.combinePassives = combinePassives;
+ this.activeSlotIndices = activeSlotIndices ?? System.Array.Empty();
+ this.allowDuplicateSkills = allowDuplicateSkills;
+ this.includeEmptyGemSet = includeEmptyGemSet;
+ this.passiveTree = passiveTree;
+ this.includeEmptyPassiveSelection = includeEmptyPassiveSelection;
+ this.maxPassiveNodeCount = Mathf.Max(0, maxPassiveNodeCount);
+ this.maxBuildCount = Mathf.Max(1, maxBuildCount);
+ }
+ }
+}
diff --git a/Assets/_Game/Scripts/Combat/Simulation/SimulationCombinationSpec.cs.meta b/Assets/_Game/Scripts/Combat/Simulation/SimulationCombinationSpec.cs.meta
new file mode 100644
index 00000000..9ac4cd1d
--- /dev/null
+++ b/Assets/_Game/Scripts/Combat/Simulation/SimulationCombinationSpec.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 8311c1f399b18a54a844b5d373eb27fa
\ No newline at end of file
diff --git a/Assets/_Game/Scripts/Combat/Simulation/SimulationReportUtility.cs b/Assets/_Game/Scripts/Combat/Simulation/SimulationReportUtility.cs
new file mode 100644
index 00000000..7c6d4934
--- /dev/null
+++ b/Assets/_Game/Scripts/Combat/Simulation/SimulationReportUtility.cs
@@ -0,0 +1,221 @@
+using System;
+using System.Collections.Generic;
+using System.Text;
+
+namespace Colosseum.Combat.Simulation
+{
+ ///
+ /// 결과 미리보기/추출 포맷입니다.
+ ///
+ public enum SimulationReportFormat
+ {
+ DetailText,
+ Markdown,
+ Csv,
+ }
+
+ ///
+ /// 허수아비 계산 시뮬레이터 결과를 외부 공유용 문자열로 변환합니다.
+ ///
+ public static class SimulationReportUtility
+ {
+ ///
+ /// 선택한 포맷으로 결과 문자열을 생성합니다.
+ ///
+ public static string BuildReport(SimulationResult result, SimulationReportFormat format)
+ {
+ if (result == null)
+ return string.Empty;
+
+ return format switch
+ {
+ SimulationReportFormat.Markdown => BuildMarkdown(result),
+ SimulationReportFormat.Csv => BuildCsv(result),
+ _ => result.DetailText,
+ };
+ }
+
+ ///
+ /// 마크다운 리포트를 생성합니다.
+ ///
+ public static string BuildMarkdown(SimulationResult result)
+ {
+ if (result == null)
+ return string.Empty;
+
+ StringBuilder builder = new StringBuilder();
+ builder.Append("# 허수아비 계산 시뮬레이션 결과");
+ builder.AppendLine();
+ builder.AppendLine();
+ builder.Append("- Build: ");
+ builder.Append(result.BuildLabel);
+ builder.AppendLine();
+ builder.Append("- Rule: ");
+ builder.Append(result.RuleName);
+ builder.AppendLine();
+ builder.Append("- Rotation: ");
+ builder.Append(result.RotationName);
+ builder.AppendLine();
+ builder.AppendLine();
+
+ builder.AppendLine("| 항목 | 값 |");
+ builder.AppendLine("| --- | --- |");
+ builder.Append("| Duration | ");
+ builder.Append(result.DurationSeconds.ToString("0.##"));
+ builder.AppendLine("s |");
+ builder.Append("| Total Damage | ");
+ builder.Append(result.TotalDamage.ToString("0.##"));
+ builder.AppendLine(" |");
+ builder.Append("| DPS | ");
+ builder.Append(result.AverageDps.ToString("0.##"));
+ builder.AppendLine(" |");
+ builder.Append("| Total Mana | ");
+ builder.Append(result.TotalManaUsed.ToString("0.##"));
+ builder.AppendLine(" |");
+ builder.Append("| Mana / Sec | ");
+ builder.Append(result.AverageManaPerSecond.ToString("0.##"));
+ builder.AppendLine(" |");
+ builder.Append("| First Cycle End | ");
+ builder.Append(result.FirstCycleEndTime >= 0f ? result.FirstCycleEndTime.ToString("0.##") + "s" : "미완료");
+ builder.AppendLine(" |");
+
+ if (result.SkillBreakdowns.Count > 0)
+ {
+ builder.AppendLine();
+ builder.AppendLine("## 스킬 기여도");
+ builder.AppendLine();
+ builder.AppendLine("| 스킬 | 사용 횟수 | 누적 피해 |");
+ builder.AppendLine("| --- | ---: | ---: |");
+
+ for (int i = 0; i < result.SkillBreakdowns.Count; i++)
+ {
+ SimulationSkillBreakdown entry = result.SkillBreakdowns[i];
+ builder.Append("| ");
+ builder.Append(entry.SkillName);
+ builder.Append(" | ");
+ builder.Append(entry.CastCount);
+ builder.Append(" | ");
+ builder.Append(entry.TotalDamage.ToString("0.##"));
+ builder.AppendLine(" |");
+ }
+ }
+
+ if (result.Warnings.Count > 0)
+ {
+ builder.AppendLine();
+ builder.AppendLine("## 경고");
+ builder.AppendLine();
+ for (int i = 0; i < result.Warnings.Count; i++)
+ {
+ builder.Append("- ");
+ builder.Append(result.Warnings[i]);
+ builder.AppendLine();
+ }
+ }
+
+ return builder.ToString().TrimEnd();
+ }
+
+ ///
+ /// CSV 리포트를 생성합니다.
+ ///
+ public static string BuildCsv(SimulationResult result)
+ {
+ if (result == null)
+ return string.Empty;
+
+ StringBuilder builder = new StringBuilder();
+ builder.AppendLine("BuildLabel,RuleName,RotationName,DurationSeconds,TotalDamage,AverageDps,TotalManaUsed,AverageManaPerSecond,FirstCycleEndTime,SkillName,CastCount,SkillDamage,Warnings");
+
+ string warnings = string.Join(" / ", result.Warnings);
+ IReadOnlyList breakdowns = result.SkillBreakdowns;
+
+ if (breakdowns.Count == 0)
+ {
+ AppendCsvRow(builder, result, null, warnings);
+ return builder.ToString().TrimEnd();
+ }
+
+ for (int i = 0; i < breakdowns.Count; i++)
+ {
+ AppendCsvRow(builder, result, breakdowns[i], warnings);
+ }
+
+ return builder.ToString().TrimEnd();
+ }
+
+ ///
+ /// 저장에 적합한 기본 파일 이름을 생성합니다.
+ ///
+ public static string BuildDefaultFileName(SimulationResult result, SimulationReportFormat format)
+ {
+ string baseName = result != null
+ ? $"{result.BuildLabel}_{result.RuleName}_{result.RotationName}"
+ : "BuildSimulation";
+
+ string extension = format == SimulationReportFormat.Csv ? "csv" : "md";
+ return $"{SanitizeFileName(baseName)}.{extension}";
+ }
+
+ private static void AppendCsvRow(
+ StringBuilder builder,
+ SimulationResult result,
+ SimulationSkillBreakdown breakdown,
+ string warnings)
+ {
+ builder.Append(EscapeCsv(result.BuildLabel));
+ builder.Append(',');
+ builder.Append(EscapeCsv(result.RuleName));
+ builder.Append(',');
+ builder.Append(EscapeCsv(result.RotationName));
+ builder.Append(',');
+ builder.Append(result.DurationSeconds.ToString("0.##"));
+ builder.Append(',');
+ builder.Append(result.TotalDamage.ToString("0.##"));
+ builder.Append(',');
+ builder.Append(result.AverageDps.ToString("0.##"));
+ builder.Append(',');
+ builder.Append(result.TotalManaUsed.ToString("0.##"));
+ builder.Append(',');
+ builder.Append(result.AverageManaPerSecond.ToString("0.##"));
+ builder.Append(',');
+ builder.Append(result.FirstCycleEndTime >= 0f ? result.FirstCycleEndTime.ToString("0.##") : string.Empty);
+ builder.Append(',');
+ builder.Append(EscapeCsv(breakdown != null ? breakdown.SkillName : string.Empty));
+ builder.Append(',');
+ builder.Append(breakdown != null ? breakdown.CastCount.ToString() : string.Empty);
+ builder.Append(',');
+ builder.Append(breakdown != null ? breakdown.TotalDamage.ToString("0.##") : string.Empty);
+ builder.Append(',');
+ builder.Append(EscapeCsv(warnings));
+ builder.AppendLine();
+ }
+
+ private static string EscapeCsv(string value)
+ {
+ if (string.IsNullOrEmpty(value))
+ return string.Empty;
+
+ bool needsQuotes = value.Contains(",") || value.Contains("\"") || value.Contains("\n") || value.Contains("\r");
+ if (!needsQuotes)
+ return value;
+
+ return $"\"{value.Replace("\"", "\"\"")}\"";
+ }
+
+ private static string SanitizeFileName(string value)
+ {
+ if (string.IsNullOrWhiteSpace(value))
+ return "BuildSimulation";
+
+ string sanitized = value;
+ char[] invalidChars = System.IO.Path.GetInvalidFileNameChars();
+ for (int i = 0; i < invalidChars.Length; i++)
+ {
+ sanitized = sanitized.Replace(invalidChars[i], '_');
+ }
+
+ return sanitized.Replace(' ', '_');
+ }
+ }
+}
diff --git a/Assets/_Game/Scripts/Combat/Simulation/SimulationReportUtility.cs.meta b/Assets/_Game/Scripts/Combat/Simulation/SimulationReportUtility.cs.meta
new file mode 100644
index 00000000..73a4c9d4
--- /dev/null
+++ b/Assets/_Game/Scripts/Combat/Simulation/SimulationReportUtility.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 47296b792ba84ab4cbf71d24c403fc0f
\ No newline at end of file
diff --git a/Assets/_Game/Scripts/Combat/Simulation/SimulationResult.cs b/Assets/_Game/Scripts/Combat/Simulation/SimulationResult.cs
new file mode 100644
index 00000000..4e3d30e4
--- /dev/null
+++ b/Assets/_Game/Scripts/Combat/Simulation/SimulationResult.cs
@@ -0,0 +1,152 @@
+using System.Collections.Generic;
+using System.Text;
+
+using UnityEngine;
+
+namespace Colosseum.Combat.Simulation
+{
+ ///
+ /// 스킬별 기여도 요약입니다.
+ ///
+ [System.Serializable]
+ public sealed class SimulationSkillBreakdown
+ {
+ [SerializeField] private string skillName;
+ [Min(0)] [SerializeField] private int castCount;
+ [Min(0f)] [SerializeField] private float totalDamage;
+
+ public string SkillName => skillName;
+ public int CastCount => castCount;
+ public float TotalDamage => totalDamage;
+
+ public SimulationSkillBreakdown(string skillName, int castCount, float totalDamage)
+ {
+ this.skillName = skillName ?? "Unknown";
+ this.castCount = Mathf.Max(0, castCount);
+ this.totalDamage = Mathf.Max(0f, totalDamage);
+ }
+ }
+
+ ///
+ /// 허수아비 계산 시뮬레이터 결과입니다.
+ ///
+ [System.Serializable]
+ public sealed class SimulationResult
+ {
+ [SerializeField] private string summaryLine = string.Empty;
+ [TextArea(8, 30)]
+ [SerializeField] private string detailText = string.Empty;
+ [SerializeField] private string buildLabel = string.Empty;
+ [SerializeField] private string ruleName = string.Empty;
+ [SerializeField] private string rotationName = string.Empty;
+ [Min(0f)] [SerializeField] private float durationSeconds;
+ [Min(0f)] [SerializeField] private float totalDamage;
+ [Min(0f)] [SerializeField] private float averageDps;
+ [Min(0f)] [SerializeField] private float totalManaUsed;
+ [Min(0f)] [SerializeField] private float averageManaPerSecond;
+ [SerializeField] private float firstCycleEndTime = -1f;
+ [SerializeField] private List skillBreakdowns = new List();
+ [SerializeField] private List warnings = new List();
+
+ public string SummaryLine => summaryLine;
+ public string DetailText => detailText;
+ public string BuildLabel => buildLabel;
+ public string RuleName => ruleName;
+ public string RotationName => rotationName;
+ public float DurationSeconds => durationSeconds;
+ public float TotalDamage => totalDamage;
+ public float AverageDps => averageDps;
+ public float TotalManaUsed => totalManaUsed;
+ public float AverageManaPerSecond => averageManaPerSecond;
+ public float FirstCycleEndTime => firstCycleEndTime;
+ public IReadOnlyList SkillBreakdowns => skillBreakdowns;
+ public IReadOnlyList Warnings => warnings;
+
+ public void FinalizeResult(
+ string buildLabel,
+ string ruleName,
+ string rotationName,
+ float durationSeconds,
+ float totalDamage,
+ float totalManaUsed,
+ float firstCycleEndTime,
+ List breakdowns,
+ List warnings)
+ {
+ this.buildLabel = buildLabel ?? string.Empty;
+ this.ruleName = ruleName ?? string.Empty;
+ this.rotationName = rotationName ?? string.Empty;
+ this.durationSeconds = Mathf.Max(0f, durationSeconds);
+ this.totalDamage = Mathf.Max(0f, totalDamage);
+ averageDps = this.durationSeconds > 0f ? this.totalDamage / this.durationSeconds : 0f;
+ this.totalManaUsed = Mathf.Max(0f, totalManaUsed);
+ averageManaPerSecond = this.durationSeconds > 0f ? this.totalManaUsed / this.durationSeconds : 0f;
+ this.firstCycleEndTime = firstCycleEndTime;
+
+ skillBreakdowns = breakdowns ?? new List();
+ this.warnings = warnings ?? new List();
+ summaryLine = BuildSummaryLine();
+ detailText = BuildDetailText();
+ }
+
+ private string BuildSummaryLine()
+ {
+ StringBuilder builder = new StringBuilder();
+ builder.Append("[BuildSimulation] ");
+ builder.Append(buildLabel);
+ builder.Append(" | Rule=");
+ builder.Append(ruleName);
+ builder.Append(" | Rotation=");
+ builder.Append(rotationName);
+ builder.Append(" | Dmg=");
+ builder.Append(totalDamage.ToString("0.##"));
+ builder.Append(" | DPS=");
+ builder.Append(averageDps.ToString("0.##"));
+ builder.Append(" | Mana=");
+ builder.Append(totalManaUsed.ToString("0.##"));
+ builder.Append(" | Cycle=");
+ builder.Append(firstCycleEndTime >= 0f ? firstCycleEndTime.ToString("0.##") + "s" : "미완료");
+ return builder.ToString();
+ }
+
+ private string BuildDetailText()
+ {
+ StringBuilder builder = new StringBuilder();
+ builder.AppendLine(summaryLine);
+ builder.Append("Duration=");
+ builder.Append(durationSeconds.ToString("0.##"));
+ builder.Append("s | ManaPerSec=");
+ builder.Append(averageManaPerSecond.ToString("0.##"));
+ builder.AppendLine();
+
+ if (skillBreakdowns.Count > 0)
+ {
+ builder.AppendLine("Skill Breakdown");
+ for (int i = 0; i < skillBreakdowns.Count; i++)
+ {
+ SimulationSkillBreakdown entry = skillBreakdowns[i];
+ builder.Append("- ");
+ builder.Append(entry.SkillName);
+ builder.Append(" | Cast=");
+ builder.Append(entry.CastCount);
+ builder.Append(" | Dmg=");
+ builder.Append(entry.TotalDamage.ToString("0.##"));
+ builder.AppendLine();
+ }
+ }
+
+ if (warnings.Count > 0)
+ {
+ builder.AppendLine("Warnings");
+ for (int i = 0; i < warnings.Count; i++)
+ {
+ builder.Append("- ");
+ builder.Append(warnings[i]);
+ builder.AppendLine();
+ }
+ }
+
+ return builder.ToString().TrimEnd();
+ }
+ }
+}
diff --git a/Assets/_Game/Scripts/Combat/Simulation/SimulationResult.cs.meta b/Assets/_Game/Scripts/Combat/Simulation/SimulationResult.cs.meta
new file mode 100644
index 00000000..547bb347
--- /dev/null
+++ b/Assets/_Game/Scripts/Combat/Simulation/SimulationResult.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: bbfa42a3c08321246b85393c07df6a8e
\ No newline at end of file
diff --git a/Assets/_Game/Scripts/Combat/Simulation/SimulationRuleSet.cs b/Assets/_Game/Scripts/Combat/Simulation/SimulationRuleSet.cs
new file mode 100644
index 00000000..e94a880b
--- /dev/null
+++ b/Assets/_Game/Scripts/Combat/Simulation/SimulationRuleSet.cs
@@ -0,0 +1,38 @@
+using UnityEngine;
+
+namespace Colosseum.Combat.Simulation
+{
+ ///
+ /// 허수아비 계산 시뮬레이터의 고정 가정입니다.
+ ///
+ [System.Serializable]
+ public class SimulationRuleSet
+ {
+ [Header("Label")]
+ [SerializeField] private string ruleName = "Dummy10s";
+
+ [Header("Timeline")]
+ [Min(0.1f)] [SerializeField] private float durationSeconds = 10f;
+ [Min(1)] [SerializeField] private int targetCount = 1;
+ [Min(0f)] [SerializeField] private float movementLossSecondsPerCast = 0f;
+ [Min(0f)] [SerializeField] private float manaRegenPerSecond = 0f;
+
+ public string RuleName => string.IsNullOrWhiteSpace(ruleName) ? "Rule" : ruleName.Trim();
+ public float DurationSeconds => durationSeconds;
+ public int TargetCount => Mathf.Max(1, targetCount);
+ public float MovementLossSecondsPerCast => Mathf.Max(0f, movementLossSecondsPerCast);
+ public float ManaRegenPerSecond => Mathf.Max(0f, manaRegenPerSecond);
+
+ ///
+ /// 룰셋 값을 한 번에 설정합니다.
+ ///
+ public void Configure(string ruleName, float durationSeconds, int targetCount, float movementLossSecondsPerCast, float manaRegenPerSecond)
+ {
+ this.ruleName = ruleName ?? string.Empty;
+ this.durationSeconds = Mathf.Max(0.1f, durationSeconds);
+ this.targetCount = Mathf.Max(1, targetCount);
+ this.movementLossSecondsPerCast = Mathf.Max(0f, movementLossSecondsPerCast);
+ this.manaRegenPerSecond = Mathf.Max(0f, manaRegenPerSecond);
+ }
+ }
+}
diff --git a/Assets/_Game/Scripts/Combat/Simulation/SimulationRuleSet.cs.meta b/Assets/_Game/Scripts/Combat/Simulation/SimulationRuleSet.cs.meta
new file mode 100644
index 00000000..3087e250
--- /dev/null
+++ b/Assets/_Game/Scripts/Combat/Simulation/SimulationRuleSet.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: d870cf57849e6f447b843cccdc235d42
\ No newline at end of file
diff --git a/Assets/_Game/Scripts/Editor/BuildSimulationBatchCommands.cs b/Assets/_Game/Scripts/Editor/BuildSimulationBatchCommands.cs
new file mode 100644
index 00000000..2d88e179
--- /dev/null
+++ b/Assets/_Game/Scripts/Editor/BuildSimulationBatchCommands.cs
@@ -0,0 +1,176 @@
+using System.Collections.Generic;
+using System.IO;
+using System.Text;
+
+using UnityEditor;
+using UnityEngine;
+
+using Colosseum.Combat.Simulation;
+using Colosseum.Passives;
+using Colosseum.Skills;
+
+namespace Colosseum.Editor
+{
+ ///
+ /// 허수아비 계산 시뮬레이터의 배치 조사 실행 메뉴입니다.
+ ///
+ public static class BuildSimulationBatchCommands
+ {
+ private const string PlayerSkillFolder = "Assets/_Game/Data/Skills";
+ private const string PlayerGemFolder = "Assets/_Game/Data/SkillGems";
+ private const string PlayerPassiveFolder = "Assets/_Game/Data/Passives/Nodes";
+ private const string PlayerPassiveTreePath = "Assets/_Game/Data/Passives/Data_PassiveTree_Player_Prototype.asset";
+ private const string ReportFolder = "BuildSimulationReports";
+ private static readonly HashSet DisabledPlayerSkillPaths = new HashSet
+ {
+ "Assets/_Game/Data/Skills/Data_Skill_Player_회전베기.asset",
+ "Assets/_Game/Data/Skills/Data_Skill_Player_돌진.asset",
+ };
+
+ ///
+ /// 현재 기준 플레이어 단일 슬롯 데미지 전수 조사를 실행합니다.
+ ///
+ [MenuItem("Tools/Colosseum/Simulation/Run Player Damage Sweep")]
+ private static void RunPlayerDamageSweep()
+ {
+ PassiveTreeData passiveTree = AssetDatabase.LoadAssetAtPath(PlayerPassiveTreePath);
+ if (passiveTree == null)
+ {
+ Debug.LogError($"[BuildSimulationBatch] 패시브 트리를 찾지 못했습니다. | Path={PlayerPassiveTreePath}");
+ return;
+ }
+
+ BuildSimulationInput template = new BuildSimulationInput();
+ template.SetBuildName("플레이어_단일슬롯_데미지전수");
+
+ SimulationRuleSet ruleSet = new SimulationRuleSet();
+ ruleSet.Configure("PlayerDamageSweep10s", 10f, 1, 0f, 0f);
+
+ RotationPolicy rotationPolicy = new RotationPolicy();
+ rotationPolicy.Configure("Slot0Only", new[] { 0 }, false, 0, false, 5, 0f);
+
+ SimulationCombinationSpec combinationSpec = new SimulationCombinationSpec();
+ combinationSpec.Configure(
+ "PlayerDamageSweep",
+ combineSkills: true,
+ combineGems: true,
+ combinePassives: true,
+ activeSlotIndices: new[] { 0 },
+ allowDuplicateSkills: false,
+ includeEmptyGemSet: true,
+ passiveTree: passiveTree,
+ includeEmptyPassiveSelection: true,
+ maxPassiveNodeCount: 0,
+ maxBuildCount: 50000);
+
+ List warnings = new List();
+ List skills = LoadPlayerSkills(warnings);
+ List gems = LoadAssetsInFolder(PlayerGemFolder);
+ List passiveNodes = LoadAssetsInFolder(PlayerPassiveFolder);
+
+ List builds = SimulationCombinationGenerator.GenerateBuilds(
+ template,
+ combinationSpec,
+ skills,
+ gems,
+ passiveNodes,
+ warnings,
+ out bool truncated);
+
+ SimulationBatchResult result = SimulationBatchRunner.Run(
+ combinationSpec.BatchName,
+ builds,
+ ruleSet,
+ rotationPolicy,
+ warnings,
+ truncated);
+
+ string reportDirectory = Path.Combine(Path.GetDirectoryName(Application.dataPath) ?? Application.dataPath, ReportFolder);
+ Directory.CreateDirectory(reportDirectory);
+
+ string timestamp = System.DateTime.Now.ToString("yyyyMMdd_HHmmss");
+ string markdownPath = Path.Combine(reportDirectory, $"PlayerDamageSweep_{timestamp}.md");
+ string csvPath = Path.Combine(reportDirectory, $"PlayerDamageSweep_{timestamp}.csv");
+
+ File.WriteAllText(markdownPath, SimulationBatchReportUtility.BuildMarkdown(result), Encoding.UTF8);
+ File.WriteAllText(csvPath, SimulationBatchReportUtility.BuildCsv(result), Encoding.UTF8);
+
+ Debug.Log(BuildSummary(result, markdownPath, csvPath));
+ }
+
+ private static List LoadPlayerSkills(List warnings)
+ {
+ List skills = LoadAssetsInFolder(PlayerSkillFolder);
+ List filtered = new List();
+ for (int i = 0; i < skills.Count; i++)
+ {
+ SkillData skill = skills[i];
+ if (skill == null || !skill.name.StartsWith("Data_Skill_Player_", System.StringComparison.Ordinal))
+ continue;
+
+ string assetPath = AssetDatabase.GetAssetPath(skill);
+ if (DisabledPlayerSkillPaths.Contains(assetPath))
+ {
+ warnings?.Add($"애니메이션 미구현 스킬 제외: {skill.SkillName}");
+ continue;
+ }
+
+ filtered.Add(skill);
+ }
+
+ return filtered;
+ }
+
+ private static List LoadAssetsInFolder(string folderPath) where T : Object
+ {
+ List assets = new List();
+ string[] guids = AssetDatabase.FindAssets($"t:{typeof(T).Name}", new[] { folderPath });
+ for (int i = 0; i < guids.Length; i++)
+ {
+ string assetPath = AssetDatabase.GUIDToAssetPath(guids[i]);
+ T asset = AssetDatabase.LoadAssetAtPath(assetPath);
+ if (asset != null)
+ assets.Add(asset);
+ }
+
+ return assets;
+ }
+
+ private static string BuildSummary(SimulationBatchResult result, string markdownPath, string csvPath)
+ {
+ StringBuilder builder = new StringBuilder();
+ builder.Append("[BuildSimulationBatch] 플레이어 단일 슬롯 데미지 전수 조사 완료");
+ builder.Append(" | Builds=");
+ builder.Append(result.GeneratedBuildCount);
+ builder.Append(" | Truncated=");
+ builder.Append(result.Truncated);
+ builder.Append(" | Markdown=");
+ builder.Append(markdownPath);
+ builder.Append(" | CSV=");
+ builder.Append(csvPath);
+
+ int topCount = Mathf.Min(10, result.Entries.Count);
+ for (int i = 0; i < topCount; i++)
+ {
+ SimulationBatchEntry entry = result.Entries[i];
+ SimulationResult simulation = entry != null ? entry.Result : null;
+ if (simulation == null)
+ continue;
+
+ builder.AppendLine();
+ builder.Append('#');
+ builder.Append(i + 1);
+ builder.Append(' ');
+ builder.Append(entry.BuildLabel);
+ builder.Append(" | DPS=");
+ builder.Append(simulation.AverageDps.ToString("0.##"));
+ builder.Append(" | Dmg=");
+ builder.Append(simulation.TotalDamage.ToString("0.##"));
+ builder.Append(" | Mana=");
+ builder.Append(simulation.TotalManaUsed.ToString("0.##"));
+ }
+
+ return builder.ToString();
+ }
+ }
+}
diff --git a/Assets/_Game/Scripts/Editor/BuildSimulationBatchCommands.cs.meta b/Assets/_Game/Scripts/Editor/BuildSimulationBatchCommands.cs.meta
new file mode 100644
index 00000000..9073d453
--- /dev/null
+++ b/Assets/_Game/Scripts/Editor/BuildSimulationBatchCommands.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 82f1fba75c8e4f040ad82e2aa0096063
\ No newline at end of file
diff --git a/Assets/_Game/Scripts/Editor/BuildSimulationBatchWindow.cs b/Assets/_Game/Scripts/Editor/BuildSimulationBatchWindow.cs
new file mode 100644
index 00000000..e3b88283
--- /dev/null
+++ b/Assets/_Game/Scripts/Editor/BuildSimulationBatchWindow.cs
@@ -0,0 +1,287 @@
+using System.Collections.Generic;
+using System.IO;
+using UnityEditor;
+using UnityEngine;
+
+using Colosseum.Combat.Simulation;
+using Colosseum.Passives;
+using Colosseum.Skills;
+
+namespace Colosseum.Editor
+{
+ ///
+ /// 허수아비 계산 시뮬레이터의 전체 조합 배치 실행 창입니다.
+ ///
+ public sealed class BuildSimulationBatchWindow : EditorWindow
+ {
+ [SerializeField] private BuildSimulationInput templateInput = new BuildSimulationInput();
+ [SerializeField] private SimulationRuleSet ruleSet = new SimulationRuleSet();
+ [SerializeField] private RotationPolicy rotationPolicy = new RotationPolicy();
+ [SerializeField] private SimulationCombinationSpec combinationSpec = new SimulationCombinationSpec();
+ [SerializeField] private string skillSearchFolder = "Assets/_Game/Data/Skills";
+ [SerializeField] private string gemSearchFolder = "Assets/_Game/Data/SkillGems";
+ [SerializeField] private string passiveNodeSearchFolder = "Assets/_Game/Data/Passives";
+ [SerializeField] private SimulationBatchResult lastBatchResult;
+ [SerializeField] private bool previewAsCsv;
+
+ private Vector2 scrollPosition;
+
+ [MenuItem("Tools/Colosseum/Simulation/Build Simulation Batch Window")]
+ private static void Open()
+ {
+ BuildSimulationBatchWindow window = GetWindow("Build Simulation Batch");
+ window.minSize = new Vector2(620f, 720f);
+ window.Show();
+ }
+
+ private void OnGUI()
+ {
+ scrollPosition = EditorGUILayout.BeginScrollView(scrollPosition);
+
+ DrawTemplateSection();
+ EditorGUILayout.Space(12f);
+ DrawRuleSection();
+ EditorGUILayout.Space(12f);
+ DrawRotationSection();
+ EditorGUILayout.Space(12f);
+ DrawCombinationSection();
+ EditorGUILayout.Space(12f);
+ DrawRunSection();
+ EditorGUILayout.Space(12f);
+ DrawExportSection();
+ EditorGUILayout.Space(12f);
+ DrawResultSection();
+
+ EditorGUILayout.EndScrollView();
+ }
+
+ private void DrawTemplateSection()
+ {
+ EditorGUILayout.LabelField("Template", EditorStyles.boldLabel);
+ EditorGUILayout.HelpBox("조합 생성에서 비활성화한 축은 이 템플릿 입력을 사용합니다.", MessageType.None);
+
+ SerializedObject serializedWindow = new SerializedObject(this);
+ SerializedProperty buildProperty = serializedWindow.FindProperty(nameof(templateInput));
+
+ DrawProperty(buildProperty, "buildName");
+ DrawProperty(buildProperty, "strength");
+ DrawProperty(buildProperty, "dexterity");
+ DrawProperty(buildProperty, "intelligence");
+ DrawProperty(buildProperty, "vitality");
+ DrawProperty(buildProperty, "wisdom");
+ DrawProperty(buildProperty, "spirit");
+ DrawProperty(buildProperty, "weapon");
+ DrawProperty(buildProperty, "directSkillSlots", true);
+ DrawProperty(buildProperty, "passiveTree");
+ DrawProperty(buildProperty, "selectedPassiveNodes", true);
+ DrawProperty(buildProperty, "passivePreset");
+ DrawProperty(buildProperty, "loadoutPreset");
+
+ serializedWindow.ApplyModifiedProperties();
+ }
+
+ private void DrawRuleSection()
+ {
+ EditorGUILayout.LabelField("Simulation", EditorStyles.boldLabel);
+ SerializedObject serializedWindow = new SerializedObject(this);
+ SerializedProperty ruleProperty = serializedWindow.FindProperty(nameof(ruleSet));
+
+ DrawProperty(ruleProperty, "ruleName");
+ DrawProperty(ruleProperty, "durationSeconds");
+ DrawProperty(ruleProperty, "targetCount");
+ DrawProperty(ruleProperty, "movementLossSecondsPerCast");
+ DrawProperty(ruleProperty, "manaRegenPerSecond");
+
+ serializedWindow.ApplyModifiedProperties();
+ }
+
+ private void DrawRotationSection()
+ {
+ EditorGUILayout.LabelField("Rotation", EditorStyles.boldLabel);
+ SerializedObject serializedWindow = new SerializedObject(this);
+ SerializedProperty rotationProperty = serializedWindow.FindProperty(nameof(rotationPolicy));
+
+ DrawProperty(rotationProperty, "policyName");
+ DrawProperty(rotationProperty, "prioritySlots", true);
+ DrawProperty(rotationProperty, "useFallbackSlot");
+ DrawProperty(rotationProperty, "fallbackSlotIndex");
+ DrawProperty(rotationProperty, "delayHighPowerSkillUntilTime");
+ if (rotationPolicy.DelayHighPowerSkillUntilTime)
+ {
+ DrawProperty(rotationProperty, "highPowerSlotIndex");
+ DrawProperty(rotationProperty, "highPowerFirstUseTime");
+ }
+
+ serializedWindow.ApplyModifiedProperties();
+ }
+
+ private void DrawCombinationSection()
+ {
+ EditorGUILayout.LabelField("Combination", EditorStyles.boldLabel);
+ SerializedObject serializedWindow = new SerializedObject(this);
+ SerializedProperty specProperty = serializedWindow.FindProperty(nameof(combinationSpec));
+
+ DrawProperty(specProperty, "batchName");
+ DrawProperty(specProperty, "combineSkills");
+ DrawProperty(specProperty, "combineGems");
+ DrawProperty(specProperty, "combinePassives");
+ DrawProperty(specProperty, "activeSlotIndices", true);
+ DrawProperty(specProperty, "allowDuplicateSkills");
+ DrawProperty(specProperty, "includeEmptyGemSet");
+ DrawProperty(specProperty, "passiveTree");
+ DrawProperty(specProperty, "includeEmptyPassiveSelection");
+ DrawProperty(specProperty, "maxPassiveNodeCount");
+ DrawProperty(specProperty, "maxBuildCount");
+
+ skillSearchFolder = EditorGUILayout.TextField("Skill Folder", skillSearchFolder);
+ gemSearchFolder = EditorGUILayout.TextField("Gem Folder", gemSearchFolder);
+ passiveNodeSearchFolder = EditorGUILayout.TextField("Passive Folder", passiveNodeSearchFolder);
+
+ serializedWindow.ApplyModifiedProperties();
+ }
+
+ private void DrawRunSection()
+ {
+ EditorGUILayout.LabelField("Run", EditorStyles.boldLabel);
+ EditorGUILayout.HelpBox("전수 조합은 매우 빠르게 폭증합니다. 폴더 범위를 줄이고 Max Build Count를 적절히 설정하는 편이 안전합니다.", MessageType.Warning);
+
+ if (GUILayout.Button("Run Batch Simulation", GUILayout.Height(32f)))
+ {
+ RunBatchSimulation();
+ }
+ }
+
+ private void DrawExportSection()
+ {
+ EditorGUILayout.LabelField("Export", EditorStyles.boldLabel);
+ if (lastBatchResult == null)
+ {
+ EditorGUILayout.HelpBox("배치 실행 후 Markdown/CSV로 복사하거나 저장할 수 있습니다.", MessageType.None);
+ return;
+ }
+
+ using (new EditorGUILayout.HorizontalScope())
+ {
+ if (GUILayout.Button("Copy Markdown"))
+ EditorGUIUtility.systemCopyBuffer = SimulationBatchReportUtility.BuildMarkdown(lastBatchResult);
+
+ if (GUILayout.Button("Copy CSV"))
+ EditorGUIUtility.systemCopyBuffer = SimulationBatchReportUtility.BuildCsv(lastBatchResult);
+ }
+
+ using (new EditorGUILayout.HorizontalScope())
+ {
+ if (GUILayout.Button("Save Markdown"))
+ SaveBatchReport(false);
+
+ if (GUILayout.Button("Save CSV"))
+ SaveBatchReport(true);
+ }
+ }
+
+ private void DrawResultSection()
+ {
+ EditorGUILayout.LabelField("Preview", EditorStyles.boldLabel);
+ if (lastBatchResult == null)
+ {
+ EditorGUILayout.HelpBox("아직 배치 결과가 없습니다.", MessageType.None);
+ return;
+ }
+
+ EditorGUILayout.LabelField("Generated", lastBatchResult.GeneratedBuildCount.ToString());
+ EditorGUILayout.LabelField("Truncated", lastBatchResult.Truncated ? "Yes" : "No");
+ previewAsCsv = EditorGUILayout.Toggle("Preview CSV", previewAsCsv);
+
+ string previewText = previewAsCsv
+ ? SimulationBatchReportUtility.BuildCsv(lastBatchResult)
+ : SimulationBatchReportUtility.BuildMarkdown(lastBatchResult);
+
+ EditorGUILayout.TextArea(previewText, GUILayout.MinHeight(320f));
+ }
+
+ private void RunBatchSimulation()
+ {
+ List warnings = new List();
+
+ List skillPool = combinationSpec.CombineSkills
+ ? LoadAssetsInFolder(skillSearchFolder)
+ : new List();
+ List gemPool = combinationSpec.CombineGems
+ ? LoadAssetsInFolder(gemSearchFolder)
+ : new List();
+ List passivePool = combinationSpec.CombinePassives
+ ? LoadAssetsInFolder(passiveNodeSearchFolder)
+ : new List();
+
+ if (combinationSpec.CombineSkills && skillPool.Count == 0)
+ warnings.Add($"스킬 폴더에서 SkillData를 찾지 못했습니다: {skillSearchFolder}");
+ if (combinationSpec.CombineGems && gemPool.Count == 0)
+ warnings.Add($"젬 폴더에서 SkillGemData를 찾지 못했습니다: {gemSearchFolder}");
+ if (combinationSpec.CombinePassives && passivePool.Count == 0 && combinationSpec.PassiveTree != null)
+ warnings.Add($"패시브 폴더에서 PassiveNodeData를 찾지 못했습니다: {passiveNodeSearchFolder}");
+
+ List builds = SimulationCombinationGenerator.GenerateBuilds(
+ templateInput,
+ combinationSpec,
+ skillPool,
+ gemPool,
+ passivePool,
+ warnings,
+ out bool truncated);
+
+ lastBatchResult = SimulationBatchRunner.Run(
+ combinationSpec.BatchName,
+ builds,
+ ruleSet,
+ rotationPolicy,
+ warnings,
+ truncated);
+
+ Debug.Log($"[BuildSimulationBatch] 배치 실행 완료 | Builds={lastBatchResult.GeneratedBuildCount} | Truncated={lastBatchResult.Truncated}");
+ }
+
+ private void SaveBatchReport(bool csv)
+ {
+ string defaultName = SimulationBatchReportUtility.BuildDefaultFileName(lastBatchResult, csv);
+ string path = EditorUtility.SaveFilePanel(
+ "배치 시뮬레이션 결과 저장",
+ Application.dataPath,
+ defaultName,
+ csv ? "csv" : "md");
+
+ if (string.IsNullOrWhiteSpace(path))
+ return;
+
+ string contents = csv
+ ? SimulationBatchReportUtility.BuildCsv(lastBatchResult)
+ : SimulationBatchReportUtility.BuildMarkdown(lastBatchResult);
+ File.WriteAllText(path, contents);
+ Debug.Log($"[BuildSimulationBatch] 결과 파일을 저장했습니다. | Path={path}");
+ }
+
+ private static List LoadAssetsInFolder(string folderPath) where T : UnityEngine.Object
+ {
+ List assets = new List();
+ if (string.IsNullOrWhiteSpace(folderPath))
+ return assets;
+
+ string[] guids = AssetDatabase.FindAssets($"t:{typeof(T).Name}", new[] { folderPath });
+ for (int i = 0; i < guids.Length; i++)
+ {
+ string assetPath = AssetDatabase.GUIDToAssetPath(guids[i]);
+ T asset = AssetDatabase.LoadAssetAtPath(assetPath);
+ if (asset != null)
+ assets.Add(asset);
+ }
+
+ return assets;
+ }
+
+ private static void DrawProperty(SerializedProperty root, string relativePath, bool includeChildren = false)
+ {
+ SerializedProperty property = root.FindPropertyRelative(relativePath);
+ if (property != null)
+ EditorGUILayout.PropertyField(property, includeChildren);
+ }
+ }
+}
diff --git a/Assets/_Game/Scripts/Editor/BuildSimulationBatchWindow.cs.meta b/Assets/_Game/Scripts/Editor/BuildSimulationBatchWindow.cs.meta
new file mode 100644
index 00000000..b4905cf5
--- /dev/null
+++ b/Assets/_Game/Scripts/Editor/BuildSimulationBatchWindow.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 0ee99b72045222a418924f50d0e5f4ab
\ No newline at end of file
diff --git a/Assets/_Game/Scripts/Editor/BuildSimulationWindow.cs b/Assets/_Game/Scripts/Editor/BuildSimulationWindow.cs
new file mode 100644
index 00000000..66269952
--- /dev/null
+++ b/Assets/_Game/Scripts/Editor/BuildSimulationWindow.cs
@@ -0,0 +1,219 @@
+using System.IO;
+
+using UnityEditor;
+using UnityEngine;
+
+using Colosseum.Combat.Simulation;
+
+namespace Colosseum.Editor
+{
+ ///
+ /// 허수아비 계산 시뮬레이터 실행 창입니다.
+ ///
+ public sealed class BuildSimulationWindow : EditorWindow
+ {
+ [SerializeField] private BuildSimulationInput buildInput = new BuildSimulationInput();
+ [SerializeField] private SimulationRuleSet ruleSet = new SimulationRuleSet();
+ [SerializeField] private RotationPolicy rotationPolicy = new RotationPolicy();
+ [SerializeField] private SimulationResult lastResult;
+ [SerializeField] private SimulationReportFormat previewFormat = SimulationReportFormat.DetailText;
+
+ private Vector2 scrollPosition;
+
+ [MenuItem("Tools/Colosseum/Simulation/Build Simulation Window")]
+ private static void Open()
+ {
+ BuildSimulationWindow window = GetWindow("Build Simulation");
+ window.minSize = new Vector2(520f, 640f);
+ window.Show();
+ }
+
+ private void OnGUI()
+ {
+ scrollPosition = EditorGUILayout.BeginScrollView(scrollPosition);
+
+ DrawBuildSection();
+ EditorGUILayout.Space(12f);
+ DrawRuleSection();
+ EditorGUILayout.Space(12f);
+ DrawRotationSection();
+ EditorGUILayout.Space(12f);
+ DrawRunSection();
+ EditorGUILayout.Space(12f);
+ DrawExportSection();
+ EditorGUILayout.Space(12f);
+ DrawResultSection();
+
+ EditorGUILayout.EndScrollView();
+ }
+
+ private void DrawBuildSection()
+ {
+ EditorGUILayout.LabelField("Build", EditorStyles.boldLabel);
+ SerializedObject serializedWindow = new SerializedObject(this);
+ SerializedProperty buildProperty = serializedWindow.FindProperty(nameof(buildInput));
+
+ DrawProperty(buildProperty, "buildName");
+ DrawProperty(buildProperty, "strength");
+ DrawProperty(buildProperty, "dexterity");
+ DrawProperty(buildProperty, "intelligence");
+ DrawProperty(buildProperty, "vitality");
+ DrawProperty(buildProperty, "wisdom");
+ DrawProperty(buildProperty, "spirit");
+ DrawProperty(buildProperty, "weapon");
+ DrawProperty(buildProperty, "directSkillSlots", true);
+ DrawProperty(buildProperty, "passiveTree");
+ DrawProperty(buildProperty, "selectedPassiveNodes", true);
+ DrawProperty(buildProperty, "passivePreset");
+ DrawProperty(buildProperty, "loadoutPreset");
+
+ serializedWindow.ApplyModifiedProperties();
+ }
+
+ private void DrawRuleSection()
+ {
+ EditorGUILayout.LabelField("Simulation", EditorStyles.boldLabel);
+ SerializedObject serializedWindow = new SerializedObject(this);
+ SerializedProperty ruleProperty = serializedWindow.FindProperty(nameof(ruleSet));
+
+ DrawProperty(ruleProperty, "ruleName");
+ DrawProperty(ruleProperty, "durationSeconds");
+ DrawProperty(ruleProperty, "targetCount");
+ DrawProperty(ruleProperty, "movementLossSecondsPerCast");
+ DrawProperty(ruleProperty, "manaRegenPerSecond");
+
+ serializedWindow.ApplyModifiedProperties();
+ }
+
+ private void DrawRotationSection()
+ {
+ EditorGUILayout.LabelField("Rotation", EditorStyles.boldLabel);
+ SerializedObject serializedWindow = new SerializedObject(this);
+ SerializedProperty rotationProperty = serializedWindow.FindProperty(nameof(rotationPolicy));
+
+ DrawProperty(rotationProperty, "policyName");
+ DrawProperty(rotationProperty, "prioritySlots", true);
+ DrawProperty(rotationProperty, "useFallbackSlot");
+ DrawProperty(rotationProperty, "fallbackSlotIndex");
+ DrawProperty(rotationProperty, "delayHighPowerSkillUntilTime");
+
+ if (rotationPolicy.DelayHighPowerSkillUntilTime)
+ {
+ DrawProperty(rotationProperty, "highPowerSlotIndex");
+ DrawProperty(rotationProperty, "highPowerFirstUseTime");
+ }
+
+ serializedWindow.ApplyModifiedProperties();
+ }
+
+ private void DrawRunSection()
+ {
+ EditorGUILayout.LabelField("Run", EditorStyles.boldLabel);
+ EditorGUILayout.HelpBox("MVP 범위는 단일 대상 피해 계산입니다. 회복/보호막/위협과 버프성 효과는 이후 단계에서 확장합니다.", MessageType.Info);
+
+ using (new EditorGUI.DisabledScope(buildInput == null || buildInput.LoadoutPreset == null))
+ {
+ if (GUILayout.Button("Run Simulation", GUILayout.Height(32f)))
+ {
+ lastResult = BuildSimulationEngine.Run(buildInput, ruleSet, rotationPolicy);
+ if (lastResult != null && !string.IsNullOrWhiteSpace(lastResult.DetailText))
+ {
+ Debug.Log(lastResult.DetailText);
+ }
+ }
+ }
+ }
+
+ private void DrawResultSection()
+ {
+ EditorGUILayout.LabelField("Preview", EditorStyles.boldLabel);
+
+ if (lastResult == null)
+ {
+ EditorGUILayout.HelpBox("아직 실행 결과가 없습니다.", MessageType.None);
+ return;
+ }
+
+ EditorGUILayout.SelectableLabel(lastResult.SummaryLine, EditorStyles.textField, GUILayout.Height(36f));
+ previewFormat = (SimulationReportFormat)EditorGUILayout.EnumPopup("Format", previewFormat);
+ string previewText = SimulationReportUtility.BuildReport(lastResult, previewFormat);
+ EditorGUILayout.TextArea(previewText, GUILayout.MinHeight(260f));
+ }
+
+ private static void DrawProperty(SerializedProperty root, string relativePath, bool includeChildren = false)
+ {
+ SerializedProperty property = root.FindPropertyRelative(relativePath);
+ if (property != null)
+ {
+ EditorGUILayout.PropertyField(property, includeChildren);
+ }
+ }
+
+ private void DrawExportSection()
+ {
+ EditorGUILayout.LabelField("Export", EditorStyles.boldLabel);
+
+ if (lastResult == null)
+ {
+ EditorGUILayout.HelpBox("실행 결과가 생기면 복사와 저장 버튼이 활성화됩니다.", MessageType.None);
+ return;
+ }
+
+ using (new EditorGUILayout.HorizontalScope())
+ {
+ if (GUILayout.Button("Copy Detail"))
+ {
+ CopyReport(SimulationReportFormat.DetailText);
+ }
+
+ if (GUILayout.Button("Copy Markdown"))
+ {
+ CopyReport(SimulationReportFormat.Markdown);
+ }
+
+ if (GUILayout.Button("Copy CSV"))
+ {
+ CopyReport(SimulationReportFormat.Csv);
+ }
+ }
+
+ using (new EditorGUILayout.HorizontalScope())
+ {
+ if (GUILayout.Button("Save Markdown"))
+ {
+ SaveReport(SimulationReportFormat.Markdown);
+ }
+
+ if (GUILayout.Button("Save CSV"))
+ {
+ SaveReport(SimulationReportFormat.Csv);
+ }
+ }
+ }
+
+ private void CopyReport(SimulationReportFormat format)
+ {
+ string report = SimulationReportUtility.BuildReport(lastResult, format);
+ EditorGUIUtility.systemCopyBuffer = report;
+ Debug.Log($"[BuildSimulation] 결과를 클립보드에 복사했습니다. | Format={format}");
+ }
+
+ private void SaveReport(SimulationReportFormat format)
+ {
+ string extension = format == SimulationReportFormat.Csv ? "csv" : "md";
+ string defaultFileName = SimulationReportUtility.BuildDefaultFileName(lastResult, format);
+ string path = EditorUtility.SaveFilePanel(
+ "시뮬레이션 결과 저장",
+ Application.dataPath,
+ defaultFileName,
+ extension);
+
+ if (string.IsNullOrWhiteSpace(path))
+ return;
+
+ string report = SimulationReportUtility.BuildReport(lastResult, format);
+ File.WriteAllText(path, report);
+ Debug.Log($"[BuildSimulation] 결과 파일을 저장했습니다. | Path={path}");
+ }
+ }
+}
diff --git a/Assets/_Game/Scripts/Editor/BuildSimulationWindow.cs.meta b/Assets/_Game/Scripts/Editor/BuildSimulationWindow.cs.meta
new file mode 100644
index 00000000..12daf85d
--- /dev/null
+++ b/Assets/_Game/Scripts/Editor/BuildSimulationWindow.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: fc6991eb0ed991a439747282181ff086
\ No newline at end of file
diff --git a/Assets/_Game/Scripts/Skills/Effects/DamageEffect.cs b/Assets/_Game/Scripts/Skills/Effects/DamageEffect.cs
index cdde04b3..c2cbfecf 100644
--- a/Assets/_Game/Scripts/Skills/Effects/DamageEffect.cs
+++ b/Assets/_Game/Scripts/Skills/Effects/DamageEffect.cs
@@ -31,6 +31,10 @@ namespace Colosseum.Skills.Effects
[Tooltip("스탯 계수 (1.0 = 100%)")]
[Min(0f)] [SerializeField] private float statScaling = 1f;
+ public float BaseDamage => baseDamage;
+ public DamageType DamageKind => damageType;
+ public float StatScaling => statScaling;
+
protected override void ApplyEffect(GameObject caster, GameObject target)
{
if (target == null) return;