← 프로젝트 포트폴리오MONSTER TAMING
PROJECT MONSTER TAMING · 기술문서

몬스터를 만드는 도구부터,
파괴되는 전장의 규칙까지.

무엇이 막혔고, 어떻게 풀었는지 적었습니다.

개발자
전찬우
프로젝트
수집형 방치 RPG · Unity 6
기간
1개월 팀 프로젝트
직접 범위
시스템 구조 · 제작 도구 · 전투 · 공성 · 저장
DOCUMENTS · 06

기술문서

현재 문서전체 구조프로젝트 전체 구조와 콘텐츠 부대와 공격 목표를 설명합니다.

00전체 구조

프로젝트 구조와 콘텐츠 실행 흐름

프로젝트 계층과 콘텐츠 실행 수명을 분리했습니다. 아래 두 그림은 각각 누가 누구를 참조하는지, 그리고 한 콘텐츠가 어떻게 시작하고 끝나는지를 보여줍니다.

계층별 참조 관계

Bootstrap구체 구현과 서비스 조립
Features게임 기능
Contents.*개별 콘텐츠
Content Framework입장·실행·종료 계약
Shared공용 Runtime
Core기반 타입·서비스

의존은 아래 기반 계층을 향합니다. Bootstrap만 최상위에서 구체 구현을 조립합니다.

실행 수명

  1. Feature Request콘텐츠와 파티 선택
  2. ContentFlow중복 입장·Phase 관리
  3. StartDataFactory콘텐츠 입력 생성
  4. Hosted / Separate Scene같은 계약, 다른 실행 방식
  5. Controller · Exit결과 반환
  6. ResultAdapter공통 진행 변경으로 변환

IContentStartData·IContentController·IContentExit이 구현과 게임 흐름 사이의 경계입니다.

콘텐츠별 Manager를 늘리지 않은 이유

Hosted와 Separate Scene 모두 입장·종료·결과 전달이 필요합니다. 이를 각 콘텐츠에 복제하면 실패 처리와 복귀 규칙이 달라지므로, 공통 수명은 Framework가 소유하고 콘텐츠는 자기 Controller와 결과 타입에 집중하게 했습니다.

콘텐츠를 추가할 때 연결하는 요소

콘텐츠마다 시작 데이터와 Controller를 연결하고, 결과를 진행 데이터에 반영할 때 ResultAdapter를 사용합니다. Bootstrap은 구현과 서비스를 조립하고, ContentFlow는 입장부터 종료와 결과 전달까지의 순서를 관리합니다.

직접 맡은 범위

계층 경계, 콘텐츠 실행 계약, Hosted·Separate Scene 진입, 결과 Adapter 연결과 통합을 설계·구현했습니다.

관련 코드

콘텐츠 프레임워크의 계층 참조ProjectMT.Contents.Framework.asmdef
{
  "name": "ProjectMT.Contents.Framework",
  "rootNamespace": "ProjectMT.Contents.Framework",
  "references": [
    "ProjectMT.Core",
    "ProjectMT.Shared",
    "UnityEngine.UI",
    "Unity.TextMeshPro"
  ],
  "includePlatforms": [],
  "excludePlatforms": [],
  "allowUnsafeCode": false,
  "overrideReferences": false,
  "precompiledReferences": [],
  "autoReferenced": true,
  "defineConstraints": [],
  "versionDefines": [],
  "noEngineReferences": false
}
콘텐츠 시작·결과·실행 수명 계약ContentContracts.cs
    public interface IContentStartData // 타입 시작 데이터 표식
    {
    }

    public interface IPartyDeploymentStartData : IContentStartData // 그리드와 무관한 부대 투입 시작값
    {
        BattlePartySnapshot Party { get; }
        int UnitSlotCount { get; }
        int SummonsPerSlot { get; }
        int DeploymentLimit { get; }
    }

    public interface IContentResultData // 타입 결과 데이터 표식
    {
    }

    public interface IObjectiveCompletionResultData : IContentResultData // 변형 씬이 공유하는 목표 완료 결과
    {
        bool ObjectiveCompleted { get; }
    }

    public interface IContentExit // 결과를 한 번 돌려주는 출구
    {
        void Complete(IContentResultData result);
        void Fail(IContentResultData result = null);
        void Cancel();
    }

    public interface IContentController // 콘텐츠 실행 수명 계약
    {
        bool IsRunning { get; }
        void Initialize(ContentContext context);
        void Shutdown();
    }

    public interface IHostedContentRunner // MainBattle 내부 실행 계약
    {
        bool Open(ContentContext context);
        void Close();
    }

    public interface IContentLauncher // MainBattle에 공개한 입장 계약
    {
        bool IsRunning { get; }
        bool StartHosted(ContentId contentId, BattlePartySnapshot party, IHostedContentRunner runner);
        bool StartHosted(
            ContentId contentId,
            BattlePartySnapshot party,
            IHostedContentRunner runner,
            ContentRunMode runMode,
            int stage);
        bool StartSeparate(ContentId contentId, BattlePartySnapshot party);
        bool StartSeparate(ContentId contentId, BattlePartySnapshot party, ContentVariantId variantId);
        bool StartSeparate(ContentId contentId, BattlePartySnapshot party, int stage);
Hosted와 Separate Scene을 조율하는 실행 흐름ContentFlow.cs
        private bool StartHostedInternal(
            ContentId contentId,
            BattlePartySnapshot party,
            IHostedContentRunner runner,
            ContentRunInfo runInfo)
        {
            if (IsRunning || runner == null ||
                !TryGetDefinition(contentId, ContentOpenMode.MainBattleHosted, out var definition) ||
                !TryValidateGrowthDungeonEntry(definition, runInfo))
            {
                return false;
            }

            if (!TryCreateStartData(definition, party, out var startData))
            {
                return false;
            }

            var run = CreateRun(definition, startData, runner, runInfo);
            Phase = ContentFlowPhase.Entering;
            activeRun = run; // Open 전에 중복 입장 차단
            if (runner.Open(run.Context))
            {
                Phase = ContentFlowPhase.Playing;
                SfxEvents.Play2D(SfxEvents.ContentStart);
                NotifyHostedRunStarted(contentId);
                return true;
            }

            activeRun = null; // Hosted 열기 실패 복구
            Phase = ContentFlowPhase.Idle;
            return false;
        }

        public bool TryGetGrowthDungeonState(ContentId contentId, out GrowthDungeonEntryState state)
        {
            state = default;
            if (!TryGetDefinition(contentId, ContentOpenMode.MainBattleHosted, out var definition) ||
                string.IsNullOrEmpty(definition.DungeonKeyItemId))
            {
                return false;
            }

            var view = progress.View;
            view.Items.TryGetQuantity(definition.DungeonKeyItemId, out var keyQuantity);
            state = new GrowthDungeonEntryState(
                contentId,
                definition.DisplayName,
                view.GrowthDungeons.GetHighestClearedStage(contentId.Value),
                keyQuantity,
                definition.SupportsSweep && definition.ResultAdapter != null);
            return true;
        }

        public bool StartSeparate(ContentId contentId, BattlePartySnapshot party)
        {
            return StartSeparate(contentId, party, default(ContentVariantId));
        }

        public bool StartSeparate(ContentId contentId, BattlePartySnapshot party, ContentVariantId variantId)
        {
            if (IsRunning || !TryGetDefinition(contentId, ContentOpenMode.SeparateScene, out var definition))
            {
                return false;
            }

            if (!definition.TryResolveSceneId(variantId, out var targetSceneId))
            {
                Debug.LogError($"Separate content variant has no SceneId. Content={contentId}, Variant={variantId}");
                return false;
            }

            if (!TryCreateStartData(definition, party, out var startData))
            {
                return false;
            }

            Phase = ContentFlowPhase.Entering;
            activeRun = CreateRun(
                definition,
                startData,
                null,
                new ContentRunInfo(contentId, "seed", ContentRunMode.SeedTest, variantId),
                targetSceneId); // 씬 이동 전 실행 등록
            sceneNavigator.Load(targetSceneId);
            return true;
01Monster Maker

사람·팀원·AI가 공유하는
몬스터 제작 규격

팀원과 AI가 같은 제작 규칙으로 작업을 이어갈 수 있도록 Monster Maker를 만들었습니다. Draft와 Profile로 제작 입력을 관리하고, 미리보기에서 모션과 효과를 확인한 뒤 자산을 생성합니다.

제작 목적

공통 제작 규격

사람이 만든 결과를 AI가 이어받고, 다른 팀원이 다시 검토할 수 있는 환경을 목표로 했습니다. 다양한 몬스터를 공통 입력 형식과 검증·저장 규칙에 맞춰 제작하도록 했습니다.

01
같은 입력 규격ID·필드·Step·Binding 규칙
02
같은 검증 단계검증·중복 차단·저장 경계
03
이어지는 작업사람 → AI → 다른 사람
Monster Maker V2에서 몬스터 제작 데이터와 Live Preview를 함께 확인하는 화면
실제 화면Monster Maker V2 · 제작과 미리보기제작 입력과 실제 전투 표현을 한 화면에서 대조합니다.
협업 구조

GUI와 API의 공통 작성 서비스

공격 액티브 프리셋의 GUI와 JSON API는 같은 작성 서비스를 호출합니다.
설계

문제와 해결 방식

문제

작업자마다 몬스터 생성·공격 구성·자산 등록 방식이 달라지면 결과를 이어받을 때마다 구조를 다시 해석해야 했습니다. 수작업 복제는 Runtime 참조 누락·GUID 변경·부분 저장 위험도 함께 만들었습니다.

적용 방식

Draft와 Profile을 공통 입력 계약으로 두고 Preview·Validator·Writer를 한 제작 순서로 연결했습니다. 공격 액티브 영역은 사람용 공방과 AI용 API가 동일한 작성 서비스를 호출하도록 구성했습니다.

다른 방법

문서와 체크리스트만 배포하거나 GUI와 API의 저장 로직을 각각 구현할 수도 있습니다. 규칙 변경 때 두 경로가 어긋날 수 있어, 작성 서비스와 검증 규칙을 코드의 공통 경계로 모았습니다.

규격화

몬스터 제작 규칙을 표준화했습니다

공통 규격을 사용하면 다른 작업자가 만든 몬스터도 익숙한 구조로 확인하고 수정할 수 있습니다.

01 · INPUT

입력 규격

ID, 입력 필드, 공격 Step과 Binding 키를 공통 형식으로 유지합니다.

02 · FLOW

작업 순서

Draft → Preview → Validate → Writer 순서로 확인과 생산을 연결합니다.

03 · QUALITY

검증 단계

지원 조합, 중복 ID, 소유권, 저장 결과와 Catalog 등록을 검사합니다.

04 · HANDOFF

작업 인계

같은 Profile과 Draft를 열어 사람과 AI의 결과를 이어서 조정합니다.

HUMAN ENTRYMonster WorkshopTryCreate / TryUpdate
ONE SERVICEAuthoring ServiceValidate · Save · Guard
AI ENTRYJSON Contract APIValidate / Create / Update

제작 작업Monster Maker는 Draft에서 Runtime 자산을 만들고 Catalog에 등록합니다. JSON API는 공격 액티브 프리셋의 검증·생성·수정을 지원합니다.

처리 흐름
  1. 01Preflight입력·소유권 검사
  2. 02Capture파일·GUID·복구 범위
  3. 03Write대상 자산 저장
  4. 04ReloadDefinition 재검증
  5. 05RegisterCatalog·GUID 확인
  6. 06Commit전체 후조건 통과

실패 처리사전 검사에서 오류가 발견되면 쓰기를 중단합니다. 생성 중 오류가 발생하면 확보한 파일의 복구를 시도합니다. 복구도 실패하면 생성 오류와 복구 오류를 함께 예외로 전달합니다.

코드에서 처리한 내용

  • Draft와 Profile에 공통 입력 형식과 제작 순서를 고정했습니다.
  • 공격 액티브 공방과 AI용 JSON API를 하나의 작성 서비스에 연결했습니다.
  • 저장된 Definition의 참조, Catalog 등록 결과와 기존 GUID를 확인합니다.
  • 후조건 통과 뒤 Commit하며, 예외와 검증 실패는 캡처된 범위의 복구 경로로 모읍니다.

GUI와 AI API가 같은 작성 서비스를 호출합니다

공격 액티브 공방은 새 프리셋 저장과 현재 프리셋 수정에 MonsterActiveAttackAuthoringService를 사용합니다. AI용 JSON API의 검증·생성·수정도 같은 서비스를 호출합니다. API가 제공하는 계약에는 지원하는 Enum, 요청 예시, Step 규칙과 VFX/SFX 생성 규칙이 포함됩니다.

구현 근거MonsterWorkshopV2Window.cs 보기MonsterActiveAttackAuthoringApi.cs 보기MonsterActiveAttackAuthoringService.cs 보기MonsterActiveAttackAuthoringApi.cs 보기

제작자가 고르는 값과 도구가 확인하는 값

제작자는 모델·모션·타격 Marker·효과를 고릅니다. Writer는 입력값을 받아 Runtime에서 참조할 자산을 만들고 저장 결과를 확인합니다. 운영 경로에서는 기본 산출물 9개와 Catalog 4개를 다루며, 설정에 따라 고유 패시브·액티브 산출물이 추가됩니다.

구현 근거MonsterMakerAssetWriter.cs 보기MonsterMakerAssetWriter.cs 보기

쓰는 범위와 완료 조건

경계실제 처리막으려는 문제
쓰기 전Draft 소유권·출력 경로를 검사하고 파일·폴더와 기존 GUID를 확보합니다.다른 제작 원본의 산출물을 덮어쓰거나 복구 범위를 놓치는 문제
산출물 저장 후Definition을 다시 불러와 Runtime 참조와 필수 값을 검사합니다.메모리에서는 연결됐지만 저장 결과에는 누락된 참조
운영 등록 후Catalog별 ID·등급·AI 연결과 기존 GUID를 확인한 뒤 Commit합니다.파일만 있고 게임에서는 찾을 수 없는 자산
중간 실패캡처한 파일·폴더 범위로 Rollback을 시도하고, 복구 실패도 별도 예외로 전달합니다.부분 생성 상태를 성공으로 처리하는 문제

구현 근거MonsterMakerAssetWriter.cs 보기MonsterMakerAssetWriter.cs 보기

이번에 만든 자산만 저장합니다

BuildAndRegister는 이번 제작 대상 자산만 저장합니다. 다른 작업자의 미저장 변경은 그대로 둡니다.

남은 과제

몬스터 전체 Draft 작성과 Catalog 등록을 API로 연결하는 작업이 남아 있습니다.

담당 내용

Draft·Profile 규격, GUI·API 공용 작성 서비스, Preview, 자산 생성·검사, Catalog 등록과 Rollback을 구현했습니다.

관련 코드

생산 전 검증과 변경 범위 확보MonsterMakerAssetWriter.cs
        public static MonsterMakerWriteResult BuildAndRegister(
            MonsterMakerDraft draft,
            MonsterCatalog catalog = null,
            MonsterRarityCatalog rarityCatalog = null)
        {
            draft?.EditorSyncActiveAttackAuthoring();
            var preflight = MonsterMakerValidator.Validate(draft);
            if (preflight.HasErrors)
            {
                throw new InvalidOperationException(BuildIssueText(preflight.Issues));
            }

            catalog ??= AssetDatabase.LoadAssetAtPath<MonsterCatalog>(MonsterCatalogPath);
            rarityCatalog ??= AssetDatabase.LoadAssetAtPath<MonsterRarityCatalog>(MonsterRarityCatalogPath);
            if (catalog == null || rarityCatalog == null)
            {
                throw new InvalidOperationException("MonsterCatalog 또는 MonsterRarityCatalog을 찾을 수 없습니다.");
            }

            var paths = BuildPaths(draft.MonsterId);
            var generatesUniquePassive = draft.UsePassiveSkill &&
                                         draft.RarityPassiveSkill is GenericMonsterPassiveSkill;
            var generatesAttackActive = draft.UseActiveSkill &&
                                        draft.Rarity >= MonsterRarity.Legendary &&
                                        draft.HasActiveProfile;
            var passivePath = BuildPassivePath(draft.MonsterId);
            var extraPaths = new List<string>();
            if (generatesUniquePassive) extraPaths.Add(passivePath);
            if (generatesAttackActive) extraPaths.Add(BuildActivePath(draft.MonsterId));
            var outputPaths = paths.Concat(extraPaths).ToArray();
            var dataFolder = DataRoot + "/" + draft.MonsterId;
            var artFolder = ArtRoot + "/" + draft.MonsterId;
            var catalogPath = RequirePersistentAssetPath(catalog, "MonsterCatalog");
            var rarityCatalogPath = RequirePersistentAssetPath(rarityCatalog, "MonsterRarityCatalog");
            ValidateProductionDraftOwnership(draft, catalogPath, rarityCatalogPath);
            var writesProductionAiCatalog =
                string.Equals(catalogPath, MonsterCatalogPath, StringComparison.OrdinalIgnoreCase) &&
                string.Equals(rarityCatalogPath, MonsterRarityCatalogPath, StringComparison.OrdinalIgnoreCase);
            var transactionPaths = outputPaths.Concat(new[] { catalogPath, rarityCatalogPath });
            if (writesProductionAiCatalog)
            {
                transactionPaths = transactionPaths.Concat(new[]
                {
                    CastleRaidAIProfileCatalogPath,
                    MainBattleAIProfileCatalogPath
                });
            }
            var transaction = MonsterMakerWriteTransaction.Capture(
                transactionPaths,
                new[] { dataFolder, artFolder });

            try
            {
                EnsureFolder("Assets/ProjectMT/02_Shared/Unit/Data", "Monsters");
                EnsureFolder(DataRoot, draft.MonsterId);
                EnsureFolder("Assets/ProjectMT/05_Art", "Monsters");
생성물·Catalog·GUID 검증과 복구MonsterMakerAssetWriter.cs
            definition = AssetDatabase.LoadAssetAtPath<MonsterDefinition>(paths[0]);
            var outputValidation = MonsterDefinitionValidator.Validate(definition, true);
            if (outputValidation.HasErrors)
            {
                throw new InvalidOperationException(BuildRuntimeIssueText(outputValidation.Issues));
            }

            RegisterLast(catalog, rarityCatalog, definition, draft, uniquePassive, attackActive);
            SaveAssetsIfDirty(catalog, rarityCatalog, castleRaidAiCatalog, mainBattleAiCatalog);
            AssetDatabase.Refresh();

            if (!catalog.TryGet(draft.MonsterId, out var registered) || registered != definition)
            {
                throw new InvalidOperationException("생성물 검증 뒤 MonsterCatalog 등록을 확인하지 못했습니다.");
            }

            if (!rarityCatalog.TryGetRarity(draft.MonsterId, out var rarity) || rarity != draft.Rarity)
            {
                throw new InvalidOperationException("생성물 검증 뒤 MonsterRarityCatalog 등록을 확인하지 못했습니다.");
            }

            var assignedAiProfile = castleRaidAiCatalog?.Resolve(draft.MonsterId);
            if (castleRaidAiCatalog != null && (assignedAiProfile == null ||
                assignedAiProfile.Pattern != draft.CastleRaidAiPattern))
            {
                throw new InvalidOperationException("생성물 검증 뒤 Hex Castle Raid AI Profile 등록을 확인하지 못했습니다.");
            }

            if (mainBattleAiCatalog != null &&
                (!mainBattleAiCatalog.TryResolve(draft.MonsterId, out var mainBattleProfile) ||
                 mainBattleProfile.Role != draft.MainBattleRole ||
                 mainBattleProfile.TargetPriority != draft.MainBattleTargetPriority))
            {
                throw new InvalidOperationException("생성물 검증 뒤 MainBattle AI Profile 등록을 확인하지 못했습니다.");
            }

            var guidAfter = outputPaths.ToDictionary(path => path, AssetDatabase.AssetPathToGUID);
            for (var index = 0; index < outputPaths.Length; index++)
            {
                var before = guidBefore[outputPaths[index]];
                if (!string.IsNullOrEmpty(before) && !string.Equals(before, guidAfter[outputPaths[index]], StringComparison.Ordinal))
                {
                    throw new InvalidOperationException("기존 Asset GUID가 변경되었습니다: " + outputPaths[index]);
                }
            }

                var result = new MonsterMakerWriteResult(
                    definition,
                    updatedExisting,
                    outputPaths,
                    guidBefore,
                    guidAfter,
                    outputValidation);
                transaction.Commit();
                return result;
            }
            catch (Exception buildException)
            {
                try
                {
                    transaction.Rollback();
                }
                catch (Exception rollbackException)
                {
                    throw new AggregateException(
                        "Monster Maker 생성과 원상복구가 모두 실패했습니다.",
                        buildException,
                        rollbackException);
                }

                throw;
몬스터 1종의 기본 산출물 경로MonsterMakerAssetWriter.cs
        public static IReadOnlyList<string> BuildPaths(string monsterId)
        {
            var data = DataRoot + "/" + monsterId;
            var art = ArtRoot + "/" + monsterId;
            return new[]
            {
                data + "/MD_" + monsterId + ".asset",
                data + "/MB_" + monsterId + ".asset",
                data + "/MM_" + monsterId + ".asset",
                data + "/MC_" + monsterId + ".asset",
                data + "/MA_" + monsterId + ".asset",
                data + "/MF_" + monsterId + ".asset",
                data + "/MR_" + monsterId + ".asset",
                art + "/AC_" + monsterId + ".controller",
                art + "/PF_" + monsterId + "_VisualAdapter.prefab"
            };
사람용 공방이 호출하는 공통 저장 경계MonsterWorkshopV2Window.cs
        private void SaveCurrentAsNew()
        {
            if (mode == WorkshopMode.Basic) basicSession.SaveAsNew(out _);
            else if (mode == WorkshopMode.Attack)
            {
                attackSerialized.ApplyModifiedProperties();
                if (MonsterActiveAttackAuthoringService.TryCreate(attackWorking, out var created, out _, out var error))
                { LoadAttack(created); attackMessage = "새 공격 액티브 프리셋을 저장했습니다."; }
                else attackMessage = "오류: " + error;
            }
            else
            {
                effectSerialized.ApplyModifiedProperties();
                if (MonsterEffectActiveAuthoringService.TryCreate(effectWorking, out var created, out _, out var error))
                { LoadEffect(created); effectMessage = "새 효과형 액티브 프리셋을 저장했습니다."; }
                else effectMessage = "오류: " + error;
            }
            libraryDirty = true;
            RebuildCurrent();
        }

        private void UpdateCurrent()
        {
            if (mode == WorkshopMode.Basic) basicSession.UpdateLoaded();
            else if (mode == WorkshopMode.Attack)
            {
                attackSerialized.ApplyModifiedProperties();
                if (MonsterActiveAttackAuthoringService.TryUpdate(attackWorking, attackLoaded, out var error))
                {
                    var loaded = attackLoaded;
                    LoadAttack(loaded);
                    NotifyActiveProfileUpdated();
                    attackMessage = "현재 공격 액티브 프리셋을 저장했습니다.";
                }
                else attackMessage = "오류: " + error;
            }
AI용 JSON API의 검증·생성·수정 경로MonsterActiveAttackAuthoringApi.cs
        public static string ValidateProfileJson(string json)
        {
            if (!TryBuildProfile(json, out var profile, out var error))
            {
                return BuildResult(false, "validate", string.Empty, string.Empty, error);
            }
            try
            {
                var success = MonsterActiveAttackAuthoringService.TryValidate(profile, null, out error);
                return BuildResult(success, "validate", profile.ProfileId, string.Empty, error);
            }
            finally
            {
                UnityEngine.Object.DestroyImmediate(profile);
            }
        }

        public static string CreateProfileFromJson(string json)
        {
            if (!TryBuildProfile(json, out var profile, out var error))
            {
                return BuildResult(false, "create", string.Empty, string.Empty, error);
            }
            try
            {
                var success = MonsterActiveAttackAuthoringService.TryCreate(
                    profile,
                    out var createdAsset,
                    out var assetPath,
                    out error);
                return BuildResult(success, "create", createdAsset != null ? createdAsset.ProfileId : profile.ProfileId,
                    assetPath, error);
            }
            finally
            {
                UnityEngine.Object.DestroyImmediate(profile);
            }
        }

        public static string UpdateProfileFromJson(string assetPath, string json)
        {
            var target = string.IsNullOrWhiteSpace(assetPath)
                ? null
                : AssetDatabase.LoadAssetAtPath<MonsterActiveAttackProfile>(assetPath);
            if (target == null)
            {
                return BuildResult(false, "update", string.Empty, assetPath,
                    $"업데이트할 공격 액티브 프로필을 찾지 못했습니다: {assetPath}");
            }
            if (!TryBuildProfile(json, out var profile, out var error))
            {
                return BuildResult(false, "update", target.ProfileId, assetPath, error);
            }
            try
            {
                var success = MonsterActiveAttackAuthoringService.TryUpdate(profile, target, out error);
                return BuildResult(success, "update", target.ProfileId, assetPath, error);
            }
            finally
            {
                UnityEngine.Object.DestroyImmediate(profile);
            }
        }
GUI와 API가 공유하는 검증 서비스MonsterActiveAttackAuthoringService.cs
    public static class MonsterActiveAttackAuthoringService // GUI와 선택형 API가 공유하는 단일 프로필 저장 경계
    {
        public const string ProfileRoot = "Assets/ProjectMT/02_Shared/Unit/Data/ActiveAttackProfiles";
        public const string CustomProfileRoot = ProfileRoot + "/Custom";

        public static bool TryValidate(
            MonsterActiveAttackProfile source,
            MonsterActiveAttackProfile excludedProfile,
            out string error)
        {
            if (source == null)
            {
                error = "공격 액티브 작업 사본이 없습니다.";
                return false;
            }
            if (!source.TryValidate(out error)) return false;
            for (var stepIndex = 0; stepIndex < source.Steps.Count; stepIndex++)
            {
                var step = source.Steps[stepIndex];
                if (MonsterActiveAttackBlockContractTemplates.TryValidateCurrent(step, out error)) continue;
                error = $"Step {stepIndex + 1:00}의 VFX/SFX 계약이 유효하지 않습니다. {error}";
                return false;
            }
            if (excludedProfile != null && !string.Equals(
                    source.ProfileId,
                    excludedProfile.ProfileId,
                    StringComparison.OrdinalIgnoreCase))
            {
                error = $"저장된 프리셋의 ID는 바꿀 수 없습니다. 새 ID가 필요하면 새 프리셋으로 분기하세요: {excludedProfile.ProfileId}";
                return false;
            }

            var guids = AssetDatabase.IsValidFolder(ProfileRoot)
                ? AssetDatabase.FindAssets("t:MonsterActiveAttackProfile", new[] { ProfileRoot })
                : Array.Empty<string>();
            for (var index = 0; index < guids.Length; index++)
            {
                var candidate = AssetDatabase.LoadAssetAtPath<MonsterActiveAttackProfile>(
                    AssetDatabase.GUIDToAssetPath(guids[index]));
                if (candidate == null || candidate == excludedProfile) continue;
                if (!string.Equals(candidate.ProfileId, source.ProfileId, StringComparison.OrdinalIgnoreCase)) continue;
                error = $"같은 프리셋 ID가 이미 있습니다: [{candidate.ProfileId}] {candidate.DisplayName}";
                return false;
            }

            error = string.Empty;
            return true;
        }
AI가 읽을 수 있는 작성 계약MonsterActiveAttackAuthoringApi.cs
    internal sealed class MonsterActiveAttackAuthoringContract
    {
        public int schemaVersion = 2;
        public string scope = "한 번 호출할 때 공격 액티브 프로필 하나만 작성합니다.";
        public string stepIdentity = "Step ID는 step_01부터 순서대로, 표시 이름은 공격 형태에서 자동 생성합니다.";
        public string vfxSfxContract = "각 Step의 VFX/SFX 공간은 기본공격 공용 계약에서 자동 생성합니다.";
        public string dataOwnership = "기본공격 프리셋을 가져와도 독립 복사하며 기본공격 자산을 참조 연결하지 않습니다.";
        public string multiHit = "hitCount 0은 공격 형태 기본값, 직접 공격은 1~3타, 왕복 투사체는 2타 고정입니다.";
        public string damageRange = "useDamageRange를 켜면 각 Step 실행 때 damageMultiplier~maximumDamageMultiplier를 한 번 독립 추첨합니다.";
        public string[] operations;
        public string[] patterns;
        public string[] progressions;
        public string[] targetPolicies;
        public string[] projectileFormations;
        public string[] instantMagicTargets;
        public string[] magicDirections;
        public string[] hitEffectTypes;
        public MonsterActiveAttackAuthoringRequest example;
    }

    public static class MonsterActiveAttackAuthoringApi // 폴더째 제거 가능한 AI용 단일 프리셋 어댑터
    {
        public static string GetContractJson()
        {
            var contract = new MonsterActiveAttackAuthoringContract
            {
                operations = new[] { "ValidateProfileJson", "CreateProfileFromJson", "UpdateProfileFromJson" },
                patterns = Enum.GetNames(typeof(MonsterActiveAttackPattern)),
                progressions = Enum.GetNames(typeof(MonsterActiveAttackProgression)),
                targetPolicies = Enum.GetNames(typeof(MonsterActiveTargetPolicy)),
                projectileFormations = Enum.GetNames(typeof(MonsterActiveProjectileFormation)),
                instantMagicTargets = Enum.GetNames(typeof(MonsterActiveInstantMagicTarget)),
                magicDirections = Enum.GetNames(typeof(MonsterActiveMagicDirection)),
                hitEffectTypes = Enum.GetNames(typeof(MonsterActiveHitEffectType)),
                example = CreateExampleRequest()
            };
            return JsonUtility.ToJson(contract, true);
        }
02공성 AI

소환한 전면을 공략하고, 가까운 병력은 경로를 공유합니다

소환 위치를 병력의 공략 방향으로 정합니다. 최대 6명의 느슨한 부대가 팀장의 경로 탐색을 공유하고, 같은 전면 안에서 실제 이동·정렬 시간과 잔여 HP의 파괴 시간을 정수 ms로 비교합니다.

공성 AI 시뮬레이터 · 전장 프리뷰24마리 · 한쪽 집중 소환설정 패널을 제외한 전장만 담았습니다. 약 34.5초 · 1배속 · 10fps · 무음입니다.
핵심 도식

가까운 병력이 팀장의 경로 탐색을 공유하는 방식

부대는 경로 탐색 결과를 공유하는 단위입니다. 임시 공격과 자리 변경 중에도 소속을 유지하고, 실제로 멀어지거나 연결이 끊어지면 다시 묶습니다.
설계 판단

소환 위치는 공략 방향이라는 약속

문제

플레이어는 눈앞의 벽을 공략하려고 그쪽에 병력을 소환합니다. 반대편 문이 싸다는 이유로 성을 크게 돌아가면 비용 계산이 빨라도 플레이어의 전략을 무시하게 됩니다.

선택

먼저 소환 측 전면의 벽·문·돌파구를 정하고, 그 안에서 실제 이동과 남은 HP의 파괴 시간을 비교합니다. 가까운 최대 6명은 왕궁에 가장 가까운 팀장의 경로를 공유합니다.

판단 기준

소환 전면을 먼저 확정한 뒤, 그 전면 안에서 이동과 파괴 비용을 비교합니다. 부대는 경로 캐시를 함께 쓰는 병력으로 묶습니다.

실제 공격 자리까지열린 경로 이동·마지막 정렬 ms+현재 남은 내구도잔여 HP ÷ 유효 DPS의 파괴 ms
처리 흐름
  1. 01소환 전면플레이어의 공격 방향
  2. 02느슨한 부대합류 가능한 최대 6명
  3. 03팀장 경로대표 탐색과 재사용
  4. 04개인 접근짧은 합류·개별 자리
  5. 05지역 공격포화 시 주변 다른 목표
  6. 06선택적 갱신영향받은 부대 재탐색

부대와 공격 목표목표·예약 자리·부대 소속의 수명은 별개입니다. 목표나 공격 자리가 바뀌어도 부대 소속과 유효한 이동 계획을 유지합니다. 병력이 멀어지거나 통행 연결이 끊어지면 부대를 다시 구성합니다.

구현

  • 소환 위치에서 왕궁 방향으로 향하는 전면의 첫 장애물과 인접 후보를 평가합니다. 비용은 소환 전면의 후보끼리 비교합니다.
  • 같은 소환 방향에서 통행 가능한 3칸 안의 병력을 최대 6명으로 묶고, 기존 소속은 4칸까지 유지해 경계 왕복을 줄입니다. 가입 여부는 현재 위치에서 팀장까지의 통행 가능 거리로 판단합니다.
  • 왕궁에 가장 가까운 현재 부대원이 팀장이며 동률은 소환 순번입니다. 사망·이탈 때 승계하고 현재 위치에서 합류 가능한 작은 부대는 병합합니다. 팀장이 바뀌어도 유효한 경로를 재사용합니다.
  • 팀장의 열린 경로 탐색을 재사용하고 각 병력은 최대 3칸의 합류와 마지막 접근을 계산합니다. 접근 칸 내부의 고정 세 자리를 각각 예약하며 실제 몸체·성벽·공격선 검사는 유지합니다.
  • 공격 자리는 현재 공략 중인 전면에서 찾습니다. 구조물 접근 경로는 현재 위치와 목표 사거리의 바깥 고리 중 큰 값 안에 있어야 합니다. 이 범위 밖으로 후퇴하는 자리만 남으면 기존 지역 대체 공격으로 넘어갑니다. 위협 대응·아군 지원과 마지막 몸체 회피는 별개입니다.
  • 주변에 공격할 대상이 없으면 왕궁 방향으로 전진합니다. 전선이 열리면 기존 자리에서 계속 멈추지 않고, 이미 계산한 개인 합류 범위의 최대 3칸 안에서 같은 전면의 빈 공간으로 따라갑니다. 파괴되어 통행 가능한 구조물 자리도 포함하며 좁은 길목 점유 금지와 몸체·예약 검사는 유지합니다.
AI RUNTIME CONTRACT

공성 AI 판단·경로 실행 계약

사각 전장에서 다듬은 경로 공유와 느슨한 부대 구성을 육각 전장에 맞게 확장했습니다. 병력은 여섯 방향의 통행 규칙 안에서 이동하고, 공격과 수비도 같은 전장 규칙을 따릅니다.

플레이어 의도소환한 쪽을 공략

소환 셀에서 왕궁 방향으로 만나는 첫 장애물을 돌파 대상으로 정합니다. 같은 전면의 인접 후보를 이동 시간과 남은 HP로 비교합니다.

느슨한 부대최대 6명 · 현재 위치로 합류

개별 전체 탐색을 반복하지 않도록 근처 병력을 묶습니다. 팀장은 왕궁 최근접 병력이며 쉽게 가입·이탈·재합류합니다. 벽 양편처럼 짧은 통행이 불가능하면 분리합니다.

경로 공유팀장 탐색 + 개인의 짧은 연결

팀장 경로의 부모 정보를 공유하고 가까운 합류점부터 재사용합니다. 사용 구간과 인접 셀의 통행 변화만 해당 부대 탐색을 무효화합니다. 합류 실패와 팀장 이동으로 새 탐색이 필요하면 해당 부대만 갱신합니다.

자리 포화2·3순위와 주변 4순위 공격

같은 전면의 대체 목표에 실제 자리를 확보합니다. 그래도 막히면 해당 병력 주변의 같은 소환 전면에서 도달 가능한 대상을 비교합니다. 가장 짧은 이동 시간의 250ms 범위에서 HP가 낮은 대상을 고릅니다. 주변 벽도 포함하며 전략 소속은 유지합니다.

비용 재검토현재 속도 · 남은 HP · 실제 DPS

접근·정렬 거리를 현재 속도로 나누고 잔여 HP 파괴 시간을 더해 정수 ms로 비교합니다. 1000ms 안에 합류 가능한 후보에만 자리에 정착해 실제 공격 중인 동료 DPS를 반영합니다.

행동 안정성공격 직전의 불필요한 왕복 방지

임시 공격은 대상이 살아 있고 접근 가능한 동안 유지합니다. 위협 대응과 아군 지원을 우선하며, 대상이 사라지거나 접근에 실패하면 다시 판단합니다.

공성 병력의 실행은 enum 기반 FSM으로 관리합니다

병력마다 초기 판단 대기 → 경로 이동 → 마지막 위치 정렬 → 공격의 흐름을 갖습니다. 대기 계획을 받거나 목표가 사라지면 Holding, 이동이 막혀 진행하지 못하면 Recovering으로 전환해 다시 판단을 요청합니다.

전략 판단과 행동 실행의 책임도 나눴습니다. World가 목표와 경로를 결정하면 Unit은 현재 실행 상태에 맞춰 이동·정렬·공격을 처리합니다. 일곱 실행 상태의 전환 조건은 TickDynamicRuntime에서 처리합니다.

구현 근거HexAssaultExecutionContracts.cs 보기HexCastleAssaultUnit.cs 보기

같은 전면의 벽과 닫힌문을 ms 단위로 비교합니다

T_move=ceil(열린 경로·정렬 거리÷현재 속도×1000)입니다. 유효한 실제 공격 동료의 DPS를 D라고 하면 도착 시 예상 HP는 max(0, 현재 HP−D×접근 시간), 파괴 시간은 이를 자신의 DPS+D로 나누어 ms로 올림합니다. 동료 반영 조건 밖에서는 D=0이며 같은 비용은 좌표순으로 고정합니다.

열린문과 닫힌문은 게임에 설정된 통행 상태와 현재 HP를 그대로 사용합니다. 이 비교는 소환 전면을 정한 다음 실행합니다.

구현 근거HexCastleAssaultWorld.cs 보기

부대와 개별 전투의 수명을 분리합니다

부대는 경로 계산 공유 집단입니다. 슬롯 포화·대체 공격·위협 대응만으로 소속을 바꾸지 않습니다. 목표가 사라져도 몸체가 나갈 때까지 점유를 유지하며, 같은 슬롯이라도 접근 경로가 달라지면 새 이동 계획을 적용합니다.

구현 근거HexCastleAssaultWorld.cs 보기HexCastleAssaultWorld.cs 보기

탐색 비용과 행동 품질을 함께 봅니다

대표 전체 탐색, 공유 구간 사용, 개인 합류 탐색, 각 방문 칸 수를 따로 셉니다. 첫 타격까지 걸린 시간, 누적 이동, 2.5초 이상 멈춘 구간과 미공격 병력도 함께 봅니다.

구현 근거HexCastleAssaultNavigation.cs 보기HexAssaultApproachPlanner.cs 보기

측정 조건

일반 전투는 최대 24명, 50명과 100명은 별도 과부하 입력입니다.

측정 환경: Unity Editor.

개선 과정
개선 과정

소환 방향과 경로 공유를 개선한 과정

계산상 유리한 선택이 플레이어의 의도와 어긋나는 문제가 있었습니다. 문제의 원인과 판단 기준을 바꾼 과정을 정리했습니다.

01. 저 벽을 부수라고 소환했는데, 왜 반대쪽으로 갈까?

성 오른쪽에 몬스터를 놓았는데 시작하자마자 위쪽 벽으로 길게 돌아갔습니다. 경로가 연결되어 있고 계산한 비용이 낮다는 조건만으로는 플레이어의 의도를 반영할 수 없었습니다. 플레이어는 앞의 벽을 공략하려고 그 위치에 소환했습니다. AI가 더 싼 다른 전면을 고르면 배치라는 전략 입력이 의미를 잃습니다.

목표를 고르는 순서를 바꿨습니다. 병력이 등록될 때 소환 셀을 전면 기준으로 저장합니다. 첫 경로는 그 셀에서 왕궁 쪽으로 한 칸씩 안쪽으로 진행하며, 옆으로 덜 벗어나는 칸을 고릅니다. 그 선에서 처음 만난 통과 불가능한 벽이나 문을 첫 돌파 대상으로 확정합니다. 이 단계에서는 멀리 있는 저HP 구조물을 전역 비용 경쟁에 포함하지 않습니다.

같은 소환 전면의 첫 장애물과 인접 돌파 후보를 다음 순서로 비교합니다. 예약 가능한 공격 자리까지의 열린 경로 이동·마지막 정렬 시간을 이동 속도로 환산하고, 현재 남은 HP를 해당 병력의 유효 DPS로 나눈 파괴 시간을 더해 정수 ms로 평가합니다. 소환 위치가 공략 방향을 정하고, 비용 계산은 그 방향 안의 빠른 목표를 정합니다.

오른쪽에 소환한 병력이 앞의 성벽 대신 위쪽 방벽으로 길게 돌아가던 경로
실제 구현 경로

HexCastleAssaultWorld가 병력별 deploymentFronts[InstanceId]를 보존합니다. HexCastleAssaultNavigation.TryResolveFrontRoute는 매 스텝에서 DistanceFromOrigin이 감소하는 이웃만 허용하고, 소환점→왕궁 축과의 외적 절댓값이 가장 작은 셀을 고릅니다. 처음 만난 IsBlocked && IsDamageable 셀을 FirstObstacle로 기록합니다.

front = deploymentFronts[unitId]
next  = inwardNeighbors(current)
        .min(lateralDeviationFromSpawnAxis)
candidates = FirstObstacle + sameLayerAdjacentBreachCells
scoreMs = ceil(approachPathSeconds * 1000)
        + ceil(remainingHp / effectiveDps * 1000)

IsInDeploymentFront의 방향 내적은 0.5 이상이어야 하고, IsOnAssaultFront는 첫 장애물 또는 같은 방어층의 인접 성벽만 통과시킵니다. TryEstimateAdvance는 예약 불가능한 공격 자리를 무한 대기 후보에서 제외합니다. 점수가 같으면 육각 좌표 순으로 고정해 같은 입력의 결과가 흔들리지 않게 합니다.

네 방향 회귀에서는 반대편 벽의 HP를 1로 낮춰도 소환 전면의 벽을 먼저 선택하도록 고정했습니다. 실제 집중 배치의 첫 접근은 위쪽 벽까지 23.587m·8700ms이던 기록에서 오른쪽 전면 5.587m·2450ms로 바뀌었습니다. 후보 범위를 플레이어가 지정한 전면으로 좁혀 해결했습니다.

02. 가까운 병력의 경로 계산 공유

병력이 가까이 붙어 있는데도 목록에는 1명, 2명짜리 부대가 많이 생겼습니다. 가까운 병력이 경로 계산을 공유하도록 부대를 느슨하게 구성하고 싶었습니다.

기존 경로 번호와 과거 합류 시점을 소속 조건으로 사용한 것이 원인이었습니다. 지금 서로 붙어 있고 합류할 수 있어도 이전 판단 기록이 다르면 별도 부대로 남았습니다. 공격 자리나 잠깐의 임시 목표가 달라질 때도 계산 집단이 잘게 갈라졌습니다.

현재 위치의 연결 가능성을 부대 구성 기준으로 바꿨습니다. 새 병력은 소환 방향이 같은 부대 중 팀장까지 실제 열린 길로 3셀 안에 도달할 수 있는 곳에 가입합니다. 내적 0.5 미만인 다른 전면과 벽 양편처럼 실제 통행이 끊긴 병력은 합치지 않습니다. 한 부대는 공격 자리 두 묶음을 감당할 수 있는 최대 6명으로 제한했습니다.

이미 가입한 병력은 4셀까지 유지해 경계에서 가입·탈퇴가 매 프레임 뒤집히지 않게 했습니다. 더 이른 작은 부대와 합칠 때도 모든 부대원이 그 팀장에게 3셀 안으로 연결되고 합친 인원이 6명 이하일 때만 병합합니다. 공격 목표·슬롯·잠깐의 지원 행동은 부대 소속과 분리했습니다.

팀장은 매 갱신 때 살아 있는 부대원 중 왕궁에 가장 가까운 병력으로 정하고, 동률이면 소환 순서를 사용합니다. 팀장이 죽으면 즉시 다음 병력이 승계하며 유효한 공유 경로는 유지합니다. 팀장만 전체 전면 경로를 만들고 부대원은 그 경로까지 최대 3셀의 짧은 연결과 마지막 공격 자리만 계산합니다. 일반 네 배치 3679프레임 전체 검사에서 최대 인원 초과와 팀장 누락은 모두 0이었습니다.

가까운 병력이 1명과 2명짜리 여러 부대로 잘게 나뉜 목록
부대와 경로 캐시의 구현 계약
join = sameDeploymentFront(dot >= 0.5)
    && openConnectionToLeader <= 3 cells
    && memberCount < 6
keep = openConnectionToLeader <= 4 cells
leader = min(distanceToPalace, spawnOrder)
path = localBfs(maxSteps: 3) + leaderSharedTrunk + finalAttackSlot

ResolveCohort가 가입, MaintainCohort가 이탈과 작은 부대 병합, RefreshCohortLeader가 승계를 담당합니다. TryResolveCohortRoute는 부대의 SharedRoute와 RouteVersion을 관리합니다. HexAssaultApproachPlanner.BeginDecision은 팀장 기준 전체 BFS를 부대 ID에 한 번 저장하고, 부대원에게는 최대 3셀 지역 BFS만 실행합니다.

공유 경로를 실제로 사용한 셀은 Dependencies에 기록합니다. 구조물의 통행 상태가 바뀌면 InvalidateSharedSearchesAt이 변경 셀 또는 인접 셀을 사용한 부대 캐시만 폐기합니다. HP 구간만 달라지면 비용장을 갱신하고 모든 부대의 열린 경로를 다시 만들지 않습니다.

03. 경로 공유 후 줄어든 계산

팀장 한 번의 탐색 결과를 부대원이 재사용하고, 각자는 공유 경로에 합류하는 짧은 구간과 마지막 공격 자리만 계산하도록 했습니다. 사용 중인 경로에 영향이 있는 부대만 갱신해 구조 변경 때 전군이 다시 계산하는 일을 막았습니다.

같은 6명의 위치·목표·능력치에서 경로 공유만 켜고 껐습니다. 선택한 자리·경로·비용은 같았고, 전체 열린 경로 탐색은 6회에서 1회로, 5회(83.3%) 줄었습니다. 개인의 짧은 합류 탐색 6회는 그대로 남았습니다. 전체 프레임 변화는 아래 성능표에 함께 제시합니다.

같은 판단 중 복원한 경로를 재사용하고 육각 충돌 계산의 반복 상수도 한 번만 만들었습니다. 범위와 이동 시간의 하한을 먼저 확인해 결과가 바뀌지 않는 정밀 검사를 건너뛰고, 진단 과정에서 병력마다 전체 명단을 다시 찾던 중복도 없앴습니다. 공격 후보·슬롯·충돌 판정은 그대로 유지했습니다.

측정값도 계산 단계별로 분리했습니다. FullSearchCount / FullVisitedCellCount는 팀장 전체 BFS, SharedPathUseCount는 공유 구간 사용, LocalConnectionSearchCount / LocalVisitedCellCount는 개인 합류 탐색을 셉니다.

전후 AI 시간·프레임 지연·탐색 수치

줄어든 계산과 전체 실행 변화

팀장이 전체 열린 경로를 한 번 찾고 부대원은 그 경로까지의 짧은 합류 구간과 마지막 공격 자리만 계산합니다. 성벽이 바뀌면 그 셀을 사용하는 부대의 경로만 다시 만듭니다. 슬롯·몸체·공격선 검사는 그대로 유지했습니다.

일반 24명 전후 비교. 같은 성, 시드, 소환 순서와 능력치로 예전 코드와 현재 코드를 각각 실행했습니다. 여러 수정이 함께 들어간 결과이므로 전체 변화만 비교합니다.

배치AI 평균 ms관측 차이(참고)프레임 평균 ms프레임 p99 ms무행동 후보
한쪽 집중8.972 → 2.8776.095ms30.564 → 19.84664.948 → 48.2580구간
양쪽 분산9.233 → 2.0767.157ms30.776 → 19.22481.148 → 49.3160구간
사방8.802 → 1.8236.979ms29.408 → 19.74867.592 → 50.6960구간
무작위8.998 → 1.0677.931ms30.136 → 19.30473.521 → 53.4440구간

여기서 무행동은 2.5초 이상 대기·복구 중 이동과 타격이 모두 없었던 구간입니다. 일반 네 배치에서는 0개였습니다. 공격하지 않았더라도 왕궁 쪽으로 움직였다면 무행동으로 세지 않았습니다.

탐색 공유 고정 대조. 같은 6명으로 공유만 켜고 껐습니다. 선택 자리·경로·비용은 같았고 전체 탐색은 6회에서 1회로 줄었습니다. 개인 합류 6회는 그대로입니다. 83.3%는 전체 탐색 호출 감소이며 실제 프레임 변화는 위 표에서 따로 봅니다.

실행 결과 — 한쪽 집중: 왕궁 파괴 / 양쪽 분산: 왕궁 파괴 / 사방: 왕궁 파괴 / 무작위: 제한 시간 도달.

일반 24명 탐색·할당·GC

현재 네 실행의 누적 탐색 횟수와 방문 셀 수입니다. 프레임 할당량에는 전투 기록과 Editor 렌더링도 들어 있습니다.

배치전체 탐색공유 구간 사용개인 합류전체 / 지역 방문 셀프레임 평균 할당 MiBGC 횟수
한쪽 집중160127569632886 / 148260.5552
양쪽 분산149156479931807 / 145740.6103
사방104106580322883 / 120370.6173
무작위162141487836588 / 123900.6754
24·50·100명 동시 소환

게임에서는 최대 24명을 사용합니다. 50명과 100명은 공격 자리 경쟁과 길목 혼잡을 보기 위한 과부하 조건입니다.

조건AI 평균 ms프레임 p95 / p99 ms정지 후보 / 미공격결과
focus-242.82134.889 / 42.1180 / 0왕궁 파괴
split-242.34833.941 / 45.5202 / 0왕궁 파괴
surround-242.18936.261 / 50.6291 / 0왕궁 파괴
random-241.08537.316 / 59.6291 / 0제한 시간 도달
focus-505.03244.150 / 60.60412 / 0왕궁 파괴
split-505.25949.881 / 72.1609 / 0왕궁 파괴
surround-505.40655.568 / 94.9534 / 0왕궁 파괴
random-503.46747.678 / 83.3051 / 0왕궁 파괴
focus-1006.68663.697 / 88.412430 / 3왕궁 파괴
split-1009.62475.180 / 139.784308 / 0왕궁 파괴
surround-10011.69294.692 / 179.111337 / 0왕궁 파괴
random-10010.68298.898 / 175.953265 / 1왕궁 파괴

100명에서는 최장 9.7초의 대기·복구 구간과 높은 p99가 남았습니다. 게임 상한 24명 결과와 함께 이 수치를 다음 최적화 기준으로 사용합니다.

04. 평균과 p95·p99를 함께 본 이유

중간 집중 배치에서 AI 평균은 8.972ms에서 4.292ms로 줄었지만, 전체 프레임 p99는 64.948ms에서 78.476ms로 나빠졌습니다. 평균만 보면 놓치는 끊김이 있어 이후부터 평균과 p95·p99를 함께 기록했습니다.

p95는 전체 프레임의 95%가 이 시간 안에 끝났다는 뜻이고, p99는 99%가 이 시간 안에 끝났다는 뜻입니다. 둘 다 낮을수록 좋으며, p99는 드물게 발생하는 큰 끊김을 확인할 때 사용합니다.

게임 상한인 24명과 별도로 50명, 100명을 한꺼번에 넣어 공격 자리 경쟁, 판단 대기, 길목 혼잡을 살폈습니다.

50·100명 직접 녹화

50·100명 스트레스 테스트 직접 녹화

50명 · 집중 → 양쪽 → 사방 → 무작위

약 149초 · 1배속 · 10fps 표본 · 무음입니다.

100명 · 집중 → 양쪽 → 사방 → 무작위

약 162초 · 1배속 · 10fps 표본 · 무음입니다.

05. 공격할 수 없을 때의 다음 행동

처음에는 한 번도 공격하지 못한 병력을 문제 지표로 많이 봤습니다. 기록을 따라가 보니 계속 전진한 병력도 있었고, 공격 자리로 접근하다 죽은 병력도 있었습니다. 공격 횟수 0만으로 무행동을 판단하면 다른 문제를 고치게 됩니다.

공격할 게 없으면 이동만 해도 괜찮다. 가만히 아무것도 안 하고 있는 상황만 없어지면 된다.

이 기준으로 다시 보니 실제로 고칠 분기가 보였습니다. 전선이 열렸는데도 예전 대기 자리를 계속 유지했고, 파괴되어 열린 성벽 자리도 전진 후보에서 빠졌습니다. 기존 공유 경로를 따라 가까운 빈 공간으로 전진하게 하고, 파괴된 자리는 현재 통행 가능 여부로 판단하도록 최소 수정했습니다. 좁은 길목을 점유하지 않는 조건과 몸 충돌 검사는 유지했습니다.

무행동 방지 분기

TryCreateHoldPlan은 병력이 이미 안전하게 이동 중이거나 유효한 공격 목표를 가진 경우 개입하지 않습니다. 둘 다 아닐 때 공유 전면 경로의 첫 장애물 접근점까지 남은 거리를 구하고, 현재 위치에서 최대 3셀 안에서 더 전진한 빈 자리를 예약합니다. 후보는 왕궁에서 더 멀어지지 않고 IsInDeploymentFront를 유지해야 합니다.

HexAssaultApproachPlanner.HoldingCell은 현재 Open(cell) 값으로 통행 가능 여부를 검사합니다. 파괴된 성벽 자리도 통행 가능하면 후보가 됩니다. 이웃 6칸 중 열린 칸이 4개 미만이면 한 칸짜리 길목으로 보고 의도적인 대기 자리를 만들지 않습니다. 슬롯 예약·몸체 반경·마지막 이동 선분 검사는 공격 접근과 동일하게 유지합니다.

현재 방식에서는 공격 가능한 대상이 있으면 싸우고, 없으면 왕궁 쪽으로 움직입니다. 일반 24명 네 배치에서는 2.5초 이상 대기·복구 상태로 멈춘 구간이 없었습니다.

소환 전면을 먼저 고정하고, 그 안에서 부대 경로 공유와 개별 공격 자리를 계산하는 방식으로 정리했습니다.

혼잡 구간의 남은 과제

100명 집중 배치에서는 최장 9.7초의 대기·복구 구간이 남아 있습니다. 혼잡 구간의 공격 자리 경쟁과 재판단 대기를 줄이는 것이 다음 최적화 과제입니다.

직접 맡은 범위

소환 전면 판단, 6인 근접 부대와 팀장 경로 재사용, 개인 공격 자리, 이동·잔여 HP 비용, 지역 공격과 선택적 재검토, 전투 기록 재생 도구를 설계·구현했습니다.

관련 코드

소환 전면에서 처음 만나는 장애물의 대표 경로HexCastleAssaultNavigation.cs
        public bool TryResolveFrontRoute(HexCoordinates start, float damagePerSecond, float moveSpeed,
            int topologyVersion, out HexCastleAssaultRoutePlan plan)
        {
            plan = null;
            if (!cells.ContainsKey(start) || palaceFootprint.Contains(start) ||
                !HexAttackSlotAllocator.Finite(damagePerSecond) || !HexAttackSlotAllocator.Finite(moveSpeed)) return false;
            FrontRouteBuildCount++;
            var path = new List<HexCoordinates> { start };
            var current = start;
            var axis = start.ToWorld(1f);
            var hasObstacle = false;
            var obstacle = default(HexCoordinates);
            var approach = start;
            var cost = 0f;
            while (!palaceApproaches.Contains(current))
            {
                var found = false;
                var next = default(HexCoordinates);
                var bestDeviation = float.PositiveInfinity;
                for (var direction = 0; direction < 6; direction++)
                {
                    var candidate = current.Neighbor(direction);
                    if (candidate.DistanceFromOrigin >= current.DistanceFromOrigin ||
                        !cells.TryGetValue(candidate, out var cell) || cell == null || palaceFootprint.Contains(candidate)) continue;
                    var point = candidate.ToWorld(1f);
                    var deviation = Mathf.Abs(axis.x * point.z - axis.z * point.x);
                    if (found && (deviation > bestDeviation + .0001f ||
                        Mathf.Abs(deviation - bestDeviation) <= .0001f && candidate.CompareTo(next) >= 0)) continue;
                    next = candidate; bestDeviation = deviation; found = true;
                }
                if (!found) return false;
                var target = cells[next];
                if (!target.CanTraverse(HexCastleTraversalFaction.Assault) && !target.IsDamageable) return false;
                cost += Vector3.Distance(cells[current].transform.position, target.transform.position) / Mathf.Max(.1f, moveSpeed);
                if (target.IsBlocked)
                {
                    if (!hasObstacle) { obstacle = next; approach = current; hasObstacle = true; }
                    cost += target.CurrentHealth / Mathf.Max(.1f, damagePerSecond);
                }
                path.Add(next); current = next;
            }
            var sector = ResolveSector(start);
            var anchor = hasObstacle ? obstacle : current;
            plan = new HexCastleAssaultRoutePlan(path, cost, current, obstacle, approach, hasObstacle,
                ResolveRouteId(sector, anchor), sector, topologyVersion);
            return true; // 소환 전면에서 첫 돌파 대상 선택
        }
부대 접근 탐색 공유와 열린 자리로 전진HexAssaultApproachPlanner.cs
        public void BeginDecision(HexCastleAssaultUnit unit)
        {
            sharedSearch = null;
            actor = unit; distance.Clear(); previous.Clear(); queue.Clear(); order.Clear(); paths.Clear();
            if (unit == null || !Open(unit.CurrentCoordinates)) return;
            FullSearchCount++;
            SearchOpenCells(unit.CurrentCoordinates, int.MaxValue);
            FullVisitedCellCount += order.Count;
        }

        public void BeginDecision(HexCastleAssaultUnit unit, int cohortId, HexCastleAssaultUnit leader)
        {
            if (cohortId <= 0 || leader == null || !leader.IsAlive) { BeginDecision(unit); return; }
            if (!sharedSearches.TryGetValue(cohortId, out var search))
            {
                BeginDecision(leader);
                search = new SharedSearch { Root = leader.CurrentCoordinates, Order = order.ToArray(),
                    Previous = new Dictionary<HexCoordinates, HexCoordinates>(previous) };
                search.Dependencies.Add(search.Root);
                sharedSearches.Add(cohortId, search); SharedSearchBuildCount++;
            }
            actor = unit; activeLeader = leader; activeCohort = cohortId; sharedSearch = search; retriedSharedSearch = false;
            distance.Clear(); previous.Clear(); queue.Clear(); order.Clear(); paths.Clear();
            if (unit == null || !Open(unit.CurrentCoordinates)) return;
            LocalConnectionSearchCount++;
            SearchOpenCells(unit.CurrentCoordinates, 3);
            LocalVisitedCellCount += order.Count;
            foreach (var c in search.Order) if (!distance.ContainsKey(c)) order.Add(c);
        }

        public void ForgetCohort(int cohortId) => sharedSearches.Remove(cohortId);

        public void InvalidateSharedSearchesAt(HexCoordinates changed)
        {
            var affected = new List<int>();
            foreach (var pair in sharedSearches)
            {
                var touches = pair.Value.Dependencies.Contains(changed);
                for (var d = 0; d < 6 && !touches; d++) touches = pair.Value.Dependencies.Contains(changed.Neighbor(d));
                if (touches) affected.Add(pair.Key);
            }
            foreach (var id in affected) sharedSearches.Remove(id);
        }

        private void SearchOpenCells(HexCoordinates start, int maximumSteps)
        {
            distance.Add(start, 0); queue.Enqueue(start);
            while (queue.Count > 0)
            {
                var c = queue.Dequeue(); order.Add(c);
                if (distance[c] >= maximumSteps) continue;
                for (var d = 0; d < 6; d++)
                {
                    var next = c.Neighbor(d);
                    if (distance.ContainsKey(next) || !Open(next) ||
                        !HexRoutePlanner.CanTraverseStep(cells[c], cells[next], d, HexCastleTraversalFaction.Assault)) continue;
                    distance.Add(next, distance[c] + 1); previous.Add(next, c); queue.Enqueue(next);
                }
            }
        }
        public bool TryPlan(HexCastleAssaultUnit unit, HexCastleAssaultTarget target, int range,
            Func<HexAttackSlotKey, bool> attackPredicate, bool holding,
            out HexAssaultApproachPlan plan, out HexAssaultFailureReason failure,
            Action<HexAttackSlotKey, HexAssaultFailureReason> rejected = null)
        {
            plan = null; failure = HexAssaultFailureReason.NoReachableApproach;
            if (actor != unit) BeginDecision(unit);
            if (unit == null || (!holding && !target.IsValid)) { failure = HexAssaultFailureReason.TargetInvalid; return false; }
            var best = float.PositiveInfinity;
            var radius = unit.SlotBodyRadius;
            var revision = slots.Revision;
            var hasReachablePath = false;
            for (var oi = 0; oi < order.Count; oi++)
            {
                var c = order[oi];
                if (holding)
                {
                    if (!distance.TryGetValue(c, out var holdingDistance) || holdingDistance > 3 || !HoldingCell(c)) continue;
                }
                else if (c.DistanceTo(target.Coordinates) > Math.Max(1, range)) continue;
                var path = Reconstruct(c);
                if (path.Length == 0) continue;
                if (!holding && !StaysOnAdvanceFront(unit, target, range, path))
                { failure = HexAssaultFailureReason.SlotApproachBlocked; continue; }
                hasReachablePath = true;
                var lowerBound = Math.Max(0, path.Length - 3) * pitch / unit.EffectiveMoveSpeed;
                if (lowerBound > best + 0.0001f) continue;
                var entry = path.Length <= 2 ? unit.transform.position : Centre(path[path.Length - 2]);
                var prefixLength = 0f; var prefixPosition = unit.transform.position;
                for (var pi = 1; pi < path.Length - 1; pi++)
                {
                    var next = Centre(path[pi]); next.y = prefixPosition.y;
                    prefixLength += Vector3.Distance(prefixPosition, next); prefixPosition = next;
                }
                for (var si = 0; si < slots.CandidateCount(radius); si++)
                {
                    var key = slots.Candidate(c, radius, si);
                    if (!slots.CanReserve(unit, key, radius, out var reason))
                    { failure = reason; rejected?.Invoke(key, reason); continue; }
                    if (attackPredicate != null && !attackPredicate(key))
                    { failure = HexAssaultFailureReason.AttackLaneBlocked; rejected?.Invoke(key, failure); continue; }
                    var destination = slots.Position(key); destination.y = unit.GroundWorldY;
                    var flatEntry = entry; flatEntry.y = destination.y;
                    // 우회 거리는 직선거리 이상입니다.
                    // 동률은 기존 후보 순서를 따릅니다.
                    if ((prefixLength + Vector3.Distance(flatEntry, destination)) / unit.EffectiveMoveSpeed >= best - 0.0001f) continue;
                    if (!TryFinalPath(unit, entry, destination, c, out var local, out var localLength))
                    { failure = HexAssaultFailureReason.SlotApproachBlocked; rejected?.Invoke(key, failure); continue; }
                    var cost = (prefixLength + localLength) / unit.EffectiveMoveSpeed;
                    if (cost >= best - 0.0001f) continue;
                    best = cost;
                    plan = new HexAssaultApproachPlan(key, path, local, cost, revision, order.Count);
                }
            }
            if (plan == null)
            {
                if (!hasReachablePath && sharedSearch != null && !retriedSharedSearch && activeLeader != null &&
                    sharedSearch.Root != activeLeader.CurrentCoordinates)
                {
                    ForgetCohort(activeCohort);
                    BeginDecision(unit, activeCohort, activeLeader); retriedSharedSearch = true;
                    return TryPlan(unit, target, range, attackPredicate, holding, out plan, out failure, rejected);
                }
                return false;
            }
            failure = HexAssaultFailureReason.None;
            return true;
        }
        private static bool StaysOnAdvanceFront(HexCastleAssaultUnit unit, HexCastleAssaultTarget target,
            int range, HexCoordinates[] path)
        {
            if (target.Structure == null) return true; // 위협 대응과 아군 지원의 이동은 제한하지 않는다.
            var outerRing = Math.Max(unit.CurrentCoordinates.DistanceFromOrigin,
                target.Coordinates.DistanceFromOrigin + Math.Max(1, range));
            foreach (var cell in path)
                if (cell.DistanceFromOrigin > outerRing) return false; // 공격 자리 탐색을 현재 전면 안으로 제한
            return true;
        }
        private bool HoldingCell(HexCoordinates c)
        {
            if (!Open(c)) return false; // 파괴되어 열린 구조물 자리도 전진 후보에 포함한다.
            var open = 0;
            for (var d = 0; d < 6; d++) if (Open(c.Neighbor(d))) open++;
            return open >= 4; // 1칸 통로에서는 대기하지 않습니다.
        }
같은 전면의 실제 이동·정렬과 잔여 HP 파괴 비용HexCastleAssaultWorld.cs
        private sealed class AdvanceEstimate
        {
            public HexCastleCellRuntime Target;
            public HexAssaultApproachPlan Approach;
            public int MoveMs, DestroyMs, ContinuationMs;
            public float SupportingDps;
            public int TotalMs => MoveMs + DestroyMs + ContinuationMs;
        }

        private bool TryEstimateAdvance(HexCastleAssaultUnit unit, HexCastleCellRuntime target, out AdvanceEstimate estimate)
        {
            estimate = null;
            if (target == null || !target.IsAlive) return false;
            var palace = target == palaceCore;
            var assaultTarget = new HexCastleAssaultTarget(target, palace);
            var range = palace ? HexCastleFoundationGenerator.PalaceFootprintRadius + 1 : unit.AttackRangeCells;
            if (!approachPlanner.TryPlan(unit, assaultTarget, range,
                    slot => !unit.ShouldAvoidSlot(slot) && CanAttackFromPosition(unit, assaultTarget, slot), false,
                    out var approach, out _)) return false; // 즉시 예약 불가 자리는 무한 대기로 취급
            var supportingDps = ActiveAttackDps(target, approach.Cost);
            estimate = new AdvanceEstimate { Target = target, Approach = approach, SupportingDps = supportingDps,
                MoveMs = Mathf.CeilToInt(approach.Cost * 1000f),
                DestroyMs = palace ? 0 : EstimateRemainingDestructionMilliseconds(target.CurrentHealth,
                    unit.EstimatedDamagePerSecond, supportingDps, approach.Cost),
                ContinuationMs = 0 }; // 현재 전면의 작업 비용만 비교한다.
            return true;
        }

        private bool TryResolveEfficientAdvance(HexCastleAssaultUnit unit, HexCastleAssaultRoutePlan route,
            out HexCastleAssaultDecision decision)
        {
            decision = default;
            var estimates = new List<AdvanceEstimate>();
            if (!route.HasFirstObstacle && TryEstimateAdvance(unit, palaceCore, out var palace)) estimates.Add(palace);
            foreach (var target in cells.Values.Where(value => value != null).OrderBy(value => value.Coordinates))
            {
                if (!IsOnAssaultFront(route, target) || !target.IsAlive || !target.IsBlocked || !target.IsDamageable || target == palaceCore ||
                    target.Kind == HexCastleCellKind.Palace ||
                    target.DefenseLayer > unit.ExpectedDefenseLayer && target.Coordinates.DistanceTo(unit.CurrentCoordinates) > LocalWorkRadiusCells) continue;
                if (target != unit.CommittedTarget.Structure && target.Coordinates != route.FirstObstacle &&
                    !(IsRingWall(target) && target.DefenseLayer == unit.ExpectedDefenseLayer) &&
                    target.Coordinates.DistanceTo(unit.CurrentCoordinates) > LocalWorkRadiusCells) continue;
                var routeId = ResolveDecisionRouteId(route.SectorId, target.Coordinates);
                if (IsOuterRingWall(target) && !CanReserveBreach(routeId, target)) continue;
                if (TryEstimateAdvance(unit, target, out var estimate)) estimates.Add(estimate);
            }
            if (estimates.Count == 0) return false;
            estimates.Sort((a, b) => a.TotalMs != b.TotalMs ? a.TotalMs.CompareTo(b.TotalMs) : a.Target.Coordinates.CompareTo(b.Target.Coordinates));
            var selected = estimates[0];
            var current = estimates.Find(value => value.Target == unit.CommittedTarget.Structure);
            var retainCurrent = current != null && current != selected && ShouldRetainCurrentAdvance(
                unit.RecoveryRequested, Time.time - unit.LastTargetSwitchTime, current.TotalMs, selected.TotalMs);
            if (retainCurrent && Time.time - unit.LastTargetSwitchTime < 1f)
                unit.DeferRouteCostReview(1f - (Time.time - unit.LastTargetSwitchTime));
            if (retainCurrent) selected = current;
            var intent = selected.Target == palaceCore ? HexCastleAssaultIntentKind.Palace :
                !unit.HasSelectedInitialWall && IsRingWall(selected.Target) && selected.Target.DefenseLayer == unit.ExpectedDefenseLayer
                    ? HexCastleAssaultIntentKind.InitialBreach : HexCastleAssaultIntentKind.Progress;
            var changed = unit.CommittedTarget.IsValid && selected.Target != unit.CommittedTarget.Structure;
            if (!TryCreateCellDecision(unit, selected.Target, selected.Target == palaceCore, route, intent,
                    out decision, null, changed, HexAssaultFailureReason.None)) return false;
            var alternative = estimates.FirstOrDefault(value => value != selected);
            unit.RecordAdvanceCost(selected.MoveMs, selected.DestroyMs, selected.ContinuationMs,
                alternative?.Target.Coordinates, alternative?.TotalMs ?? 0);
            if (selected.SupportingDps > 0 || alternative?.SupportingDps > 0) unit.DeferRouteCostReview(1f);
            if (selected.Target != palaceCore)
                unit.RecordBreachCostEvaluation(selected.Target.Coordinates, selected.MoveMs / 1000f, selected.DestroyMs / 1000f,
                    unit.EstimatedDamagePerSecond + selected.SupportingDps, alternative?.Target.Coordinates, (alternative?.MoveMs ?? 0) / 1000f,
                    (alternative?.DestroyMs ?? 0) / 1000f);
            return true;
        }

        private static bool ShouldRetainCurrentAdvance(bool recovering, float sinceSwitch, int currentMs, int bestMs) =>
            !recovering && (sinceSwitch < 1f || currentMs - bestMs < Math.Max(1000, Mathf.CeilToInt(currentMs * .1f)));

        private void RefreshActiveAttackDps(HexCastleAssaultUnit selectingUnit)
        {
            activeAttackDps.Clear(); // 판단 1회당 실제 정착·공격 중인 병력만 집계
            foreach (var attacker in units)
            {
                if (attacker == null || attacker == selectingUnit || !attacker.IsAlive ||
                    attacker.ExecutionState != HexAssaultExecutionState.Attacking ||
                    attacker.CurrentTarget.Structure == null ||
                    !CanAttackFromSlot(attacker, attacker.CurrentTarget, attacker.CurrentSlotLease)) continue;
                var key = attacker.CurrentTarget.Structure.GetInstanceID();
                activeAttackDps.TryGetValue(key, out var previous);
                activeAttackDps[key] = previous + attacker.EstimatedDamagePerSecond;
            }
        }

        private float ActiveAttackDps(HexCastleCellRuntime target, float approachSeconds) => approachSeconds <= 1f && target != null &&
            activeAttackDps.TryGetValue(target.GetInstanceID(), out var value) ? value : 0f;

        private static int EstimateRemainingDestructionMilliseconds(float hp, float ownDps, float activeDps, float approachSeconds)
        {
            var remaining = Mathf.Max(0f, hp - Mathf.Max(0f, activeDps) * Mathf.Max(0f, approachSeconds));
            return Mathf.CeilToInt(remaining / Mathf.Max(.1f, ownDps + Mathf.Max(0f, activeDps)) * 1000f);
        }
자리 포화 시 지역 공격과 열린 전면의 대기 자리 갱신HexCastleAssaultWorld.cs
        private bool TryResolveLocalFallback(HexCastleAssaultUnit unit, out HexCastleAssaultDecision decision, int beforeTravelMs = int.MaxValue)
        {
            decision = default;
            var hasRoute = TryResolveCohortRoute(unit, out var route);
            if (!hasRoute) route = new HexCastleAssaultRoutePlan(new[] { unit.CurrentCoordinates }, 0f,
                unit.CurrentCoordinates, default, unit.CurrentCoordinates, false, unit.RouteId,
                unit.RouteSector, topologyVersion); // 지역 전투 문맥만 유지
            // 대기 중에도 가까운 전면을 공격합니다.
            // 전략 목표와 부대 소속은 유지합니다.
            var estimates = new List<BreachTimeEstimate>();
            foreach (var target in cells.Values.Where(c => IsEligibleLocalWorkTarget(unit, c, null, true) &&
                         IsInDeploymentFront(unit, c.Coordinates)))
            {
                if (TryEstimateBreachTime(unit, target, out var estimate)) estimates.Add(estimate);
            }
            var nearest = estimates.Count == 0 ? 0 : estimates.Min(value => value.MovementMilliseconds);
            foreach (var estimate in estimates.Where(value => value.MovementMilliseconds <= nearest + LocalWorkMovementBandMilliseconds)
                         .OrderBy(value => value.Target.CurrentHealth).ThenBy(value => value.TotalMilliseconds).ThenBy(value => value.Target.Coordinates))
            {
                if (beforeTravelMs != int.MaxValue && !ShouldCompleteLocalWorkBeforeTravel(beforeTravelMs, estimate.TotalMilliseconds)) continue;
                var routeId = ResolveDecisionRouteId(route.SectorId, estimate.Target.Coordinates);
                if (TryCreateCellDecision(unit, estimate.Target, false, route, HexCastleAssaultIntentKind.LocalFallback,
                        out decision, routeId))
                {
                    RecordBreachCostEvaluation(unit, estimate, estimates);
                    return true;
                }
            }
            return false;
        }

        private bool TryResolveRankedOverflowTarget(
            HexCastleAssaultUnit unit,
            HexCastleAssaultRoutePlan route,
            HexCastleCellRuntime saturatedTarget,
            HexAssaultFailureReason saturationReason,
            out HexCastleAssaultDecision decision)
        {
            decision = default;
            var estimates = new List<BreachTimeEstimate>();
            foreach (var target in cells.Values.Where(value =>
                         IsEligibleLocalWorkTarget(unit, value, saturatedTarget, true) && IsOnAssaultFront(route, value)))
            {
                if (TryEstimateBreachTime(unit, target, out var estimate))
                {
                    estimates.Add(estimate);
                }
            }

            // 가까운 작업의 완료 시간을 비교합니다.
            // 먼저 끝나면 기존 목표를 유지한 채 처리합니다.
            // 서로 다른 최종 목표는 직접 비교하지 않습니다.
            var minimumMovementMilliseconds = estimates.Count > 0
                ? estimates.Min(value => value.MovementMilliseconds) : int.MaxValue;
            var fourthPriority = estimates
                .Where(value => value.MovementMilliseconds <= minimumMovementMilliseconds + LocalWorkMovementBandMilliseconds)
                .OrderBy(value => value.Target.CurrentHealth)
                .ThenBy(value => value.TotalMilliseconds)
                .ThenBy(value => value.Target.Coordinates)
                .Take(1).ToArray();
            var ranked = estimates
                .Where(value => value.Target.DefenseLayer <= unit.ExpectedDefenseLayer && IsStrategicOverflowTarget(saturatedTarget, value.Target))
                .OrderBy(value => value.TotalMilliseconds)
                .ThenBy(value => value.Target.Coordinates)
                .Take(MaximumRankedOverflowTargets)
                .ToArray();
            for (var index = 0; index < ranked.Length; index++)
            {
                var estimate = ranked[index];
                if (fourthPriority.Length > 0 &&
                    ShouldCompleteLocalWorkBeforeTravel(estimate.MovementMilliseconds, fourthPriority[0].TotalMilliseconds)) continue;
                var routeId = ResolveDecisionRouteId(route.SectorId, estimate.Target.Coordinates);
                if (!TryCreateCellDecision(
                        unit,
                        estimate.Target,
                        false,
                        route,
                        HexCastleAssaultIntentKind.Progress,
                        out decision,
                        routeId,
                        true,
                        saturationReason))
                {
                    continue;
                }

                RecordBreachCostEvaluation(unit, estimate, estimates);
                return true;
            }

            // 네 번째 우선순위는 가까운 임시 공격입니다.
            // 최근접 작업과 250ms 이내 후보만 남깁니다.
            // 그중 남은 HP가 가장 낮은 대상을 고릅니다.
            if (fourthPriority.Length > 0)
            {
                var estimate = fourthPriority[0];
                var routeId = ResolveDecisionRouteId(route.SectorId, estimate.Target.Coordinates);
                if (TryCreateCellDecision(
                        unit,
                        estimate.Target,
                        false,
                        route,
                        HexCastleAssaultIntentKind.LocalFallback,
                        out decision,
                        routeId))
                {
                    RecordBreachCostEvaluation(unit, estimate, estimates);
                    return true;
                }
            }

            return false;
        }

        private static bool ShouldCompleteLocalWorkBeforeTravel(int travelMilliseconds, int localCompletionMilliseconds)
            => travelMilliseconds > (long)localCompletionMilliseconds + 1000L;

        private static bool IsStrategicOverflowTarget(
            HexCastleCellRuntime saturatedTarget,
            HexCastleCellRuntime candidate)
        {
            if (saturatedTarget == null || candidate == null)
            {
                return false;
            }

            if (IsRingWall(saturatedTarget))
            {
                return IsRingWall(candidate) &&
                       candidate.DefenseLayer == saturatedTarget.DefenseLayer;
            }

            if (saturatedTarget.BuildingRole != HexCastleBuildingRole.None)
            {
                return candidate.BuildingRole == saturatedTarget.BuildingRole;
            }

            return candidate.Kind == saturatedTarget.Kind;
        }

        private static bool IsEligibleLocalWorkTarget(
            HexCastleAssaultUnit unit,
            HexCastleCellRuntime target,
            HexCastleCellRuntime excluded, bool allowRecoveryLayer = false)
        {
            if (unit == null || target == null || target == excluded || !target.IsAlive ||
                !target.IsBlocked || !target.IsDamageable || target.Kind == HexCastleCellKind.Palace ||
                (!allowRecoveryLayer && target.DefenseLayer > unit.ExpectedDefenseLayer) ||
                target.Coordinates.DistanceTo(unit.CurrentCoordinates) > LocalWorkRadiusCells)
            {
                return false;
            }

            var targetPosition = target.Coordinates.ToWorld(1f);
            var unitPosition = unit.CurrentCoordinates.ToWorld(1f);
            return Vector3.Dot(targetPosition, unitPosition) >= 0f;
        }

        private bool TryEstimateBreachTime(
            HexCastleAssaultUnit unit,
            HexCastleCellRuntime target,
            out BreachTimeEstimate estimate)
        {
            estimate = default;
            if (unit == null || target == null || !target.IsAlive || !target.IsBlocked || !target.IsDamageable)
                return false;
            var assaultTarget = new HexCastleAssaultTarget(target, false);
            if (!approachPlanner.TryPlan(unit, assaultTarget, unit.AttackRangeCells,
                    slot => !unit.ShouldAvoidSlot(slot) && CanAttackFromPosition(unit, assaultTarget, slot), false,
                    out var approach, out _)) return false;
            // 이 유닛이 끝낼 수 있는 시간을 비교합니다.
            // 부대 선호는 제한 범위 안에서만 적용합니다.
            var ownDps = Mathf.Max(0.1f, unit.EstimatedDamagePerSecond);
            var supportingDps = ActiveAttackDps(target, approach.Cost);
            var expectedDps = ownDps + supportingDps;
            estimate = new BreachTimeEstimate(
                target,
                Mathf.CeilToInt(approach.Cost * 1000f),
                EstimateRemainingDestructionMilliseconds(target.CurrentHealth, ownDps, supportingDps, approach.Cost),
                expectedDps);
            return true;
        }

        private static void RecordBreachCostEvaluation(
            HexCastleAssaultUnit unit,
            BreachTimeEstimate selected,
            IEnumerable<BreachTimeEstimate> candidates)
        {
            var alternatives = candidates
                .Where(value => value.Target != selected.Target)
                .OrderBy(value => value.TotalMilliseconds)
                .ThenBy(value => value.Target.Coordinates)
                .Take(1)
                .ToArray();
            var hasAlternative = alternatives.Length > 0;
            var alternative = hasAlternative ? alternatives[0] : default;
            unit.RecordBreachCostEvaluation(
                selected.Target.Coordinates,
                selected.MovementMilliseconds / 1000f,
                selected.DestructionMilliseconds / 1000f,
                selected.ExpectedDamagePerSecond,
                hasAlternative ? alternative.Target.Coordinates : (HexCoordinates?)null,
                hasAlternative ? alternative.MovementMilliseconds / 1000f : 0f,
                hasAlternative ? alternative.DestructionMilliseconds / 1000f : 0f);
        }

        private bool TryCreateHoldPlan(HexCastleAssaultUnit unit, out HexCastleAssaultDecision decision)
        {
            decision = default;
            if (unit.IsInSafeMotion || unit.CurrentTarget.IsValid) return false;
            HexAssaultApproachPlan candidate = null;
            if (TryResolveCohortRoute(unit, out var route))
            {
                var front = route.HasFirstObstacle ? route.FirstObstacleApproach : route.DestinationApproach;
                var distance = unit.CurrentCoordinates.DistanceTo(front);
                for (var progress = Math.Min(3, distance); progress > 0 && candidate == null; progress--)
                {
                    var remaining = distance - progress;
                    approachPlanner.TryPlan(unit, default, 3, slot =>
                        slot.Cell.DistanceTo(front) <= remaining &&
                        slot.Cell.DistanceFromOrigin <= unit.CurrentCoordinates.DistanceFromOrigin &&
                        IsInDeploymentFront(unit, slot.Cell), true, out candidate, out _);
                }
            }
            if (candidate == null && unit.TryRetainHold(topologyVersion, out decision)) return true;
            if (candidate == null && !approachPlanner.TryPlan(unit, default, 3, null, true, out candidate, out _)) return false;
            if (!slotAllocator.TryCommit(unit, default, candidate.Slot, unit.SlotBodyRadius, candidate.Cost,
                    candidate.OccupancyRevision, true, out var lease, out _)) return false;
            ReleaseBreachReservation(unit);
            decision = new HexCastleAssaultDecision(default, candidate.Cells, candidate.Slot.Cell, unit.RouteId,
                unit.RouteSector, topologyVersion, HexCastleAssaultIntentKind.HoldPosition,
                slotLease: lease, finalApproach: candidate.FinalPath, planKind: HexAssaultPlanKind.Hold);
            Record(HexAssaultTraceKind.SlotReserved, unit, 0, lease, HexAssaultFailureReason.WaitingForOpening);
            return true;
        }
Unity Navigation 비의존 계약HexCastleGenerationTests.cs
        [Test]
        public void RuntimeAssembly_DoesNotReferenceSquareCastleRaidAssembly()
        {
            var references = typeof(HexCoordinates).Assembly.GetReferencedAssemblies()
                .Select(reference => reference.Name)
                .ToArray();

            Assert.That(references, Does.Not.Contain("ProjectMT.Contents.CastleRaid"));
        }

        [Test]
        public void RuntimeAssembly_DoesNotReferenceNavigationAssemblies()
        {
            var references = typeof(HexCoordinates).Assembly.GetReferencedAssemblies()
                .Select(reference => reference.Name)
                .ToArray();

            Assert.That(references, Does.Not.Contain("Unity.AI.Navigation"));
            Assert.That(references, Does.Not.Contain("UnityEngine.AIModule"));
        }
최대 6명 근접 부대의 가입·팀장·경로 재사용HexCastleAssaultWorld.cs
        private HexCastleAssaultCohortAssignment ResolveCohort(
            HexCastleAssaultUnit unit,
            HexCastleAssaultRoutePlan route,
            int? routeIdOverride)
        {
            var unitId = unit.GetInstanceID();
            MaintainCohort(unit);
            if (unitCohorts.TryGetValue(unitId, out var existing))
            {
                return existing;
            }

            CohortRecord selected = null;
            for (var index = 0; index < cohorts.Count; index++)
            {
                var cohort = cohorts[index];
                RefreshCohortLeader(cohort);
                if (cohort.MemberCount >= MaximumCohortSize || !CanJoinCohort(unit, cohort, CohortJoinDistanceCells))
                {
                    continue;
                }

                selected = cohort;
                break;
            }

            if (selected == null)
            {
                if (route == null) navigation.TryResolveFrontRoute(unit.CurrentCoordinates,
                    unit.EstimatedDamagePerSecond, unit.EffectiveMoveSpeed, topologyVersion, out route);
                selected = new CohortRecord
                {
                    CohortId = nextCohortId++,
                    RouteId = routeIdOverride ?? route?.RouteId ?? unit.RouteId,
                    SectorId = route?.SectorId ?? unit.RouteSector,
                    SharedRoute = route
                };
                cohorts.Add(selected);
            }

            selected.Members.Add(unit);
            RefreshCohortLeader(selected);
            var assignment = new HexCastleAssaultCohortAssignment(
                selected.CohortId,
                selected.RouteId,
                selected.SectorId);
            unitCohorts[unitId] = assignment;
            return assignment;
        }

        private bool TryResolveCohortRoute(HexCastleAssaultUnit unit, out HexCastleAssaultRoutePlan route)
        {
            var assignment = ResolveCohort(unit, null, null);
            var cohort = cohorts.First(value => value.CohortId == assignment.CohortId);
            var leader = cohort.Leader;
            route = cohort.SharedRoute;
            if (route != null && route.HasFirstObstacle &&
                (!cells.TryGetValue(route.FirstObstacle, out var obstacle) || obstacle == null ||
                 obstacle.CanTraverse(HexCastleTraversalFaction.Assault) ||
                 leader.CurrentCoordinates.DistanceFromOrigin < route.FirstObstacle.DistanceFromOrigin)) route = null;
            if (route != null) { SharedFrontRouteReuseCount++; return true; }
            if (leader == null || !navigation.TryResolveFrontRoute(leader.CurrentCoordinates,
                    leader.EstimatedDamagePerSecond, leader.EffectiveMoveSpeed, topologyVersion, out route)) return false;
            cohort.SharedRoute = route; cohort.RouteVersion++;
            return true;
        }

        public HexCastleAssaultUnit GetCohortLeader(HexCastleAssaultUnit unit)
        {
            if (unit == null || !unitCohorts.TryGetValue(unit.GetInstanceID(), out var assignment)) return null;
            var cohort = cohorts.FirstOrDefault(value => value.CohortId == assignment.CohortId);
            if (cohort == null) return null;
            RefreshCohortLeader(cohort);
            return cohort.Leader;
        }

        private void RefreshCohortLeader(CohortRecord cohort)
        {
            cohort.Members.RemoveAll(member => member == null || !member.IsAlive);
            HexCastleAssaultUnit leader = null;
            foreach (var member in cohort.Members)
                if (leader == null || member.CurrentCoordinates.DistanceFromOrigin < leader.CurrentCoordinates.DistanceFromOrigin ||
                    member.CurrentCoordinates.DistanceFromOrigin == leader.CurrentCoordinates.DistanceFromOrigin &&
                    ResolveSpawnOrder(member) < ResolveSpawnOrder(leader)) leader = member;
            cohort.Leader = leader; // 팀장 승계 후 경로·자리·목표 유지
        }

        private bool CanJoinCohort(HexCastleAssaultUnit unit, CohortRecord cohort, int steps)
        {
            var leader = cohort.Leader;
            if (leader == null || !unit.IsAlive) return false;
            if (leader == unit) return true;
            var from = deploymentFronts.TryGetValue(unit.GetInstanceID(), out var a) ? a : unit.CurrentCoordinates;
            var to = deploymentFronts.TryGetValue(leader.GetInstanceID(), out var b) ? b : leader.CurrentCoordinates;
            if (Vector3.Dot(from.ToWorld(1f).normalized, to.ToWorld(1f).normalized) < .5f) return false;
            return HasShortCohortConnection(unit.CurrentCoordinates, leader.CurrentCoordinates, steps);
        }

        private bool IsInDeploymentFront(HexCastleAssaultUnit unit, HexCoordinates target)
        {
            var start = deploymentFronts.TryGetValue(unit.GetInstanceID(), out var deployed) ? deployed : unit.CurrentCoordinates;
            return Vector3.Dot(start.ToWorld(1f).normalized, target.ToWorld(1f).normalized) >= .5f;
        }

        private bool HasShortCohortConnection(HexCoordinates start, HexCoordinates goal, int steps)
        {
            if (start.DistanceTo(goal) > steps || !cells.TryGetValue(start, out var first) ||
                first == null || !first.CanTraverse(HexCastleTraversalFaction.Assault)) return false;
            if (start == goal) return true;
            cohortJoinDistances.Clear(); cohortJoinQueue.Clear();
            cohortJoinDistances.Add(start, 0); cohortJoinQueue.Enqueue(start);
            while (cohortJoinQueue.Count > 0)
            {
                var at = cohortJoinQueue.Dequeue();
                if (cohortJoinDistances[at] >= steps) continue;
                for (var d = 0; d < 6; d++)
                {
                    var next = at.Neighbor(d);
                    if (cohortJoinDistances.ContainsKey(next) || !cells.TryGetValue(next, out var cell) ||
                        cell == null || !HexRoutePlanner.CanTraverseStep(cells[at], cell, d, HexCastleTraversalFaction.Assault)) continue;
                    if (next == goal) return true;
                    cohortJoinDistances.Add(next, cohortJoinDistances[at] + 1); cohortJoinQueue.Enqueue(next);
                }
            }
            return false;
        }

        private void MaintainCohort(HexCastleAssaultUnit unit)
        {
            if (unit == null || !unitCohorts.TryGetValue(unit.GetInstanceID(), out var assignment)) return;
            var current = cohorts.FirstOrDefault(value => value.CohortId == assignment.CohortId);
            if (current == null) { unitCohorts.Remove(unit.GetInstanceID()); return; }
            RefreshCohortLeader(current);
            if (!unit.IsAlive || !CanJoinCohort(unit, current, CohortJoinDistanceCells + 1))
            {
                ReleaseCohort(unit);
                if (unit.IsAlive) unit.RequestStrategicDecision(true);
                return;
            }
            foreach (var candidate in cohorts)
            {
                RefreshCohortLeader(candidate);
                if (candidate.CohortId >= current.CohortId || candidate.MemberCount + current.MemberCount > MaximumCohortSize ||
                    !CanJoinCohort(unit, candidate, CohortJoinDistanceCells)) continue;
                var canMerge = true;
                foreach (var member in current.Members)
                    if (!CanJoinCohort(member, candidate, CohortJoinDistanceCells)) { canMerge = false; break; }
                if (!canMerge) continue;
                foreach (var member in current.Members)
                {
                    candidate.Members.Add(member);
                    unitCohorts[member.GetInstanceID()] = new HexCastleAssaultCohortAssignment(candidate.CohortId, candidate.RouteId, candidate.SectorId);
                    Record(HexAssaultTraceKind.CohortReassigned, member);
                }
                current.Members.Clear(); approachPlanner?.ForgetCohort(current.CohortId); cohorts.Remove(current);
                RefreshCohortLeader(candidate);
                return; // 더 이른 부대로만 합쳐 왕복 재가입을 막는다.
            }
        }
공성 병력의 일곱 실행 상태HexAssaultExecutionContracts.cs
    public enum HexAssaultExecutionState { Inactive, AwaitInitialPlan, Traversing, Aligning, Attacking, Holding, Recovering }
이동·정렬·공격·대기 상태 전환HexCastleAssaultUnit.cs
        private void TickDynamicRuntime(float deltaTime)
        {
            if (attackActionRunning)
            {
                ExecutionState = HexAssaultExecutionState.Attacking;
                TickAttackAction(deltaTime); return;
            }
            if (motionActive)
            {
                if (trapMovementLockRemaining > 0f) { animationDriver?.PlayIdle(); return; }
                if (!CanDynamicStep(motionFrom, motionTo))
                {
                    motionActive = false;
                    MarkNoProgress(HexAssaultFailureReason.NoReachableApproach);
                    return;
                }
                if (motionFinal && !assaultWorld.ApproachPlanner.SegmentClear(this, transform.position, motionEnd))
                {
                    motionActive = false;
                    if (TryRefreshFinalApproach()) { BeginFinalApproach(deltaTime); return; }
                    MarkNoProgress(HexAssaultFailureReason.SlotApproachBlocked);
                    return;
                }
                AdvanceMotion(deltaTime); return;
            }
            if (movementPath != null && pathIndex < movementPath.Count - 1)
            {
                if (trapMovementLockRemaining > 0f) { animationDriver?.PlayIdle(); return; }
                var next = movementPath[pathIndex + 1];
                if (!CanDynamicStep(CurrentCoordinates, next))
                { MarkNoProgress(HexAssaultFailureReason.NoReachableApproach); return; }
                if (!finishSafeSegmentOnly && activePlan.FinalApproach != null && activePlan.FinalApproach.Count > 0 && pathIndex == movementPath.Count - 2)
                { BeginFinalApproach(deltaTime); return; }
                BeginMotion(next, ResolvePosition(next), false);
                AdvanceMotion(deltaTime); return;
            }
            if (activePlan.SlotLease.IsValid && finalApproachIndex < activePlan.FinalApproach.Count)
            {
                if (trapMovementLockRemaining > 0f) { animationDriver?.PlayIdle(); return; }
                BeginFinalApproach(deltaTime); return;
            }
            if (activePlan.PlanKind == HexAssaultPlanKind.Hold)
            {
                ExecutionState = HexAssaultExecutionState.Holding;
                strategicDecisionRequested = true; // 재판단 시점은 대기 시간이 결정한다.
                CheckNoProgress(); animationDriver?.PlayIdle(); return;
            }
            if (!currentTarget.IsValid)
            {
                ExecutionState = HexAssaultExecutionState.Holding;
                strategicDecisionRequested = true;
                CheckNoProgress(); animationDriver?.PlayIdle(); return;
            }
            if (currentTarget.Kind == HexCastleAssaultTargetKind.Ally)
            { TickSupportTarget(); return; }
            if (!CanAttackTarget(currentTarget))
            {
                strategicDecisionRequested = true;
                CheckNoProgress(); animationDriver?.PlayIdle(); return;
            }
            ExecutionState = HexAssaultExecutionState.Attacking;
            animationDriver?.PlayIdle();
            FaceTowards(ResolveTargetPosition(currentTarget), deltaTime);
            if (Time.time >= nextAttackTime) StartDynamicAttack(currentTarget);
        }
03육각 성 생성

내부 골격을 보존하고 전장 제약을 검사하는 육각 성 생성

육각 좌표에서 다양한 성의 실루엣을 만들되, 방어층 확장·진입 경로·시설 동선이 지켜야 할 조건을 별도로 검사합니다.

Unity 실행 화면4중벽 전체 구조성을 회전하며 네 개 방어층과 관문·시설 배치를 확인할 수 있습니다.
핵심 도식

생성과 검증을 나눈 처리 흐름

입력 조건으로 성 후보를 만든 뒤 전장 제약을 검사하고, 실행 배치와 회귀 비교값을 함께 남깁니다.
설계 판단

설계 배경

문제

2~4중 성벽, 왕궁, 관문, 격벽, 병영 출구와 배치 구역이 동시에 성립해야 했습니다. 모양만 그럴듯한 랜덤 배치는 막힌 출구나 끊어진 방어층처럼 플레이 불가능한 결과를 만들 수 있었습니다.

선택

실루엣과 방어층을 계획하고 육각 셀에 관문·시설·함정을 배치합니다. HexCastleValidator는 위상과 6방향 진입·시설 계약을 검사하며, StructureSignature와 LayoutSignature는 골격과 전체 결과의 변화를 나눠 비교합니다.

검토한 대안

검증된 성 Prefab을 미리 제작하면 안정적이지만 조합 수와 반복 플레이가 제한됩니다. 무제약 랜덤은 다양성을 늘리는 만큼 재현과 회귀 검증이 어려워, 규칙 기반 생성과 후보 검증을 함께 선택했습니다.

처리 흐름
  1. 01Theme·Seed재현 입력
  2. 02Silhouette폐곡선·방어층
  3. 03Axial Grid육각 셀 투영
  4. 04Placement관문·시설·함정
  5. 05Validation연결성·출구
  6. 06Signature회귀 비교

처리 결과생성기는 성 배치와 배치 조건의 검사 결과를 함께 반환합니다.

구현

  • 9개 테마의 생성 규칙과 2→4중벽 확장 시 안쪽 Ring 좌표 보존을 구현했습니다.
  • 성벽 연결 수, 진입 방향별 방어층 통과, 병영 출구와 관문의 통행 계약을 검사합니다.
  • 구조 서명과 전체 배치 서명을 나누고 같은 입력의 재현·다른 Seed의 변화를 회귀 테스트로 비교합니다.

테마와 난이도에 따라 전장을 생성합니다

Theme은 실루엣 생성 규칙을, Seed는 재현 가능한 변화를, 난이도는 방어층과 시설 조건을 결정합니다. 육각 축 좌표 (Q, R) 위에 방어층·관문·시설·함정을 배치하고, 생성 결과와 검증 보고서를 함께 다룹니다. 연결성·진입 경로·시설 동선 검사로 플레이 가능한 배치인지 확인합니다.

구현 근거HexCastleFoundationGenerator.cs 보기HexCastleSilhouettePlanner.cs 보기HexCastleCandidate.cs 보기

2중벽을 4중벽으로 확장해도 안쪽 골격은 유지합니다

9개 테마의 실루엣 규칙을 각각 구현했습니다. 방어층을 늘릴 때 기존 안쪽 Ring의 셀 목록은 유지하고 바깥 Ring을 추가합니다. 방어층 확장 테스트에서 2→3→4중벽의 기존 Ring 좌표를 비교합니다. 실루엣 골격은 보존하고, 시설과 능력치는 난이도에 따라 바뀝니다.

  1. 2중벽기존 Ring A · B
  2. 3중벽A · B 유지 + C
  3. 4중벽A · B · C 유지 + D

같은 Seed에서 방어층을 추가할 때의 골격 보존 관계입니다.

구현 근거HexCastleGenerationTests.cs 보기

생성 후 확인하는 배치 조건

검사 대상실제 검사찾아내는 실패
성벽 위상일반 성벽의 연결 수 2, 연결 타워의 분기 조건을 검사합니다.끊어진 벽이나 잘못 남은 연결 타워
6방향 진입외곽의 각 진입 방향에서 왕궁까지 경로와 방어층 통과 순서를 확인합니다.도달 불가 또는 방어층을 건너뛰는 경로
병영 출구인접 이동 가능 빈 셀이 2개 이상인지 확인합니다.생성 직후 병력이 나올 수 없는 시설
관문 계약열린 격벽 성문의 진영별 통행과 앞뒤 접근 셀을 검사합니다.공격대·수비대의 통행 규칙 불일치
시설·함정역할·등급·배치 Band와 난이도별 수량, 함정 중복·위치·간격을 검사합니다.보기에는 배치됐지만 게임 규칙과 맞지 않는 구성

구현 근거HexCastleCandidate.cs 보기HexCastleFoundationGenerator.cs 보기HexCastleGenerationTests.cs 보기

서명을 둘로 나눠 변경 원인을 좁힙니다

같은 Theme·Seed·난이도·생성 설정·규칙 버전으로 생성 결과를 재현합니다. 골격의 변화와 시설·능력치를 포함한 배치 변화를 두 서명으로 나눠 비교합니다.

비교값포함 범위사용 목적
StructureSignature성벽 경로·왕궁 셀의 좌표와 구조·연결 상태골격과 통행 구조가 변했는지 비교
LayoutSignature전체 셀과 체력·보상·시설 등 배치 상태내용까지 포함한 생성 결과를 비교

구현 근거HexCastleGenerationTests.cs 보기HexCastleLayout.cs 보기

직접 맡은 범위

육각 좌표 기반 생성 규칙, 테마 실루엣, 방어층·배치 제약, 검증과 서명 흐름을 직접 설계·통합했습니다.

관련 코드

Seed·Theme·난이도에서 Layout 생성HexCastleFoundationGenerator.cs
        public HexCastleLayout Generate(
            int seed,
            int defenseLayerCount,
            HexCastleTheme theme = HexCastleTheme.CentralCompartment,
            HexCastleThemeOneTuning tuning = null)
        {
            return GenerateInternal(seed, defenseLayerCount, theme, tuning, null);
        }

        public HexCastleLayout GenerateForDifficulty(
            int seed,
            int difficultyLevel,
            HexCastleTheme theme = HexCastleTheme.CentralCompartment,
            HexCastleThemeOneTuning tuning = null)
        {
            var profile = HexCastleDifficultyProfile.Resolve(difficultyLevel, seed);
            return GenerateInternal(seed, profile.DefenseLayerCount, theme, tuning, profile);
        }

        private static HexCastleLayout GenerateInternal(
            int seed,
            int defenseLayerCount,
            HexCastleTheme theme,
            HexCastleThemeOneTuning tuning,
            HexCastleDifficultyProfile difficultyProfile)
        {
            if (defenseLayerCount < 2 || defenseLayerCount > CanonicalWallRadii.Length)
            {
                throw new ArgumentOutOfRangeException(nameof(defenseLayerCount), "방어선은 2~4중이어야 합니다.");
            }

            if (!HexCastleSilhouettePlanner.SupportedThemes.Contains(theme))
            {
                throw new ArgumentOutOfRangeException(
                    nameof(theme),
                    $"{theme}은 정식 육각 성 테마가 아닙니다.");
            }

            tuning = tuning ?? HexCastleThemeOneTuning.CreateDraftDefaults();
            tuning.Validate(defenseLayerCount);

            var wallRadii = ResolveCanonicalWallRadii(defenseLayerCount).ToArray();
            var requiredGateSocketCount = difficultyProfile == null
                ? 1
                : Enumerable.Range(0, wallRadii.Length - 1)
                    .Max(bandIndex => ResolveOpenPartitionGateTargetCount(
                        seed,
                        bandIndex,
                        tuning,
                        difficultyProfile));
            var plan = HexCastleSilhouettePlanner.Build(
                theme,
                seed,
                wallRadii,
                requiredGateSocketCount);
            var buildRadius = plan.MaximumRadius;
            var boardRadius = buildRadius + HexSpatialContract.MinimumDeploymentRings;
            var request = new HexCastleGenerationRequest(
                seed,
                theme,
                defenseLayerCount,
                boardRadius,
                buildRadius,
                PalaceFootprintRadius,
                difficultyProfile?.Level ?? 0);
            var cells = HexCoordinates.EnumerateRadius(boardRadius).ToDictionary(
                coordinate => coordinate,
                coordinate => CreateOpenCell(coordinate, buildRadius));

            ApplyPalace(cells, tuning);
            var reservedGateApproaches = ApplyWallNetwork(
                cells,
                seed,
                plan,
                wallRadii,
                tuning,
                difficultyProfile);
            ExpandExteriorDeployment(cells); // 실제 성벽 굴곡 바깥의 열린 바닥까지 배치 영역으로 연결
            var palaceGuardDirection = ApplyPalaceGuardBarracks(
                cells,
                seed,
                defenseLayerCount,
                tuning,
                difficultyProfile);
            ApplyPalaceGuardTurrets(cells, seed, defenseLayerCount, tuning, palaceGuardDirection);
            ApplyFoundationBuildings(
                cells,
                seed,
                plan,
                wallRadii,
                tuning,
                reservedGateApproaches,
                difficultyProfile);
            var traps = HexCastleTrapPlanner.Build(
                cells,
                wallRadii,
                difficultyProfile,
                buildRadius,
                seed);
            return new HexCastleLayout(
                request,
                cells,
                wallRadii,
                FoundationRulesVersionBase + tuning.DraftVersion,
                traps);
9개 테마 실루엣과 형식 검증HexCastleSilhouettePlanner.cs
        public static readonly IReadOnlyList<HexCastleTheme> SupportedThemes = new[]
        {
            HexCastleTheme.CentralCompartment,
            HexCastleTheme.DiamondRadial,
            HexCastleTheme.CompositeCompartments,
            HexCastleTheme.HexHoneycomb,
            HexCastleTheme.PetalBloom,
            HexCastleTheme.CrystalMandala,
            HexCastleTheme.FractalBastion,
            HexCastleTheme.VoronoiCrystal,
            HexCastleTheme.IrisShutter
        };

        public static HexCastleSilhouettePlan Build(
            HexCastleTheme theme,
            int seed,
            IReadOnlyList<int> wallRadii,
            int requiredGateSocketCountPerBand = 1)
        {
            ValidateRadii(wallRadii);
            requiredGateSocketCountPerBand = Math.Max(1, Math.Min(2, requiredGateSocketCountPerBand));
            HexCastleSilhouettePlan result;
            switch (theme)
            {
                case HexCastleTheme.CentralCompartment:
                    result = BuildCentralPlan(wallRadii);
                    break;
                case HexCastleTheme.DiamondRadial:
                    result = BuildDiamondPlan(seed, wallRadii);
                    break;
                case HexCastleTheme.CompositeCompartments:
                    result = BuildCompositePlan(seed, wallRadii);
                    break;
                case HexCastleTheme.HexHoneycomb:
                    result = BuildHoneycombPlan(seed, wallRadii);
                    break;
                case HexCastleTheme.PetalBloom:
                    result = BuildPetalPlan(seed, wallRadii);
                    break;
                case HexCastleTheme.CrystalMandala:
                    result = BuildCrystalPlan(seed, wallRadii);
                    break;
                case HexCastleTheme.FractalBastion:
                    result = BuildFractalBastionPlan(seed, wallRadii);
                    break;
                case HexCastleTheme.VoronoiCrystal:
                    result = BuildVoronoiCrystalPlan(seed, wallRadii);
                    break;
                case HexCastleTheme.IrisShutter:
                    result = BuildIrisShutterPlan(seed, wallRadii);
                    break;
                default:
                    throw new InvalidOperationException(
                        $"{theme}은 아직 실제 육각 생성 규칙이 구현되지 않은 테마입니다.");
            }

            result = EnsureFormalGateSockets(result, seed, requiredGateSocketCountPerBand);
            ValidatePlan(result);
            return result;
동일 Seed의 서명 재현과 테마 확장 보존HexCastleGenerationTests.cs
        [Test]
        public void ThemeOneFoundation_IsDeterministicAndSeedChangesBuildingLayout()
        {
            var generator = new HexCastleFoundationGenerator();
            var first = generator.Generate(10801, 3);
            var repeat = generator.Generate(10801, 3);
            var changed = generator.Generate(10802, 3);

            Assert.That(repeat.LayoutSignature, Is.EqualTo(first.LayoutSignature));
            Assert.That(repeat.StructureSignature, Is.EqualTo(first.StructureSignature));
            Assert.That(changed.StructureSignature, Is.Not.EqualTo(first.StructureSignature));
            Assert.That(changed.LayoutSignature, Is.Not.EqualTo(first.LayoutSignature));
        }

        [Test]
        public void FormalThemeCatalog_IsExactlyAThroughIAndPreservesInnerRingsWhenExpanded()
        {
            const int seed = 10801;
            var themes = HexCastleThemeCatalog.ComparisonThemes;
            Assert.That(themes, Is.EqualTo(new[]
            {
                HexCastleTheme.CentralCompartment,
                HexCastleTheme.DiamondRadial,
                HexCastleTheme.CompositeCompartments,
                HexCastleTheme.HexHoneycomb,
                HexCastleTheme.PetalBloom,
                HexCastleTheme.CrystalMandala,
                HexCastleTheme.FractalBastion,
                HexCastleTheme.VoronoiCrystal,
                HexCastleTheme.IrisShutter
            }));
            Assert.That(themes.Select(HexCastleThemeCatalog.ResolveCode),
                Is.EqualTo("ABCDEFGHI".ToCharArray()));
            var signatures = new HashSet<string>();
            foreach (var theme in themes)
            {
                HexCastleSilhouettePlan previous = null;
                for (var defenseLayers = 2; defenseLayers <= 4; defenseLayers++)
                {
                    var radii = HexCastleFoundationGenerator.ResolveCanonicalWallRadii(defenseLayers);
                    var plan = HexCastleSilhouettePlanner.Build(theme, seed, radii);
                    Assert.That(plan.Rings.Count, Is.EqualTo(defenseLayers), theme.ToString());
                    if (previous != null)
                    {
                        for (var index = 0; index < previous.Rings.Count; index++)
                        {
                            Assert.That(plan.Rings[index].Cells,
                                Is.EqualTo(previous.Rings[index].Cells),
                                $"{theme}의 {index + 1}중벽 안쪽 골격이 증축 중 바뀌었습니다.");
                        }
                    }

                    previous = plan;
                }

                var largestPlan = previous;
                var signature = string.Join("|", largestPlan.Rings.Select(ring =>
                    string.Join(";", ring.Cells.Select(cell => $"{cell.Q},{cell.R}"))));
                Assert.That(signatures.Add(signature), Is.True, $"{theme} 외곽·중벽이 다른 후보와 같습니다.");
            }
        }
테마·방어층·Seed 후보의 연결성 검사HexCastleGenerationTests.cs
        [Test]
        public void FormalThemes_SeedSweepPreservesConnectedTopologyAndValidation()
        {
            var pipeline = new HexCastleGenerationPipeline();
            var themes = HexCastleThemeCatalog.Themes;
            foreach (var theme in themes)
            {
                for (var defenseLayers = 2; defenseLayers <= 4; defenseLayers++)
                {
                    for (var index = 0; index < 24; index++)
                    {
                        var seed = unchecked(10801 + index * 7919);
                        HexCastleCandidate candidate = null;
                        Assert.DoesNotThrow(() => candidate = pipeline.GenerateFoundation(
                            seed,
                            defenseLayers,
                            theme), $"{theme}/{defenseLayers}중벽/Seed {seed}");
                        Assert.That(candidate.Validation.IsValid, Is.True,
                            $"{theme}/{defenseLayers}중벽/Seed {seed}: " +
                            string.Join(" | ", candidate.Validation.Errors));
                    }
                }
            }
        }
성벽 연결 수와 여섯 진입 경로의 방어층 순서 검사HexCastleCandidate.cs
            var wallTopology = HexCastleWallTopologyResolver.Build(layout);
            foreach (var pair in wallTopology)
            {
                var cell = layout.Cells[pair.Key];
                if (cell.Kind == HexCastleCellKind.Tower && pair.Value.ConnectionCount < 3)
                {
                    errors.Add($"2방향 성벽 {pair.Key}에 불필요한 연결 타워가 남았습니다.");
                }

                if (cell.Kind == HexCastleCellKind.Wall && pair.Value.ConnectionCount != 2)
                {
                    errors.Add($"일반 성벽 {pair.Key}의 연결 수가 2가 아닙니다.");
                }
            }

            var planner = new HexRoutePlanner();
            for (var direction = 0; direction < HexCoordinates.Directions.Length; direction++)
            {
                var start = HexCoordinates.Directions[direction] * layout.BattlefieldRadius;
                var route = planner.FindMinimumBreachRoute(layout, start);
                routes.Add(route);
                if (!route.IsComplete)
                {
                    errors.Add($"진입 방향 {direction}에서 왕궁까지 경로가 없습니다.");
                    continue;
                }

                var expectedLayers = Enumerable.Range(1, layout.DefenseLayerCount).ToArray();
                if (!route.CrossedDefenseLayers.SequenceEqual(expectedLayers))
                {
                    errors.Add($"진입 방향 {direction}의 방어층 순서가 {string.Join(",", route.CrossedDefenseLayers)}입니다.");
                }
            }
골격과 전체 배치를 서로 다른 범위로 계산하는 서명HexCastleLayout.cs
        private string CalculateSignature(bool includeContents)
        {
            unchecked
            {
                const ulong offset = 1469598103934665603UL;
                const ulong prime = 1099511628211UL;
                var hash = offset;
                var source = includeContents
                    ? cells.Values
                    : cells.Values.Where(cell =>
                        cell.IsWallPathCell || cell.Kind == HexCastleCellKind.Palace);
                foreach (var cell in source.OrderBy(value => value.Coordinates))
                {
                    hash ^= (uint)cell.Coordinates.Q;
                    hash *= prime;
                    hash ^= (uint)cell.Coordinates.R;
                    hash *= prime;
                    hash ^= (uint)cell.Kind;
                    hash *= prime;
                    hash ^= (uint)cell.DefenseLayer;
                    hash *= prime;
                    hash ^= (uint)cell.WallRole;
                    hash *= prime;
                    hash ^= (uint)cell.DistrictId;
                    hash *= prime;
                    hash ^= (uint)(cell.WallConnectionMask + 1);
                    hash *= prime;
                    if (cell.HasExplicitGateState)
                    {
                        hash ^= 0x47415433u;
                        hash *= prime;
                        hash ^= (uint)cell.GateRole;
                        hash *= prime;
                        hash ^= (uint)cell.GatePassageMask;
                        hash *= prime;
                    }
                    if (includeContents)
                    {
                        hash ^= (uint)Mathf.RoundToInt(cell.HitPoints * 10f);
                        hash *= prime;
                        hash ^= (uint)cell.RewardValue;
                        hash *= prime;
04공용 전투

공격 규칙과 연출을 분리한 공용 전투 실행기

Profile은 공격 판정 규칙을, Binding은 몬스터별 모션·VFX·SFX 연결을 담당합니다. Validator에서 지원 조합을 확인한 뒤 공용 Executor로 실행합니다.

기본공격 조립소에서 공격 방식과 판정 범위를 미리 확인하는 화면
실제 화면기본공격 조립소 · 판정 미리보기공격 Recipe의 수치와 실제 판정 범위를 함께 확인합니다.
핵심 도식

판정 규칙과 표현 데이터의 합류

Profile은 공격 규칙을, Binding은 몬스터별 표현을 맡고, 검증된 조합만 공용 Executor가 실행합니다.
설계 판단

설계 배경

문제

몬스터마다 공격 MonoBehaviour를 만들면 피해 판정, 투사체, 다단 히트, 모션과 효과 연결이 함께 복제됩니다. 수정 범위가 커지고 실행기가 처리할 수 없는 데이터 조합도 자산에 들어갈 수 있었습니다.

선택

Delivery·Collision·Sequence·Movement·Shape를 독립 축으로 정의하고 Profile이 규칙을, Binding이 몬스터별 표현을 소유하게 했습니다. Validator가 지원하지 않는 조합을 자산 단계에서 차단한 뒤 공용 Executor가 해석합니다.

검토한 대안

공격마다 클래스를 두면 국소 수정은 쉽지만 공통 판정이 복제됩니다. 범용 노드 그래프는 제작·검증 도구의 범위를 늘립니다. 현재 필요한 동작을 명시적으로 지원하는 데이터 계약을 선택했습니다.

DeliveryDirect · Projectile · Traveling Area
CollisionDirect · Stop · Pierce · Area · Pass
SequenceSingle · Burst · Return
MovementNone · Dash
ShapeSingle · Fan · Line · Circle
처리 흐름
  1. 01Profile판정 Recipe
  2. 02Validator불가능 조합 차단
  3. 03Binding KeyAttack·Slot·Motion
  4. 04Executor공용 실행 분기
  5. 05RuntimeDamage·VFX·SFX

처리 결과Validator가 Profile의 조합을 검사하고, Executor는 통과한 입력으로 공격을 실행합니다.

구현

  • Profile의 다섯 모듈 축과 피해 예산을 검증하고 Returning·Dash·Sweep 같은 조합별 불변식을 명시했습니다.
  • Attack ID·Slot ID·Motion ID를 Binding Key로 사용해 같은 판정이라도 몬스터별 모션과 VFX를 연결합니다.
  • Executor는 입력과 시전자 수명을 확인하며, 시간 분할 부채꼴 판정은 한 실행 안에서 중복 타깃을 제외합니다.

실제 Profile 세 개, 하나의 Executor

실제 자산주요 조합공용 실행 경로
BA_M_05 · 짧은 돌진 타격Direct / DirectResolve / Single / Dash / SingleDash 이동 처리 → 원점 재계산 → ApplyInstantHit
BA_M_06 · 단일 다단 타격Direct / DirectResolve / Burst / None / Single, 피해 비율 0.3·0.3·0.4첫 타격 → ContinueRepeatedHit으로 후속 단계 처리
BA_S_01 · 왕복 투사체Projectile / PassThrough / ReturnPasses / None / Line, 피해 비율 0.6·0.4LaunchProjectiles → Returning 이동과 왕복 피해 단계

구현 근거MonsterBasicAttackExecutor.cs 보기

Profile에서 지원 조합을 검사합니다

Returning은 ReturnPasses·단일 투사체·2단계 피해·PassThrough가 함께 있어야 합니다. Burst는 2단계 이상의 Direct 공격만 허용합니다. 방향성 Sweep은 Direct·Fan·Single이어야 하며 Dash는 양수 이동 거리가 필요합니다. 이 조건을 Profile 검증과 제작 흐름에서 확인하고, Executor는 검증된 입력을 처리하는 역할에 집중합니다.

구현 근거MonsterBasicAttackProfile.cs 보기

Monster Maker와 연결되는 지점

Profile이 판정 규칙을, Binding이 Attack·Slot·Motion ID에 따른 몬스터별 모션·VFX·SFX 연결을 소유합니다. Maker에서 입력과 연결을 검증하고 Runtime에서는 같은 실행기를 사용합니다.

구현 근거MonsterBasicAttackBindingProjection.cs 보기MonsterBasicAttackExecutor.cs 보기

새 공격 동작을 추가할 때

기존 Profile로 표현할 수 없는 동작을 추가할 때는 데이터 정의와 조합 검사, Executor의 실행 처리도 함께 확장합니다.

직접 맡은 범위

공격 데이터 구조, Binding 연결, 지원 조합 검사와 공용 공격 실행기를 설계·구현했습니다.

관련 코드

공격 Recipe의 다섯 모듈 축MonsterBasicAttackProfile.cs
    public enum MonsterBasicAttackDeliveryModule // 피해가 목표까지 전달되는 핵심 방식
    {
        Direct,
        Projectile,
        TravelingArea
    }

    public enum MonsterBasicAttackCollisionModule // 이동 판정의 충돌 처리
    {
        DirectResolve,
        StopOnFirstTarget,
        Pierce,
        AreaImpact,
        PassThrough
    }

    public enum MonsterBasicAttackSequenceModule // 한 Marker 뒤 피해 단계 구성
    {
        Single,
        Burst,
        ReturnPasses
    }

    public enum MonsterBasicAttackMovementModule // 공격자 논리 루트 이동
    {
        None,
        Dash
    }

    public enum MonsterBasicAttackPresentationKind // 기본 연출 슬롯을 고르는 비전투 태그
    {
        Contact,
        Shot,
        Sweep,
        Thrust,
        Slam,
        Explosion,
        Instant,
        Dash,
        Combo,
        Scatter,
        Returning,
        Breath,
        Beam,
        Wave
    }

    public enum MonsterBasicAttackShape // XZ 판정 모양
    {
        Single,
        Fan,
        Line,
        Circle
    }
지원하지 않는 모듈 조합 차단MonsterBasicAttackProfile.cs
        private bool TryValidateModuleCombination(out string error)
        {
            if (DeliveryModule != MonsterBasicAttackDeliveryModule.Direct && combatType != MonsterCombatType.Ranged)
            {
                error = $"Moving Basic Attack delivery requires Ranged combat type. Profile={name}";
                return false;
            }

            if (DeliveryModule == MonsterBasicAttackDeliveryModule.Direct)
            {
                if (projectileTravel != MonsterBasicAttackProjectileTravel.None ||
                    CollisionModule != MonsterBasicAttackCollisionModule.DirectResolve || projectileCount != 1)
                {
                    error = $"Direct Basic Attack cannot use projectile travel, collision, or volley. Profile={name}";
                    return false;
                }
            }
            else if (DeliveryModule == MonsterBasicAttackDeliveryModule.Projectile)
            {
                if (projectileTravel == MonsterBasicAttackProjectileTravel.None ||
                    CollisionModule == MonsterBasicAttackCollisionModule.DirectResolve)
                {
                    error = $"Projectile Basic Attack requires travel and collision modules. Profile={name}";
                    return false;
                }

                if (CollisionModule == MonsterBasicAttackCollisionModule.AreaImpact && shape != MonsterBasicAttackShape.Circle)
                {
                    error = $"Area-impact Projectile requires a Circle shape. Profile={name}";
                    return false;
                }

                if (CollisionModule == MonsterBasicAttackCollisionModule.Pierce &&
                    projectileTravel != MonsterBasicAttackProjectileTravel.Straight)
                {
                    error = $"Piercing Projectile requires Straight travel. Profile={name}";
                    return false;
                }
            }
            else if (projectileTravel != MonsterBasicAttackProjectileTravel.Straight ||
                     CollisionModule != MonsterBasicAttackCollisionModule.PassThrough || projectileCount != 1)
            {
                error = $"Traveling Area requires Straight travel, PassThrough collision, and one actor. Profile={name}";
                return false;
            }

            switch (SequenceModule)
            {
                case MonsterBasicAttackSequenceModule.Single when HitCount != 1:
                    error = $"Single sequence requires one damage ratio. Profile={name}";
                    return false;
                case MonsterBasicAttackSequenceModule.Burst when
                    HitCount < 2 || DeliveryModule != MonsterBasicAttackDeliveryModule.Direct:
                    error = $"Burst sequence requires two or more Direct damage stages. Profile={name}";
                    return false;
                case MonsterBasicAttackSequenceModule.ReturnPasses when
                    HitCount != 2 || DeliveryModule != MonsterBasicAttackDeliveryModule.Projectile ||
                    projectileTravel != MonsterBasicAttackProjectileTravel.Returning ||
                    CollisionModule != MonsterBasicAttackCollisionModule.PassThrough || projectileCount != 1:
                    error = $"Return-pass sequence requires one Returning Projectile and two damage ratios. Profile={name}";
                    return false;
            }

            if (projectileTravel == MonsterBasicAttackProjectileTravel.Returning &&
                SequenceModule != MonsterBasicAttackSequenceModule.ReturnPasses)
            {
                error = $"Returning travel requires ReturnPasses sequence. Profile={name}";
                return false;
            }

            if (projectileCount > 1 &&
                (DeliveryModule != MonsterBasicAttackDeliveryModule.Projectile ||
                 projectileTravel != MonsterBasicAttackProjectileTravel.Straight ||
                 CollisionModule == MonsterBasicAttackCollisionModule.DirectResolve))
            {
                error = $"Volley requires a Straight Projectile collision recipe. Profile={name}";
                return false;
            }

            if (MovementModule == MonsterBasicAttackMovementModule.Dash &&
                dashDistance <= 0f)
            {
                error = $"Dash movement requires a positive distance. Profile={name}";
                return false;
            }

            if (sweepDirection != MonsterBasicAttackSweepDirection.Simultaneous &&
                (DeliveryModule != MonsterBasicAttackDeliveryModule.Direct ||
                 shape != MonsterBasicAttackShape.Fan || SequenceModule != MonsterBasicAttackSequenceModule.Single))
            {
                error = $"Directional sweep requires one Direct Fan hit. Profile={name}";
                return false;
            }

            error = null;
            return true;
현재 Profile과 Motion에 필요한 Binding 투영MonsterBasicAttackBindingProjection.cs
    public static class MonsterBasicAttackBindingProjection // Draft 보관값에서 현재 공격에 필요한 연결만 투영
    {
        public static IReadOnlyList<MonsterBasicAttackBindingRequirement> BuildRequirements(
            MonsterBasicAttackProfile profile,
            IReadOnlyList<MonsterMakerAttackDraft> attacks)
        {
            var result = new List<MonsterBasicAttackBindingRequirement>();
            if (profile == null)
            {
                return result;
            }

            var motionIds = ResolveMotionIds(attacks);
            foreach (var slot in profile.VfxSlots)
            {
                if (slot == null)
                {
                    continue;
                }

                if (slot.AssignmentScope == MonsterBasicAttackVfxAssignmentScope.MonsterShared)
                {
                    result.Add(new MonsterBasicAttackBindingRequirement(profile.AttackId, slot, string.Empty));
                    continue;
                }

                foreach (var motionId in motionIds)
                {
                    result.Add(new MonsterBasicAttackBindingRequirement(profile.AttackId, slot, motionId));
                }
            }

            return result;
        }

        public static IReadOnlyList<MonsterBasicAttackVfxBinding> BuildActiveBindings(
            MonsterMakerDraft draft)
        {
            var result = new List<MonsterBasicAttackVfxBinding>();
            if (draft?.BasicAttackProfile == null)
            {
                return result;
            }

            foreach (var requirement in BuildRequirements(draft.BasicAttackProfile, draft.Attacks))
            {
                var binding = ResolveLast(draft.BasicAttackVfxBindings, requirement.Key);
                if (binding != null)
                {
                    result.Add(binding);
                }
            }

            return result;
공격 Profile을 해석하는 공용 실행 분기MonsterBasicAttackExecutor.cs
    public sealed class MonsterBasicAttackExecutor : IMonsterActionExecutor // 15종 Profile을 조립 실행하는 단일 경계
    {
        private readonly List<UnitActor> targets = new List<UnitActor>();

        public bool Execute(MonsterActionExecutionContext context)
        {
            var action = context.AssetSet?.CombatProfile?.Action;
            var profile = context.ResolvedAttackBlock;
            if (profile == null || context.World == null || context.Source == null ||
                context.PrimaryTarget == null || !context.PrimaryTarget.IsAlive)
            {
                return false;
            }

            var origin = ResolveOrigin(context);
            var primaryPosition = context.PrimaryTarget.Position;
            var forward = ResolveForward(context.Source.transform.forward, origin, primaryPosition);
            context.World.ShowMonsterBasicAttackArea(
                profile,
                context.Source,
                origin,
                forward,
                primaryPosition,
                context.ResolvedAttackRange);

            if (profile.MovementModule == MonsterBasicAttackMovementModule.Dash)
            {
                var primaryActor = ResolveActor(context.PrimaryTarget);
                var stopDistance = context.Source.BodyRadius + (primaryActor?.BodyRadius ?? 0.25f);
                if (profile.VfxSlots.Count > 0)
                {
                    MonsterBasicAttackVfxRuntime.Dispatch(
                        MonsterBasicAttackVfxEvent.DashExit,
                        CreateVfxContext(
                            context,
                            profile,
                            origin,
                            primaryPosition,
                            primaryPosition,
                            Quaternion.LookRotation(forward, Vector3.up)));
                }
                context.Source.AdvanceForBasicAttack(
                    primaryPosition,
                    profile.DashDistance,
                    stopDistance,
                    profile.DashDuration / context.PlaybackSpeed);
                origin = context.Source.transform.position;
                forward = ResolveForward(forward, origin, primaryPosition);
                if (profile.VfxSlots.Count > 0)
                {
                    MonsterBasicAttackVfxRuntime.Dispatch(
                        MonsterBasicAttackVfxEvent.DashEnter,
                        CreateVfxContext(
                            context,
                            profile,
                            origin,
                            primaryPosition,
                            primaryPosition,
                            Quaternion.LookRotation(forward, Vector3.up)));
                }
            }

            var attackRotation = Quaternion.LookRotation(forward, Vector3.up);
            if (profile.VfxSlots.Count > 0)
            {
                MonsterBasicAttackVfxRuntime.Dispatch(
                    MonsterBasicAttackVfxEvent.RecipeExecute,
                    CreateVfxContext(context, profile, origin, primaryPosition, primaryPosition, attackRotation));
            }
            else
            {
                context.World.PlayMonsterFeedbackAt(
                    profile.LaunchFeedback,
                    origin,
                    attackRotation,
                    context.AssetSet?.BodyProfile?.VfxScale ?? 1f);
            }
            context.World.PlayBasicAttackFeelAt(
                profile.LaunchFeel,
                origin,
                attackRotation,
                context.AssetSet?.BodyProfile?.VfxScale ?? 1f,
                ResolveUnitFeelTarget(context.Source),
                ResolveFeelIntensity(context));

            if (profile.UsesProjectileVisual)
            {
                return LaunchProjectiles(
                    context,
                    profile,
                    context.AttackBlock == null ? action as ProjectileActionDefinition : null,
                    origin,
                    forward);
            }

            if (profile.Progression != MonsterBasicAttackProgression.Simultaneous &&
                profile.SequenceModule == MonsterBasicAttackSequenceModule.Single)
            {
                context.World.StartCoroutine(ContinueProgressiveHit(context, profile, origin, forward));
                return true;
            }

            if (profile.Shape == MonsterBasicAttackShape.Fan &&
                profile.SweepDirection != MonsterBasicAttackSweepDirection.Simultaneous &&
                profile.SequenceModule == MonsterBasicAttackSequenceModule.Single)
            {
                context.World.StartCoroutine(ContinueFanSweep(context, profile, origin, forward));
                return true;
            }

            if (profile.SequenceModule == MonsterBasicAttackSequenceModule.Burst)
            {
                var firstApplied = ApplyInstantHit(context, profile, origin, forward, 0);
                context.World.StartCoroutine(ContinueRepeatedHit(context, profile, origin, forward));
                return firstApplied;
            }

            return ApplyInstantHit(context, profile, origin, forward, 0);
        }
05저장·정산

저장 완료를 기준으로 진행과 정산을 확정하는 구조

GameDataService는 현재 진행 데이터의 복제본에 변경을 적용하고 저장합니다. SaveAsync가 성공하면 복제본을 현재 데이터로 교체하고, ContentFlow가 보상 화면을 표시합니다.

콘텐츠 정산과 저장 이후 표시되는 클리어 보상 결과 화면
실제 화면콘텐츠 결과 · 저장 이후 보상 표시완료 결과와 보상 UI는 정산 데이터의 저장이 끝난 뒤 열립니다.
핵심 도식

확정 상태와 변경 후보를 분리한 저장 트랜잭션

GameDataService가 저장 후 진행값을 확정하면 ContentFlow가 보상을 표시합니다.
설계 판단

설계 배경

문제

보상·성장·장비 변경은 여러 진행값을 함께 바꾸며, 콘텐츠 종료 콜백과 재시도가 중복 접수될 수 있습니다. 파일보다 메모리나 결과 화면을 먼저 바꾸면 쓰기 실패 뒤 손실·중복 정산이 발생할 수 있었습니다.

선택

GameDataService가 변경을 직렬화하고 current의 복제본에만 변경을 적용한 뒤 SaveAsync 완료 후 current를 교체합니다. 파일은 .tmp에 완성한 뒤 본 파일과 교체하고, ContentFlow는 종료 결과와 정산의 중복 진입을 각각 차단합니다.

검토한 대안

현재 값을 먼저 바꾸고 나중에 자동 저장하거나, 보상 화면을 먼저 보여준 뒤 저장할 수도 있습니다. 구현은 간단하지만 실패 후 어느 상태가 진실인지와 재시도에서 무엇을 다시 지급할지가 모호해져 채택하지 않았습니다.

처리 흐름
  1. 01Exit Once결과 중복 차단
  2. 02Pending Change동일 변경 유지
  3. 03Serialize Gate동시 변경 직렬화
  4. 04Clone & Apply후보 변경·검증
  5. 05SaveAsync파일 교체·후처리
  6. 06Commit & Present진행·보상 확정

처리 결과저장이 실패하면 ContentFlow는 같은 PendingChange로 재시도합니다.

구현

  • SemaphoreSlim으로 진행 변경을 한 건씩 처리하고, Clone–TryApply–Save–Commit 순서로 확정 전 상태를 분리합니다.
  • SaveService가 직렬화 결과를 다시 역직렬화해 버전과 데이터를 검증한 뒤 AtomicFileStore에 전달합니다.
  • ContentFlow의 ExitAccepted와 SettlementInFlight가 느린 콜백·연속 클릭의 중복 접수를 막고, 실패 시 동일 PendingChange로 재시도합니다.

두 개의 중복 방지와 하나의 확정 경계

ContentFlow의 ExitAccepted는 결과의 중복 접수를, SettlementInFlight는 진행 중 정산의 중복 실행을 막습니다. GameDataService는 SemaphoreSlim 안에서 current.Clone()에 변경을 적용하고 SaveAsync가 반환된 뒤에만 current를 교체합니다. 저장 실패 시 같은 PendingChange를 유지해 재시도합니다.

구현 근거GameDataService.cs 보기ContentFlow.cs 보기ContentFlow.cs 보기

현재 남아 있는 오류 상황

상황현재 동작과 한계
쓰기·교체 전 실패후보가 current를 대체하지 않습니다. 콘텐츠 정산은 실패 상태를 표시하고 같은 변경으로 재시도합니다.
File.Replace 미지원File.Copy로 덮어쓰는 대체 경로를 사용합니다. 이 경로는 원자적 교체가 아닙니다.
교체 후 정리 실패본 파일 교체 후 .bak 삭제가 실패하면 저장 호출은 실패하지만, 디스크에는 새 데이터가 남고 메모리는 이전 값을 유지할 수 있습니다.
프로세스 중단·재실행중복 차단은 현재 앱 실행 동안 유지됩니다. 재시작 후 중복 정산을 막는 영속 정산 ID는 아직 적용하지 않았습니다.

구현 근거GameDataService.cs 보기AtomicFileStore.cs 보기ContentFlow.cs 보기

직접 맡은 범위

진행 데이터의 변경·저장 순서, 파일 교체 경계, ContentFlow의 중복 정산 방지와 재시도 연결을 설계·구현했습니다.

관련 코드

후보를 저장한 뒤 현재 진행을 확정GameDataService.cs
        private async Task<(bool Success, CommanderSkillSummonReceipt Receipt)> ApplyAndSaveCoreAsync(GameProgressChange change)
        {
            var notifyChanged = false;
            CommanderSkillSummonReceipt receipt = null;
            await gate.WaitAsync();
            try
            {
                if (!IsLoaded)
                {
                    ProjectMT.Shared.Audio.SfxProgressSounds.Notify(change, false);
                    return (false, null);
                }

                var candidate = current.Clone(
                    commanderSkillBalanceConfig,
                    commanderSkillSummonConfig); // 원본 보존 후 변경 검증
                if (!candidate.TryApply(
                        change,
                        commanderGrowthConfig,
                        itemCatalog,
                        equipmentBalanceConfig,
                        commanderSkillBalanceConfig,
                        commanderSkillSummonConfig))
                {
                    ProjectMT.Shared.Audio.SfxProgressSounds.Notify(change, false);
                    return (false, null);
                }

                try { await saveService.SaveAsync(candidate); } // 저장 성공을 먼저 확인
                catch
                {
                    try { ProjectMT.Shared.Audio.SfxEvents.Play2D(ProjectMT.Shared.Audio.SfxEvents.SaveError); }
                    catch (Exception soundError) { UnityEngine.Debug.LogException(soundError); }
                    throw;
                }
                current = candidate; // 성공한 후보만 확정
                receipt = candidate.CommanderSkillSummonReceipt;
                notifyChanged = !change.SuppressChangedNotification;
            }
            finally
            {
                gate.Release();
            }

            ProjectMT.Shared.Audio.SfxProgressSounds.Notify(change, true);
            if (notifyChanged)
            {
                Changed?.Invoke();
            }

            return (true, receipt);
직렬화 결과를 쓰기 전 재검증SaveService.cs
        public async Task SaveAsync(GameProgressData data)
        {
            var envelope = new SaveEnvelope
            {
                dataVersion = CurrentDataVersion,
                savedAtUtc = DateTime.UtcNow.ToString("O"),
                gameData = data.Clone()
            };

            var json = JsonUtility.ToJson(envelope, true);
            var verification = JsonUtility.FromJson<SaveEnvelope>(json); // 쓰기 전 역직렬화 검증
            if (verification == null || verification.dataVersion != CurrentDataVersion || verification.gameData == null)
            {
                throw new InvalidOperationException("Serialized save verification failed.");
            }

            await fileStore.ReplaceAsync(savePath, Encoding.UTF8.GetBytes(json)); // 검증된 내용만 교체
        }
.tmp에 완성한 뒤 본 파일과 교체AtomicFileStore.cs
        public async Task ReplaceAsync(string path, byte[] bytes)
        {
            var directory = Path.GetDirectoryName(path);
            if (!string.IsNullOrEmpty(directory))
            {
                Directory.CreateDirectory(directory);
            }

            var temporaryPath = path + ".tmp"; // 완성 전 임시 파일
            var backupPath = path + ".bak"; // 교체 중 복구 파일
            await File.WriteAllBytesAsync(temporaryPath, bytes);

            if (!File.Exists(path))
            {
                File.Move(temporaryPath, path); // 첫 저장은 바로 이동
                return;
            }

            try
            {
                File.Replace(temporaryPath, path, backupPath, true); // 기존 파일 원자 교체
                if (File.Exists(backupPath))
                {
                    File.Delete(backupPath);
                }
            }
            catch (PlatformNotSupportedException)
            {
                File.Copy(temporaryPath, path, true); // 미지원 플랫폼 대체 경로
                File.Delete(temporaryPath);
            }
느리거나 중복된 콘텐츠 결과 무시ContentFlow.cs
        private async Task HandleExitAsync(ActiveRun run, ContentOutcome outcome, IContentResultData result)
        {
            if (run == null || !ReferenceEquals(activeRun, run) || Interlocked.Exchange(ref run.ExitAccepted, 1) != 0) // 늦거나 중복된 결과 무시
            {
                return;
            }

            Phase = ContentFlowPhase.Finishing;
            if (outcome == ContentOutcome.Cancel || run.Definition.ResultAdapter == null)
            {
                FinishRun(run);
                return;
            }

            var adapter = run.Definition.ResultAdapter;
            var runInfo = run.Context.RunInfo;
            if (outcome != ContentOutcome.Complete || !adapter.IsSuccessfulResult(result))
            {
                run.PendingResultPresentation = new ContentResultPresentation(
                    run.Definition.ContentId,
                    run.Definition.DisplayName,
                    ContentOutcome.Fail,
                    adapter.CreateResultSummary(result, runInfo, ContentOutcome.Fail),
                    null);
                await ShowResultAndFinishAsync(run);
                return;
            }
정산 중복 차단·재시도·저장 후 보상 표시ContentFlow.cs
        private async Task TrySaveAndFinishAsync(ActiveRun run)
        {
            if (run == null || !ReferenceEquals(activeRun, run) || Phase != ContentFlowPhase.Finishing ||
                run.PendingChange == null || Interlocked.Exchange(ref run.SettlementInFlight, 1) != 0)
            {
                return;
            }

            run.CanRetry = false;
            ShowSaving();

            var saved = false;
            try
            {
                saved = await progress.TryApplyAndSaveAsync(run.PendingChange);
            }
            catch (Exception exception)
            {
                Debug.LogException(exception);
            }
            finally
            {
                Interlocked.Exchange(ref run.SettlementInFlight, 0);
            }

            if (!ReferenceEquals(activeRun, run))
            {
                return;
            }

            if (!saved)
            {
                Debug.LogError($"Content progress could not be saved. Content={run.Definition.ContentId}");
                run.CanRetry = true;
                ShowSaveFailed(() => RetrySave(run));
                return;
            }

            HideFinishFeedback();
            PlayResultSound(run);
            if (run.PendingResultPresentation != null && resultView != null)
            {
                try
                {
                    await resultView.ShowAsync(run.PendingResultPresentation);
                }
                catch (Exception exception)
                {
                    Debug.LogException(exception); // 결과 표현 실패가 복귀를 막지 않음
                }

                if (!ReferenceEquals(activeRun, run))
                {
                    return;
                }
            }

            if (run.PendingPresentation != null)
            {
                try
                {
                    rewardPresentation?.PlayConfirmed(run.PendingPresentation); // 저장 성공 뒤에만 화면 연출 허용
                }
                catch (Exception exception)
                {
                    Debug.LogException(exception); // 표현 실패가 콘텐츠 복귀를 막지 않음
                }
            }

            FinishRun(run);
저장 실패 뒤 확정 진행값 유지 회귀 계약CommanderGrowthAndStatsTests.cs
        public async Task CommanderLegionGrowth_SaveFailureLeavesConfirmedProgressUnchanged()
        {
            var config = ScriptableObject.CreateInstance<CommanderGrowthConfig>();
            var itemCatalog = AssetDatabase.LoadAssetAtPath<ItemCatalog>(
                "Assets/ProjectMT/02_Shared/Items/Data/ItemCatalog.asset");
            var store = new MemoryFileStore();
            var service = new GameDataService(new SaveService(store, "memory"), config, itemCatalog);
            await service.LoadAsync();

            try
            {
                Assert.That(await service.TryApplyAndSaveAsync(
                    GameProgressChange.GrantItems(new ItemAmount(ItemIds.Gold, 100L))), Is.True);
                store.FailWrites = true;

                Assert.ThrowsAsync<System.IO.IOException>(async () =>
                    await service.TryApplyAndSaveAsync(
                        GameProgressChange.UpgradeCommanderLegionStat(CommanderLegionStat.Defense, 0)));
                Assert.That(service.View.Gold, Is.EqualTo(100L));
                Assert.That(service.View.CommanderLegionGrowth.DefenseLevel, Is.Zero);
            }
            finally
            {
                UnityEngine.Object.DestroyImmediate(config);
            }
        }