feat: 허수아비 계산 시뮬레이터 추가

- 빌드 입력, 룰셋, 회전 정책, 결과/리포트 모델을 포함한 데미지 계산 시뮬레이터 기반을 추가
- 단일 실행 창과 배치 전수 조사 창, 플레이어 데미지 스윕 메뉴를 추가
- DamageEffect 계산값 접근자를 열어 기존 전투 공식을 시뮬레이터에서 재사용하도록 정리
This commit is contained in:
2026-03-28 15:07:09 +09:00
parent 29cb132d5d
commit 285da31047
30 changed files with 2909 additions and 0 deletions

View File

@@ -0,0 +1,424 @@
using System.Collections.Generic;
using Colosseum.Passives;
using Colosseum.Skills;
namespace Colosseum.Combat.Simulation
{
/// <summary>
/// 스킬/젬/패시브 자산 풀에서 유효한 빌드 조합을 생성합니다.
/// </summary>
public static class SimulationCombinationGenerator
{
/// <summary>
/// 조합 조건에 따라 실제 시뮬레이션 입력 빌드를 생성합니다.
/// </summary>
public static List<BuildSimulationInput> GenerateBuilds(
BuildSimulationInput template,
SimulationCombinationSpec spec,
IReadOnlyList<SkillData> skillPool,
IReadOnlyList<SkillGemData> gemPool,
IReadOnlyList<PassiveNodeData> passiveNodePool,
List<string> warnings,
out bool truncated)
{
warnings ??= new List<string>();
truncated = false;
BuildSimulationInput baseTemplate = template != null ? template.CreateCopy() : new BuildSimulationInput();
List<BuildSimulationInput> results = new List<BuildSimulationInput>();
SimulationCombinationSpec safeSpec = spec ?? new SimulationCombinationSpec();
List<List<PassiveNodeData>> passiveSelections = BuildPassiveSelections(baseTemplate, safeSpec, passiveNodePool, warnings);
List<int> activeSlots = BuildActiveSlots(safeSpec);
Dictionary<SkillData, List<SkillLoadoutEntry>> loadoutCache = new Dictionary<SkillData, List<SkillLoadoutEntry>>();
SkillLoadoutEntry[] workingSlots = new SkillLoadoutEntry[7];
for (int i = 0; i < workingSlots.Length; i++)
{
IReadOnlyList<SkillLoadoutEntry> 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<PassiveNodeData> passiveSelection = passiveSelections[passiveIndex];
GenerateSkillAssignments(
baseTemplate,
safeSpec,
skillPool,
gemPool,
activeSlots,
loadoutCache,
workingSlots,
0,
new HashSet<SkillData>(),
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<SkillData> skillPool,
IReadOnlyList<SkillGemData> gemPool,
IReadOnlyList<int> activeSlots,
Dictionary<SkillData, List<SkillLoadoutEntry>> loadoutCache,
SkillLoadoutEntry[] workingSlots,
int slotCursor,
HashSet<SkillData> usedSkills,
IReadOnlyList<PassiveNodeData> passiveSelection,
List<BuildSimulationInput> results,
List<string> 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<SkillLoadoutEntry> 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<SkillGemData> gemPool,
IReadOnlyList<int> activeSlots,
Dictionary<SkillData, List<SkillLoadoutEntry>> loadoutCache,
SkillLoadoutEntry[] workingSlots,
int slotCursor,
IReadOnlyList<PassiveNodeData> passiveSelection,
List<BuildSimulationInput> results,
List<string> warnings,
ref int buildIndex,
ref bool truncated)
{
int slotIndex = activeSlots[slotCursor];
IReadOnlyList<SkillLoadoutEntry> 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<SkillLoadoutEntry> variants = spec.CombineGems
? GetLoadoutVariants(skill, spec, gemPool, loadoutCache)
: new List<SkillLoadoutEntry> { 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<SkillData>(),
passiveSelection,
results,
warnings,
ref buildIndex,
ref truncated);
if (truncated)
return;
}
}
private static List<SkillLoadoutEntry> GetLoadoutVariants(
SkillData skill,
SimulationCombinationSpec spec,
IReadOnlyList<SkillGemData> gemPool,
Dictionary<SkillData, List<SkillLoadoutEntry>> loadoutCache)
{
if (loadoutCache.TryGetValue(skill, out List<SkillLoadoutEntry> cachedVariants))
return cachedVariants;
List<SkillLoadoutEntry> variants = new List<SkillLoadoutEntry>();
int gemSlotCount = skill != null ? skill.MaxGemSlotCount : 0;
if (skill == null)
return variants;
List<SkillGemData> compatibleGems = new List<SkillGemData>();
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<SkillGemData> selected = new List<SkillGemData>();
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<SkillGemData> compatibleGems,
List<SkillGemData> selected,
int startIndex,
int maxGemCount,
List<SkillLoadoutEntry> 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<List<PassiveNodeData>> BuildPassiveSelections(
BuildSimulationInput template,
SimulationCombinationSpec spec,
IReadOnlyList<PassiveNodeData> passiveNodePool,
List<string> warnings)
{
List<List<PassiveNodeData>> selections = new List<List<PassiveNodeData>>();
if (!spec.CombinePassives)
{
List<PassiveNodeData> currentSelection = new List<PassiveNodeData>();
IReadOnlyList<PassiveNodeData> 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<PassiveNodeData>());
return selections;
}
List<PassiveNodeData> candidates = new List<PassiveNodeData>();
IReadOnlyList<PassiveNodeData> 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<PassiveNodeData> selected = new List<PassiveNodeData>();
if (spec.IncludeEmptyPassiveSelection)
selections.Add(new List<PassiveNodeData>());
CollectPassiveSelections(tree, spec, candidates, selected, 0, selections);
if (selections.Count == 0)
selections.Add(new List<PassiveNodeData>());
return selections;
}
private static void CollectPassiveSelections(
PassiveTreeData tree,
SimulationCombinationSpec spec,
IReadOnlyList<PassiveNodeData> candidates,
List<PassiveNodeData> selected,
int startIndex,
List<List<PassiveNodeData>> 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<PassiveNodeData>(selected));
CollectPassiveSelections(tree, spec, candidates, selected, i + 1, results);
}
selected.RemoveAt(selected.Count - 1);
}
}
private static bool TryValidatePassiveSelection(PassiveTreeData tree, IReadOnlyList<PassiveNodeData> selected)
{
List<string> ids = new List<string>(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<int> BuildActiveSlots(SimulationCombinationSpec spec)
{
List<int> activeSlots = new List<int>();
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;
}
}
}