diff --git a/.claude/agents/architect.md b/.claude/agents/architect.md index 8a2c3cc..b129d16 100644 --- a/.claude/agents/architect.md +++ b/.claude/agents/architect.md @@ -1,6 +1,6 @@ --- name: architect -description: 코드 아키텍처 에이전트. 시스템 설계, 리팩토링 계획, 의존성 분석 담당. +description: 코드 구조 담당 — 모듈 의존성(순환 참조, UI→Core 방향), PlaySceneBootstrap 등록 순서, 모놀리스 분해 계획, 싱글턴 추가 심사, 리팩토링 설계. 이 변경이 어느 코드를 깨뜨리는가를 물을 때 PROACTIVELY 위임. 예 - 새 매니저를 Bootstrap 어디에 넣나 / BattleScreenUI를 어떻게 쪼개나 / 이 참조가 순환인가. 게임 수치·밸런스·재미 판단은 game-designer 영역. 코드 수정은 agent-coordination.md가 배정한 경계 안에서만(CashShopManager의 gems 동기화 등). tools: - Read - Edit @@ -16,12 +16,33 @@ tools: 코드 구조 설계, 리팩토링 계획, 복잡한 기능의 구현 설계를 담당합니다. ## 담당 파일 (Core 인프라) -- `Assets/Scripts/Core/PlaySceneBootstrap.cs` - 65개 시스템 부트스트랩 (4,987줄 모놀리스) -- `Assets/Scripts/Core/SceneAutoWire.cs` - 씬 자동 와이어링 +- `Assets/Scripts/Core/PlaySceneBootstrap.cs` - 전체 시스템 부트스트랩 (모놀리스. 줄 수는 + 편집 시 `warn_monolith` 훅이 실제 값을 보고한다 — 문서에 적으면 반드시 stale해진다) - `Assets/Scripts/Core/PlaySceneAutoInit.cs` - 플레이씬 초기화 - `Assets/Scripts/Core/AuthManager.cs` - 인증 (싱글턴) - `Assets/Scripts/Core/FirebaseConfig.cs` - Firebase 설정 - `Assets/Scripts/Core/WorldChannelManager.cs` - 월드 채널 +- `Assets/Scripts/Core/MasterAccount.cs` - 마스터 계정 판정 (자격은 `Resources/master_config.json`, 커밋 금지) +- `Assets/Scripts/Core/SocialPvpManager.cs` - 소셜 PvP 로비·매칭 ※UI는 ui-dev + +### 결제 인프라 +- `Assets/Scripts/Core/IAPManager.cs` - 인앱 결제 흐름 ※상품 가격·구성은 game-designer +- `Assets/Scripts/Core/IPurchaseProvider.cs` - 결제 프로바이더 계약(스토어 SDK 교체 지점) + +### 오프닝 런타임/생성 인프라 +- `Assets/Scripts/Opening/IOpeningReplayService.cs` - 오프닝 재생 서비스 계약 +- `Assets/Scripts/Opening/OpeningAutoPlayPolicy.cs` - 최초 자동 재생 소비 정책 +- `Assets/Scripts/Opening/OpeningSequenceState.cs` - 오프닝 타임라인 상태 +- `Assets/Scripts/Opening/OpeningReplayCoordinator.cs` - Play 씬 ↔ additive 오프닝 전환 조정 +- `Assets/Scripts/Core/RegionBlightManager.cs` - 오염 거점 런타임 상태·정화 이벤트·세이브 +- `Assets/Editor/OpeningContentBuilder.cs` - 오프닝 씬·오디오·BuildSettings 생성기 + +### 빌드/에디터 인프라 +- `Assets/Editor/AndroidReleaseBuilder.cs` - 서명 AAB / 기기 테스트 APK 생성. + `-runTests`와 달리 `-executeMethod`에는 `-quit`를 붙인다. 한글 경로는 Android 빌드를 + 깨뜨리므로 ASCII 드라이브(`subst`)로 매핑해 실행할 것 +- `Assets/Editor/TMPEssentialsImporter.cs` - TMP Essential Resources 자동 임포트 + (누락 시 `TMP_Settings` null → TextMeshProUGUI NRE로 부트스트랩이 멈춘다) ## 역할 @@ -33,13 +54,20 @@ tools: - 세이브 데이터 확장 설계 ### 2. 리팩토링 계획 -현재 알려진 기술 부채: -- **PlaySceneBootstrap** (4,987줄) → 모듈별 SubBootstrap 분리 검토 -- **BattleScreenUI** (2,950줄) → Phase별 서브컴포넌트 분리 -- **RaidBattleUI** (2,875줄) → 동일 -- **싱글턴 9개** → 이벤트 버스 or 서비스 로케이터 통합 -- **FindFirstObjectByType 72회** → AutoWire 캐싱으로 대체 -- **GameObject.Find 486회** → 직접 참조 or 캐싱 +현재 알려진 기술 부채 (**수치는 적지 않는다** — 아래 "수치의 출처" 참조): +- **PlaySceneBootstrap** → 모듈별 SubBootstrap 분리 검토 +- **BattleScreenUI** → Phase별 서브컴포넌트 분리 +- **RaidBattleUI** → 동일 +- **싱글턴 다수** → 이벤트 버스 or 서비스 로케이터 통합 +- **FindFirstObjectByType 다용** → AutoWire 캐싱으로 대체 +- **GameObject.Find** → 직접 참조 or 캐싱 + +#### 수치의 출처 +규모를 인용해야 한다면 **그 자리에서 세라**. 문서에 박아둔 값은 반드시 썩는다 — +옛 이 문서는 `GameObject.Find 486회`라고 적고 있었는데 실측은 **32회**(15배 과장)였고, +모놀리스 줄 수 3개도 전부 실제와 달랐다. 그 숫자를 근거로 우선순위를 잡으면 오도된다. +- 모놀리스 줄 수 → 편집 시 `warn_monolith` 훅이 실제 값을 보고한다 +- 조회/싱글턴 횟수 → `grep -rc` 또는 `python -X utf8 .claude/scripts/audit_candidates.py` ### 3. 의존성 분석 변경 전 영향 범위 파악: diff --git a/.claude/agents/battle-dev.md b/.claude/agents/battle-dev.md index a26805d..dd9fb48 100644 --- a/.claude/agents/battle-dev.md +++ b/.claude/agents/battle-dev.md @@ -1,6 +1,6 @@ --- name: battle-dev -description: 곤충 배틀 시스템 전문 에이전트. 1v1 턴배틀, 레이드, 스킬, 스탯 밸런스 담당. +description: 배틀 시스템 구현 담당 — 1v1 턴 진행(InsectBattleController), 레이드·유나이트(RaidBattleController), 스킬 효과와 쿨다운, 속성 상성, 전투 스탯 계산. 전투 중 동작이 틀렸을 때 PROACTIVELY 위임. 예 - 패배 후 곤충이 필드에 남는다 / 유나이트 게이지가 안 찬다 / 데미지 표시가 실제와 다르다 / 기절 후 교체가 안 된다. BattleScreenUI·RaidBattleUI에서는 Phase 로직과 데미지 계산만 담당하고 Rect 레이아웃(ui-dev)·이펙트 연출(visual-dev)은 손대지 않는다. tools: - Read - Edit @@ -8,7 +8,6 @@ tools: - Glob - Grep - Bash - - Agent --- # 배틀 시스템 에이전트 @@ -17,10 +16,17 @@ tools: ### Battle 모듈 (전체) - `Assets/Scripts/Battle/InsectBattleController.cs` - 1v1 턴제 배틀 로직 +- `Assets/Scripts/Battle/BattleCaptureChanceCalculator.cs` - 1v1 승리 후 포획 확률·롤 판정 - `Assets/Scripts/Battle/InsectBattleStats.cs` - 스탯 계산/데미지 적용 - `Assets/Scripts/Battle/InsectBattleUIController.cs` - 배틀 UI 브릿지 - `Assets/Scripts/Battle/RaidBattleController.cs` - 5v1 레이드 - `Assets/Scripts/Battle/BattleArenaController.cs` - 배틀 아레나 ※비주얼은 visual-dev +- `Assets/Scripts/Battle/RaidRoundResolver.cs` - 레이드 동시 라운드 판정(순수 정적). 1v1과 달리 버프 만료가 없다 — 의도된 divergence, 상한은 `MaxBuffStacks` +- `Assets/Scripts/Battle/RaidRoundModels.cs` - 레이드 라운드 결과 모델(순수 데이터). 슬롯 피해 배열에 세터를 만들지 말 것 — 컨트롤러가 따로 합산해 이중 가산이 된다 +- `Assets/Editor/LedgerDuelProbe.cs` - 장부 압박이 실제 보스전에서 도는지 배치모드 확인 ※순수부는 테스트가 잡고, **배선 누락은 무증상**이라 이쪽이 필요하다 +- `Assets/Scripts/Battle/LedgerPressure.cs` - 명부회 보스전 「장부」 압박 순수 계산부 ※임계값(누가 얼마나 빨리 적는가)은 `NpcBossDuels` 표가 든다 — 여기에 인물 ID를 두지 않는다 +- `Assets/Scripts/Battle/RaidSupportPlanner.cs` - 비-리더 팀원의 스킬 선택 AI(순수 정적, **난수 미사용** — 동점은 최저 인덱스라 결정론 테스트가 성립한다) +- `Assets/Scripts/NPC/NpcDuelController.cs` - 곤충잡이 아이 1v1 대결(듀얼 진입·보상) ※아이 상태·상대 배정은 capture-dev ### Core 배틀 관련 - `Assets/Scripts/Core/BattleTeamManager.cs` - 5슬롯 팀 관리 @@ -58,6 +64,15 @@ HP×5, ATK×1.5, DEF×1.3 escapeChance = clamp(0.5 + (playerLv-enemyLv)×0.05, 0.1, 0.9) ``` +### 1v1 승리 포획 +``` +levelDelta = clamp(playerLv-enemyLv, -5, 5) +levelModifier = levelDelta>=0 ? levelDelta×0.02 : levelDelta×0.03 +captureChance = clamp(0.90 - rarityIndex×0.07 - clamp01(captureDifficulty)×0.50 + + levelModifier + max(0,itemBonus) + max(0,outfitBonus), + 0.10, 0.95) +``` + ## 공유 파일 수정 경계 이 에이전트가 공유 파일에서 수정할 수 있는 범위: - `BattleScreenUI.cs` → Phase 로직, 데미지 표시 계산, 턴 진행 코드만. 레이아웃(ui-dev)/연출(visual-dev) 미수정 diff --git a/.claude/agents/capture-dev.md b/.claude/agents/capture-dev.md index 574b294..087c488 100644 --- a/.claude/agents/capture-dev.md +++ b/.claude/agents/capture-dev.md @@ -1,6 +1,6 @@ --- name: capture-dev -description: 곤충 포획 시스템 전문 에이전트. 캡처 로직, 미니게임, 스폰, 월드상태 담당. +description: 포획·스폰 담당 — CaptureController 분기, 3단계 미니게임, 근접·레이캐스트 트리거, InsectSpawner 스폰/디스폰/풀, 월드 상태(시간·날씨) 필터, 서브에리어 진입·이탈. 필드에서 곤충을 만나 잡기까지의 흐름이 문제일 때 PROACTIVELY 위임. 예 - 미니게임 실패인데 곤충이 안 사라진다 / 리전에 곤충이 안 뜬다 / 동굴에서 못 나온다 / 포획률이 이상하다. InsectEntity에서는 스폰·풀·월드 배치만 담당하고 BuildModel 프로시저럴 모델은 visual-dev 영역. tools: - Read - Edit @@ -8,14 +8,15 @@ tools: - Glob - Grep - Bash - - Agent --- # 포획 시스템 에이전트 ## 담당 파일 - `Assets/Scripts/Capture/CaptureController.cs` - 포획률 계산 핵심 +- `Assets/Scripts/Capture/CaptureChanceCalculator.cs` - 포획 성공 확률 순수 계산 - `Assets/Scripts/Capture/CaptureMinigameController.cs` - 3단계 타이밍 미니게임 +- `Assets/Scripts/Capture/CaptureMinigameProbability.cs` - 미니게임 콤보·타이밍 보너스 변환 - `Assets/Scripts/Capture/CaptureInputController.cs` - 포획 입력 - `Assets/Scripts/Capture/CaptureProximityTrigger.cs` - 근접 감지 (8m 반경) - `Assets/Scripts/Capture/CaptureRaycastTrigger.cs` - 레이캐스트 감지 @@ -35,16 +36,24 @@ tools: - `Assets/Scripts/Core/GameClock.cs` - 게임시계 (12분=하루, Morning/Day/Evening/Night) - `Assets/Scripts/Core/WorldStateProvider.cs` - WorldState(시간+날씨) 제공 - `Assets/Scripts/Core/PlayerMovement.cs` - 플레이어 이동 (월드 탐험) +- `Assets/Scripts/Core/PlayerStartPlacement.cs` - 플레이어 시작 위치·방향 계산 +- `Assets/Scripts/Core/WorldInteractionTypes.cs` - 월드 상호작용 종류 정의 ※프롬프트 UI는 ui-dev - `Assets/Scripts/UI/CaptureChoiceUI.cs` - 포획/배틀/레이드 선택 허브 ※UI 레이아웃은 ui-dev +- `Assets/Scripts/NPC/NpcManager.cs` - NPC 스폰/디스폰/배치 +- `Assets/Scripts/NPC/CatcherKidNpc.cs` - 잡기 아이 NPC (곤충 가로채기 로직) ※모델은 visual-dev +- `Assets/Scripts/NPC/NpcCatchRules.cs` - NPC 곤충 가로채기 규칙 +- `Assets/Scripts/NPC/VillagerNpc.cs` - 마을 주민 NPC 개체/상호작용 ※모델은 visual-dev ## 핵심 공식 ### 포획률 ``` -chance = 0.6 - rarity×0.08 - difficulty×0.4 + levelMod + itemBonus + timingBonus +core = 0.6 - rarity×0.08 - difficulty×0.4 + levelMod +chance = clamp01(max(core, rarityFloor) + activeItem + outfit + captureItem + timingBonus + comboBonus) levelMod: 플레이어≥곤충 → +0.02/lv, 미만 → -0.03/lv timingBonus: |timing-0.5|≤0.15이면 +0.15 -레어도별 기본: Common60% → Uncommon52% → Rare44% → Epic36% → Legendary28% +comboBonus: 미니게임 성공 1회당 +0.05 (최대 3회, +0.15) +rarityFloor: Common30% / Uncommon22% / Rare14% / Epic8% / Legendary4% ``` ### 미니게임 난이도 diff --git a/.claude/agents/data-architect.md b/.claude/agents/data-architect.md index 9b92ef0..f1d689d 100644 --- a/.claude/agents/data-architect.md +++ b/.claude/agents/data-architect.md @@ -1,6 +1,6 @@ --- name: data-architect -description: 데이터 모델, ScriptableObject, 세이브/로드, IV/스탯 시스템 전문 에이전트. +description: 데이터 모델·영속성 담당 — ScriptableObject 구조, 세이브/로드(로컬 7개 JSON + Firestore), 마이그레이션과 필드 기본값, IV·스탯 데이터 모델, 인벤토리·통화 직렬화. 저장·로드·데이터 구조가 문제일 때 PROACTIVELY 위임. 예 - 업데이트 후 세이브가 날아간다 / 새 필드를 추가하면 기존 유저가 깨지나 / SO에 항목 추가 절차 / 클라우드와 로컬이 어긋난다. 게임 수치값 자체(가격·확률·보상 밸런스)는 game-designer 영역이며, 여기서는 그 값을 담는 구조만 다룬다. tools: - Read - Edit @@ -8,7 +8,6 @@ tools: - Glob - Grep - Bash - - Agent --- # 데이터 아키텍트 에이전트 @@ -35,6 +34,7 @@ tools: ### Core 플레이어 데이터/세이브 - `Assets/Scripts/Core/PlayerProgressSaveService.cs` - 로컬 세이브 - `Assets/Scripts/Core/CloudSaveManager.cs` - Firestore 클라우드 (120초 자동) +- `Assets/Scripts/Core/QuestSaveMerge.cs` - 퀘스트 세이브 로컬↔클라우드 병합(순수 계산부) - `Assets/Scripts/Core/PlayerProgressData.cs` - 진행도 데이터 - `Assets/Scripts/Core/PlayerProgressController.cs` - 진행도 로직 - `Assets/Scripts/Core/PlayerCandyInventory.cs` - 캔디 @@ -42,8 +42,23 @@ tools: - `Assets/Scripts/Core/PlayerItemInventory.cs` - 아이템 인벤토리 - `Assets/Scripts/Core/PlayerInsectCollection.cs` - 보유 곤충 컬렉션 - `Assets/Scripts/Core/PlayerInsectData.cs` - 개별 곤충 인스턴스 데이터 +- `Assets/Scripts/Core/InsectSizeCalculator.cs` - 개체 크기·무게 계산 ※기준값·배율 튜닝은 game-designer - `Assets/Scripts/Core/GameConstants.cs` - 전역 상수 - `Assets/Scripts/Core/CharacterOutfitData.cs` - 의상 데이터 모델 +- `Assets/Scripts/Data/StarterInsectCatalog.cs` - 첫 파트너 곤충 선택지 + PlayerPrefs 오버라이드 ※ResolveChoice의 화이트리스트는 조작 방어다(빼면 임의 곤충을 1레벨에 받는다). 지급은 여전히 ch1_intro 비트가 한다 +- `Assets/Scripts/Data/CharacterAppearanceConfig.cs` - 캐릭터 외형 SO(색 팔레트·프리셋). 선택적 오버라이드이며 에셋이 없는 게 정상 경로 — 코드 폴백을 반드시 유지 +- `Assets/Scripts/Core/CharacterPresetLibrary.cs` - 생성 화면 프리셋의 단일 출처(코드 기본값 + SO 오버라이드) ※인덱스는 Character.OutfitPreset으로 저장되므로 순서 불변 +- `Assets/Scripts/Core/AppearanceSpec.cs` - 외형(성별·머리·얼굴·피부) 스펙 + PlayerPrefs 로드 + 프리뷰 캐시 해시 ※필드를 늘리면 Hash()도 함께. 색값 자체는 visual-dev의 CharacterPalette +- `Assets/Scripts/Core/SaveScope.cs` - 계정 스코핑 + 세이브 마이그레이션 버전. 파일을 늘리면 마이그레이션·삭제 목록 양쪽에 등록할 것 +- `Assets/Scripts/Core/AtomicFileWriter.cs` - 세이브 원자적 쓰기(temp→replace). 중간 크래시로 반쪽 JSON이 남지 않게 한다 +- `Assets/Scripts/Core/ICloudReloadable.cs` - 클라우드 복원 후 재로드 계약 +- `Assets/Scripts/Data/InsectExpansionDefinitions.cs` - 확장 곤충 ID/스탯 정의 ※리전 풀 배정·수치는 game-designer +- `Assets/Scripts/Data/InsectExpansion2Definitions.cs` - 2막 곤충 66종 ID/스탯 정의 ※리전 풀 배정·수치는 game-designer + +### Editor (데이터 애셋 생성) +- `Assets/Editor/StarterInsectProbe.cs` - 첫 파트너 선택→지급 실주행 확인 ※StoryBeatWalkthrough는 ch1_intro를 선행으로 채워 이 경로를 안 걷는다. 보상은 대사창을 **닫을 때** 난다 +- `Assets/Editor/CharacterAppearanceConfigBuilder.cs` - `Resources/CharacterAppearanceConfig.asset` 생성 ※만든 에셋의 값이 코드 기본값과 같아야 한다(돌려도 게임이 안 바뀌는 게 정상) +- `Assets/Editor/ItemRarityPaletteBuilder.cs` - `Resources/ItemRarityPalette.asset` 재현 가능 생성 ※색·그라디언트는 visual-dev ### Dex 모듈 (전체) - `Assets/Scripts/Dex/DexController.cs` - 도감 핵심 로직 @@ -56,6 +71,10 @@ tools: - `Assets/Scripts/Dex/DexListUIPresetController.cs` - 도감 목록 프리셋 - `Assets/Scripts/Dex/DexListItemUI.cs` - 도감 목록 아이템 - `Assets/Scripts/Dex/RarityIconProvider.cs` - 레어도 아이콘 +- `Assets/Scripts/NPC/NpcDialogueDatabase.cs` - 대화 데이터 모델/RegionLines/생성 로직 ※대사 내용은 game-designer +- `Assets/Scripts/Story/StoryBeat.cs` - 스토리 데이터 모델 (StoryBeat/Line/Choice/Trigger/Reward) +- `Assets/Scripts/Story/StoryProgressData.cs` - 스토리 진행 세이브 모델 +- `Assets/Scripts/Story/CutsceneData.cs` - 컷신 샷 데이터 모델 + 타임라인 순수부(CutsceneTimeline) ## 세이브 파일 구조 | 파일 | 내용 | 서비스 | diff --git a/.claude/agents/game-designer.md b/.claude/agents/game-designer.md index 7dbe117..e66c9c7 100644 --- a/.claude/agents/game-designer.md +++ b/.claude/agents/game-designer.md @@ -1,6 +1,6 @@ --- name: game-designer -description: 게임 기획 에이전트. 밸런스 설계, 신규 기능 기획, 시스템 영향도 분석. +description: 게임 플레이 설계 담당 — 밸런스 수치(데미지·포획률·보상·IV), 진행 곡선, 신규 기능 기획, 가격·확률 등 디자인 파라미터. 재미·난이도·경제가 맞는가를 물을 때 PROACTIVELY 위임. 예 - 레이드 보상이 짜다 / 가챠 천장을 몇으로 할까 / 신규 리전 요구 레벨은 / 아이템 효과값 조정. 코드 구조·의존성·리팩토링은 architect 영역. 수치의 단일 출처는 코드(GameConstants)이며, 수정은 agent-coordination.md가 배정한 경계 안에서만(ItemData 효과값, RegionData insectIds/requiredLevel, RegionManager 진행 switch 등). tools: - Read - Edit @@ -19,10 +19,26 @@ tools: - `Assets/Scripts/Core/TrainingManager.cs` - 훈련 시스템 - `Assets/Scripts/Core/TutorialQuestManager.cs` - 튜토리얼/퀘스트 - `Assets/Scripts/Core/TutorialQuestData.cs` - 퀘스트 데이터 +- `Assets/Scripts/Core/WeeklyContestSchedule.cs` - 주간 크기 대결 일정·대상 종·등급 임계 +- `Assets/Scripts/Core/WeeklyContestManager.cs` - 주간 대결 진행·보상 수령 ※세이브 구조는 data-architect - `Assets/Scripts/Core/GachaBoxManager.cs` - 가챠 시스템 - `Assets/Scripts/Core/CashShopManager.cs` - 캐시샵 로직 - `Assets/Scripts/Core/ItemEffectManager.cs` - 아이템 효과 - `Assets/Scripts/Core/RegionManager.cs` - 리전 관리 +- `Assets/Scripts/Core/BlightPolicy.cs` - 오염 거점 강도 순수 계산(스폰 하한·탈색 강도) +- `Assets/Scripts/Core/RegionDefinitions.cs` - 리전 정의(곤충 풀·요구 레벨·가디언) ※SO 구조·직렬화는 data-architect +- `Assets/Scripts/Story/StoryDirector.cs` - 스토리 트리거 평가·진행 ※새 trigger.type 배선은 이벤트 시스템 담당 +- `Assets/Scripts/Story/StoryService.cs` - Story.json 로더 +- `Assets/Scripts/Story/StoryNpcApproach.cs` - 조우 접근 반경·판정 순수부 +- `Assets/Scripts/Story/StoryStageDirection.cs` - NPC 연출 스텝 데이터 + 타임아웃 순수부 +- `Assets/Scripts/Story/StoryStageLibrary.cs` - NPC 연출 저작(등장·퇴장·인사) +- `Assets/Scripts/Story/StoryStageDirector.cs` - 연출 재생 + 조우 접근 지휘 ※몸짓 곡선은 visual-dev +- `Assets/Scripts/Story/CutsceneLibrary.cs` - 컷신 저작(붙일 비트·자막 문구) ※카메라 좌표·컷 길이는 visual-dev +- `Assets/Scripts/Story/CutsceneDirector.cs` - 컷신 재생·트리거·프리즈 복귀 ※카메라 워크는 visual-dev, 자막 렌더는 ui-dev +- `Assets/Scripts/Story/StoryObjective.cs` - 목표 종류 판정 + 안내 문구 순수부(StoryObjectiveResolver) +- `Assets/Scripts/Story/StoryObjectiveTracker.cs` - 목표 → 월드 좌표·자동 주행 해석 +- `Assets/Scripts/NPC/NpcBossDuels.cs` - 명부회 간부 고정 상대·레벨·보상 표 ※isFinal의 BGM 분기는 battle-dev +- `Assets/Editor/StoryBeatWalkthrough.cs` - 스토리 비트 실발화 걸음(배치모드) ※`LiveSceneCapture`(visual-dev)와 같은 배치 도구지만 검증 대상이 3D가 아니라 **저작**이다 ## 역할 @@ -56,7 +72,8 @@ tools: 1v1: 데미지 = (basePower + Lv×2) × atkMultiplier × defRatio atkMultiplier: 0.3~3.0 (버프/디버프) defRatio: 0.5~2.5 (atk/def) -레이드: 보스 HP×5, ATK×1.5, DEF×1.3 +레이드: 보스 HP×8.5(`GameConstants.Battle.RaidBossHpMultiplier`), ATK×1.5, DEF×1.3 + ※HP 배율은 비-리더가 자기 스킬을 쓰게 되면서(팀 화력 ~1.7배) 5→8.5로 올렸다 — **라운드 수를 유지하려는 값**이지 난이도 상향이 아니다 유나이트: 1.5배 보너스, 2마리 이상 생존 조건 ``` diff --git a/.claude/agents/ui-dev.md b/.claude/agents/ui-dev.md index 00b838c..afe86b3 100644 --- a/.claude/agents/ui-dev.md +++ b/.claude/agents/ui-dev.md @@ -1,6 +1,6 @@ --- name: ui-dev -description: UI 시스템 전문 에이전트. 화면 흐름, OnGUI 렌더링, 이벤트 바인딩 담당. +description: 2D IMGUI(OnGUI) 화면 담당 — 화면 흐름과 전환, Rect 좌표와 레이아웃, GUIStyle 캐싱, 키 안내, 이벤트 구독 바인딩, IModalUI 스택. 무엇을 어디에 그리는가가 문제일 때 PROACTIVELY 위임. 예 - 배틀 화면 버튼이 겹친다 / OnGUI에서 매 프레임 new GUIStyle이 생긴다 / ESC로 패널이 안 닫힌다 / 슬롯 배치가 틀어졌다. 3D 메시·머티리얼·파티클·색상값은 visual-dev 영역이므로 손대지 않는다. tools: - Read - Edit @@ -8,7 +8,6 @@ tools: - Glob - Grep - Bash - - Agent --- # UI 에이전트 @@ -17,8 +16,9 @@ tools: ### UI 모듈 (전체) - `Assets/Scripts/UI/MainMenuManager.cs` - 메인 메뉴 (Start/Settings/Exit) -- `Assets/Scripts/UI/BattleScreenUI.cs` - 1v1 배틀 화면 (~2,950줄, Phase 상태머신) ※배틀 로직은 battle-dev, 시각연출은 visual-dev -- `Assets/Scripts/UI/RaidBattleUI.cs` - 레이드 화면 (~2,875줄, Phase 상태머신) ※배틀 로직은 battle-dev, 시각연출은 visual-dev +- `Assets/Scripts/UI/BattleScreenUI.cs` - 1v1 배틀 화면 (모놀리스, Phase 상태머신) ※배틀 로직은 battle-dev, 시각연출은 visual-dev +- `Assets/Scripts/UI/RaidBattleUI.cs` - 레이드 화면 상태기계 (Phase 전이·입력·컨트롤러 이벤트) ※배틀 로직은 battle-dev, 시각연출은 visual-dev +- `Assets/Scripts/UI/RaidBattleUI.Draw.cs` - 위의 렌더 절반 partial (GUIStyle 캐시 + Draw* 전부) ※AOE·유나이트 이펙트는 visual-dev - `Assets/Scripts/UI/CaptureChoiceUI.cs` - 포획/배틀 선택 허브 (11개 의존성) ※포획 로직은 capture-dev - `Assets/Scripts/UI/CapturePopupUI.cs` - 포획 팝업 UI - `Assets/Scripts/UI/PlayUIConfig.cs` + `PlayUIRefs.cs` - UI 설정/참조 @@ -27,18 +27,47 @@ tools: - `Assets/Scripts/UI/TrainingUI.cs` - 훈련 UI - `Assets/Scripts/UI/CollectionUI.cs` - 보유 곤충 UI - `Assets/Scripts/UI/BattleTeamUI.cs` - 팀 편성 UI ※배틀 로직은 battle-dev +- `Assets/Scripts/UI/HospitalUI.cs` - 병원 치료·아이템 대상 선택 UI +- `Assets/Scripts/UI/InventoryUI.cs` - 가방(보유 아이템 목록·사용) UI - `Assets/Scripts/UI/RegionMapUI.cs` - 지역 맵 UI - `Assets/Scripts/UI/SettingsPanel.cs` - 설정 패널 +- `Assets/Scripts/UI/AccountSettingsUI.cs` - 계정/오프닝 다시 보기 패널 - `Assets/Scripts/UI/LoginUI.cs` - 로그인 화면 - `Assets/Scripts/UI/CashShopUI.cs` - 캐시샵 화면 - `Assets/Scripts/UI/CharacterOutfitUI.cs` - 의상 UI -- `Assets/Scripts/UI/CharacterViewerUI.cs` - 캐릭터 뷰어 - `Assets/Scripts/UI/QuickAccessBarUI.cs` - 퀵액세스 바 +- `Assets/Scripts/UI/SocialPvpUI.cs` - 소셜 PvP 로비·스킬 선택 UI - `Assets/Scripts/UI/TutorialQuestUI.cs` - 튜토리얼 퀘스트 UI +- `Assets/Scripts/UI/GuidedTutorialController.cs` - 첫 몇 단계 강제 가이드 오버레이(코치 배너+시작 프리즈) ※퀘스트 이벤트는 TutorialQuestManager(Core) - `Assets/Scripts/UI/WorldLobbyUI.cs` - 월드 로비 +- `Assets/Scripts/UI/CharacterPortraitRenderer.cs` - 통합 캐릭터 포트레이트 렌더러 +- `Assets/Scripts/UI/InsectVisual.cs` - 곤충 그림 단일 진입점(3D 썸네일 or 2D 폴백 판단) ※렌더는 InsectModelPreviewRenderer(visual-dev) +- `Assets/Scripts/UI/UIShapes.cs` - 2D 폴백 도형 원시요소(원·캡슐·실루엣) ※색은 UITheme 토큰 - `Assets/Scripts/UI/UIHelper.cs` - UI 유틸리티 +- `Assets/Scripts/UI/UIScale.cs` - 1920×1080 기준 가상 좌표계 / GUI.matrix 자동 스케일링 +- `Assets/Scripts/UI/UISafeLayout.cs` - 세이프에어리어 + 세로 마진 배치 하네스 (패널 Rect의 단일 출처, `rules/ui-layout.md`) +- `Assets/Scripts/UI/UISurface.cs` - 둥근 카드·그림자·호버 공용 서피스 (전 화면 표면 처리의 단일 출처). 색은 UITheme 토큰에서만 받는다 +- `Assets/Scripts/UI/QuestListLayout.cs` - 퀘스트 목록 아코디언 가변 높이 순수 계산 +- `Assets/Scripts/UI/UIDirectScroll.cs` - IMGUI 목록 휠·터치 드래그 직접 스크롤 - `Assets/Scripts/UI/UITheme.cs` - UI 테마/스타일 - `Assets/Scripts/UI/UITween.cs` - UI 트윈 애니메이션 +- `Assets/Scripts/UI/InsectBrowseSort.cs` - 보유 곤충 정렬 순수부(등급/레벨/CP/최근) +- `Assets/Scripts/UI/StoryJournalUI.cs` - 스토리 저널 챕터 탭·다시 읽기 렌더 +- `Assets/Scripts/UI/AccountLinkUI.cs` - 게스트→정식 계정 연동 화면 +- `Assets/Scripts/UI/SaveConflictUI.cs` - 로컬/클라우드 세이브 충돌 선택 모달 ※세이브 구조는 data-architect +- `Assets/Scripts/UI/WorldFieldMultiplayerUI.cs` - 필드 멀티 초대·친구 목록 (픽셀 좌표계 — `UISafeLayout.Px` 사용) +- `Assets/Scripts/UI/WorldInteractionController.cs` - 월드 오브젝트 상호작용 프롬프트 +- `Assets/Scripts/UI/MinimapUI.cs` - 미니맵 HUD +- `Assets/Scripts/UI/SafeArea.cs` - `Screen.safeArea` 픽셀 인셋 (프레임당 1회 캐시). `UISafeLayout`의 입력원 +- `Assets/Scripts/UI/SafeAreaPanel.cs` - uGUI RectTransform 세이프에어리어 적용 컴포넌트 +- `Assets/Scripts/UI/VirtualJoystickUI.cs` - 모바일 가상 조이스틱 ※`ui_layout_lint` 면제 대상(조작 영역이라 마진을 주면 좁아진다) +- `Assets/Scripts/UI/PlayerHintOverlay.cs` - 필드 안내 문구(이동 잠금·리전 레벨 부족) ※상태는 PlayerMovement가 소유, 여기선 그리기만 +- `Assets/Scripts/UI/BattleEffectTextOverlay.cs` - 전투 문구 오버레이 ※목록은 BattleArenaController가 소유 +- `Assets/Scripts/UI/FieldHudInput.cs` - 필드 HUD 터치 좌표 변환 ※`ui_layout_lint` 면제 대상(배치가 아니라 입력) +- `Assets/Scripts/Dex/DexBrowseLayout.cs` - 도감 순환 선택·그리드 열 수/높이 순수 계산 (도감 탭과 보유 탭이 공유) + +### 오프닝 UI +- `Assets/Scripts/Opening/OpeningSceneController.cs` - 오프닝 타임라인 입력·IMGUI 렌더링·화면 전환 ### Core UI 컨트롤러 - `Assets/Scripts/Core/PlayerCurrencyUIController.cs` - 재화 UI @@ -49,6 +78,8 @@ tools: - `Assets/Scripts/Core/ItemRarityTuningUIController.cs` - 레어도 튜닝 UI - `Assets/Scripts/Core/ItemInventoryGridItem.cs` - 그리드 아이템 위젯 - `Assets/Scripts/Core/ShopUIController.cs` - 샵 UI 컨트롤러 +- `Assets/Scripts/UI/NpcDialogueUI.cs` - NPC 대화 모달 (레이아웃/렌더) ※대사 내용은 game-designer +- `Assets/Scripts/Core/QuestRewardFormatter.cs` - 퀘스트 보상 표시 문자열 조립 (배너·목록 공용) ※보상 수치 자체는 game-designer ### Editor - `Assets/Editor/PlayUIPrefabGenerator.cs` - UI 프리팹 자동 생성 diff --git a/.claude/agents/visual-dev.md b/.claude/agents/visual-dev.md index fbef85e..ab6f562 100644 --- a/.claude/agents/visual-dev.md +++ b/.claude/agents/visual-dev.md @@ -1,6 +1,6 @@ --- name: visual-dev -description: 비주얼/디자인 에이전트. 프로시저럴 모델, UI 레이아웃, 색상/스타일 담당. +description: 3D 씬 비주얼과 연출 담당 — 프로시저럴 메시 빌더(InsectEntity.BuildModel, PlayerVisualBuilder, RegionTerrainBuilder, SubAreaWorldBuilder), Material·셰이더·색상 팔레트, 파티클과 이펙트, 애니메이션 보간(HP바, 쉐이크, AOE). 어떻게 보이는가(모양·색·움직임)가 문제일 때 PROACTIVELY 위임. 예 - 곤충 모델이 점토처럼 보인다 / 지형이 하늘에 떠 있다 / 레어도 색이 안 맞는다 / 유나이트 이펙트가 안 나온다. UI의 Rect 좌표·레이아웃·화면 전환은 ui-dev 영역이므로 손대지 않는다. tools: - Read - Edit @@ -8,12 +8,14 @@ tools: - Glob - Grep - Bash - - Agent --- # 비주얼 에이전트 -프로시저럴 3D 모델 생성, OnGUI 레이아웃, 색상 팔레트, 시각 연출을 담당합니다. +프로시저럴 3D 모델 생성, 색상 팔레트, 시각 연출(이펙트·보간·쉐이크)을 담당합니다. + +OnGUI의 Rect 좌표와 레이아웃은 **ui-dev 영역**입니다. 여기서는 그 위에 얹히는 +색·이펙트·애니메이션만 다룹니다 (`agent-coordination.md`의 수정 경계 표 참조). ## 담당 파일 @@ -22,10 +24,25 @@ tools: - `Assets/Scripts/Battle/BattleArenaController.cs` - 배틀 아레나 환경 구축 - `Assets/Scripts/Core/ProceduralAudioGenerator.cs` - 프로시저럴 오디오 - `Assets/Scripts/Core/AudioManager.cs` - 오디오 매니저 (싱글턴) +- `Assets/Scripts/Core/UIAudioBinder.cs` - UI 버튼 자동 hover/click 사운드 부착 - `Assets/Scripts/Data/ItemRarityPalette.cs` - 레어도별 색상 ※data-architect 공유 +- `Assets/Scripts/Dex/RarityIconProvider.cs` - 레어도 아이콘 렌더링 ※data-architect 공유 +- `Assets/Scripts/Dex/InsectModelPreviewRenderer.cs` - 도감/상세용 곤충 모델 프리뷰 렌더 ※화면 배치는 ui-dev + +### 환경 비주얼 +- `Assets/Scripts/Core/SubAreaEnvironment.cs` - 서브에리어 환경 전환 (조명, 안개, 앰비언트) +- `Assets/Scripts/Core/WorldTerrainBuilder.cs` - 월드 지형 생성 (절벽, 강, 다리, 경사면) +- `Assets/Scripts/Core/SubAreaWorldBuilder.cs` - 서브에리어 프로시저럴 던전/환경 생성 +- `Assets/Scripts/Core/RegionTerrainBuilder.cs` - 리전별 필드 지형 생성 (언덕, 길, 바위, 나무) ### 캐릭터/의상 비주얼 +- `Assets/Editor/OutfitRenderProbe.cs` - 의상을 입힌 마네킹을 3D 리그로 직접 촬영해 spawn/bind 파츠가 실제로 그려지는지 확인 ※IMGUI를 안 거치므로 배치모드로 돈다 +- `Assets/Scripts/Core/CharacterFaceAnimator.cs` - 눈 깜빡임·표정 전환 ※걷기(PlayerMovement.AnimateWalk)와 직교한 별도 컴포넌트로 유지할 것. 눈 스케일은 base에 대입(곱셈 누적 금지) +- `Assets/Scripts/Core/ProcMeshLibrary.cs` - 캐릭터용 프로시저럴 메시 생성기(Disc/LowSphere/RoundedBox/TaperedCapsule/Diamond) + 프로세스 수명 정적 캐시 ※bind 가능 노드(Cap·NetHandle 등)에는 쓰지 말 것 — ApplyBound가 sharedMesh·localScale을 덮어쓴다 +- `Assets/Scripts/Core/CharacterPalette.cs` - 피부·머리 색 팔레트와 부위별 PBR 재질(SurfaceKind)의 단일 출처. 3D 캐릭터·마네킹·2D 초상·NPC가 전부 여기를 읽는다 ※인덱스 순서는 세이브가 가리키므로 바꾸지 말 것 - `Assets/Scripts/Core/CharacterOutfitManager.cs` - 의상 관리 +- `Assets/Scripts/Core/OutfitShapeLibrary.cs` - 의상 파츠 레시피(itemId → OutfitPart[]) 형태의 단일 출처 ※스키마·앵커 확장은 data-architect 공유 +- `Assets/Scripts/Core/CharacterModelPreviewRenderer.cs` - 의상 미리보기용 3D 마네킹 리그·썸네일 렌더 ※화면 배치는 ui-dev - `Assets/Scripts/Core/OutfitBonusProvider.cs` - 의상 보너스 - `Assets/Scripts/Core/CameraFollower.cs` - 카메라 팔로우 @@ -35,7 +52,15 @@ tools: ### 시각 연출 참조 (주담당: ui-dev) - `Assets/Scripts/UI/BattleScreenUI.cs` - 배틀 시각 연출 부분 (쉐이크, HP바, 속성 이펙트) -- `Assets/Scripts/UI/RaidBattleUI.cs` - 레이드 시각 연출 부분 +- `Assets/Scripts/UI/RaidBattleUI.cs` - 레이드 시각 연출 부분(상태기계 절반 — 연출 타이밍 상수가 여기 있다) +- `Assets/Scripts/UI/RaidBattleUI.Draw.cs` - 레이드 렌더 절반 partial: AOE·유나이트 이펙트·HP바·속성 임팩트 ※레이아웃은 ui-dev +- `Assets/Scripts/NPC/NpcVisualBuilder.cs` - NPC 프로시저럴 모델 빌더 +- `Assets/Scripts/NPC/NpcWalkAnimator.cs` - NPC 걷기 애니메이션 +- `Assets/Scripts/NPC/NpcGesture.cs` - NPC 몸짓 정의 + 각도 곡선 순수부(NpcGesturePose) +- `Assets/Scripts/Core/VillageBuilder.cs` - 마을 프로시저럴 지형/건물 +- `Assets/Scripts/Core/BlightVfx.cs` - 오염 거점 구조물·안개·지면 탈색·정화 붕괴 연출 +- `Assets/Editor/LiveSceneCapture.cs` - 배치모드 실화면 캡처(3D 변경을 눈으로 확인) ※IMGUI는 안 잡힘 +- `Assets/Editor/BlightSiteDebugMenu.cs` - 오염 거점 육안 확인용 에디터 메뉴(이동·정화·초기화) ## 현재 비주얼 시스템 diff --git a/.claude/audit-archive/covered-detail.md b/.claude/audit-archive/covered-detail.md new file mode 100644 index 0000000..38ef10e --- /dev/null +++ b/.claude/audit-archive/covered-detail.md @@ -0,0 +1,794 @@ +# Covered 상세 — 각 라운드의 변경 내역 원문 + +`.claude/audit-progress.md`의 Covered 인덱스에 대응하는 서술 원문이다. +본체는 훅·스킬이 매 호출마다 읽으므로 이름만 남기고 서술은 여기에 둔다. + +### CharacterPortraitRenderer (P0:2, P1:2, 2026-05-20) + +DrawWithOutfit 캐싱, DrawItemPreview 추가, footHalfGap 0.18→0.28, Proportions 헬퍼 통일. **사용자 명시 요청**: 캐릭터 미리보기 도구가 magnify만 표시되던 회귀 → DrawCharacterTool 9종 분기 추가 (gun/wand/lasso/shuriken/sword/web_shooter/magnify/camera/기본). ApplyToolShape ↔ DrawToolPreview ↔ DrawCharacterTool 3곳 분기 순서 1:1 정합. + +### PlayerVisualBuilder (P1:5, 2026-05-20) + +Body Cube화, 어깨 ±0.29, 신발 ±0.19, SafeDestroyMat 가드, 도구 손 좌표 + +### CloudSaveManager (P1:2, 2026-05-20) + +401 토큰 갱신 재시도 (Load/Save), pendingSave 회귀 수정 + +### PlaySceneBootstrap (P1:1, 2026-05-20) + +CriticalBootstrapException fail-fast + OnGUI 사용자 알림, GachaBox.AutoWire(database), RegionDefinitions 분리 + +### CharacterOutfitUI (P1:2, 2026-05-20) + +카드 미리보기 DrawItemPreview 호출, bigTitle/bigBonus CachedStyle + +### CharacterOutfitManager (P1:2, 2026-05-20) + +ApplyToolShape 손 좌표 재배치, magnify/camera 분리, **도구별 mesh 교체** (Cube/Cylinder/Sphere)로 막대기 회귀 차단 + +### PlayerMovement (P1:1, 2026-05-20) + +OutfitChanged 구독 + InvalidateToolBase, 도구 swing 동기 + +### CashShopManager (P1:1, 2026-05-20) + +wallet 단일 소스, 1회 PlayerPrefs 마이그레이션 + +### CaptureMinigameController (P0:1, P1:1, 2026-05-20) + +InitMinigameStyles 6 캐시 필드, OnDisable 안전망 + +### GachaBoxManager (P1:1, 2026-05-20) + +ValidateInsectId + DB 캐시 + +### TutorialQuestManager (P1:1, P0:4, 2026-05-20) + +CompleteQuest 후 CloudSave 즉시 동기. **튜토리얼 진행 회귀 P0:4건** — NotifyTeamSet/NotifyItemUsed/NotifyTraining/NotifySkillEquipped 호출 누락으로 q_equip/q_item/q_training/q_team이 영구 정지 + 후속 13개 퀘스트 연쇄 정지. 4곳에 Notify 호출 추가 (BattleTeamManager.TeamChanged 구독, ItemEffectManager.ActivateItem, TrainingManager.TrainSkill, TrainingUI EquipSkill 사용자 액션). + +### BattleScreenUI (P2:1 DrawCombo + P1:2 P2:1 추가, 2026-05-20) + +DrawCombo ComboCol, DrawSwapSelect SwapHeaderBase + alpha 갱신, DisableCanvasBattleUI + CheckGuardianDefeat FindFirstObjectByType 캐싱 + +### SubAreaWorldBuilder (P0:8 + UI:1, 2026-05-20) + +FindSafeSpawnPosition spiral 탐색, BuildCave/BuildTemple 입구 끼임 차단. 진입/퇴장 토스트 알림(3초) + F2 수동 Exit + 우측 하단 안내. RegionManager.ForceExitSubArea(). **사용자 명시 요청 2차**: 9개 환경 boundary wall halfSize를 floor 안쪽으로 조정 (Cave 16→14, DeepForest 18→16, Underwater 16→14, FogSwamp 16→14, MountainPeak 14→11, Temple 14→13, Reeds 14→11, GenericArea 12→9), BuildGreenhouse CreateGlassWall NoCollider 제거(collider 보존), Y 안전망(Y<-3 → 자동 RequestExit + 알림) + cachedPlayerTransform lazy 캐싱. + +### AuthManager (P1:2, 2026-05-20) + +TryRefreshTokenForRetry public + refreshInProgress 가드. **사용자 명시 요청**: ValidateCredentials 헬퍼 + Register/Login 진입 시 호출 (빈 이메일/짧은 비밀번호 차단, Firebase API 호출 절약). 인증 흐름 전반 검증: TryAutoLogin 단일 호출, idTokenAcquiredAt 갱신 모든 경로 OK, AuthFailed→LoginUI 복귀, 회원가입→CharacterCreate 흐름 정상. + +### LoginUI (P1:2, 2026-05-20) + +AuthFailed 구독 + 외형 변경 후 InvalidateCache. **사용자 명시 요청**: OnGUI 매 프레임 17개 new GUIStyle 변형 → base 스타일 fontSize 동적 갱신 패턴으로 교체 (BattleScreenUI/DexScreenUI 패턴). OnGUI 본문 잔존 new GUIStyle = 0. + +### RaidBattleUI (P0:2, P2:1, 2026-05-20) + +EndRaid 재진입 누수(타이머 4개 + 팀 캐시 3개 초기화), GameObject.Find Player → playerMovement.transform 캐싱 + +### RaidBattleController (P1:1, P0:1, 2026-05-20) + +UseUniteAttack 데미지 표시 정합성, CheckEnd 패배 시 BossEntity.Despawn 누락 + OnRaidVictory의 Despawn 가드 분리 + +### InsectBattleController (P0:2, 2026-05-20) + +패배 + 도주 성공 시 enemyEntity.Despawn 누락 — 둘 다 추가. 옛은 전투 후 곤충 필드 잔존, 중첩 발동 가능 + +### CaptureController (P0:1, 2026-05-20) + +미니게임 캡처 실패 시 50% Despawn → 100% Despawn. 사용자 명시: "미니게임 끝나면 사라져야" + +### CaptureFeedbackController (clean, 2026-05-20) + +Explore 점검 완료. P0/P1 회귀 없음. CaptureResolved += -= 짝 정상, AudioManager/TutorialQuestManager.Instance null 가드 모두 있음, OnGUI 메서드 없음(TextMeshPro 사용), 타이머 흐름 순차적 + +### BattleArenaController (P1:1, 2026-05-20) + +OnDisable ↔ CleanupArena 중복 정리 → OnDisable이 CleanupArena 위임으로 단일화. CleanupArena가 model 필드 null화까지 포함 (DRY + 옛 OnDisable 누락 보강). Explore P0:2 거짓양성 (Rect는 struct stack 할당, SetupNormalBattle 첫 줄 CleanupArena 호출됨) + +### DexController (clean, 2026-05-20) + +Explore P0/P1 모두 거짓양성. SaveAndNotify는 RegisterEncounter/RegisterCapture 끝에 무조건 호출됨 (라인 51, 70), encounter/capture는 별개 dictionary라 race 없음, 0.1초 debounce 시나리오는 정상 게임 흐름에서 발생 불가. DexListUIController는 별도 시스템 (DexController audit 범위 외). + +### DexDetailUIController (clean, 2026-05-20) + +Canvas 기반 UI (TMP_Text/Image), OnGUI 없음. 이벤트 구독 없음 (직접 Show/Hide 호출), AutoWire 안전, null 가드 견고 (라인 35-44). Resources.Load는 Show 1회 호출이라 P0 아님. 부수 발견: DexScreenUI에 OnGUI new GUIStyle 다수 → Uncovered 1순위로 분리. + +### DexScreenUI (P0:31, 2026-05-20) + +매 OnGUI 31개 new GUIStyle + 수십 개 new Color 회귀. InitDexStyles 패턴 + 31개 캐시 필드 + 매 프레임 핫패스 Color 38개 static readonly. OnGUI 본문 잔존 new GUIStyle = 0. 매 프레임 GC 압박 105~150 객체 → 0 객체. DrawTopBar(6) + DrawPokedex loop(6) + DrawDetail(12) + DrawOwnedCard loop(5) + DrawItems/Row(4) + DrawNoSelection(1) + DrawCentered(1) 모두 캐시 사용. + +### DexListUIController (clean, 2026-05-20) + +Explore P0/P1 모두 거짓양성. SortList의 dexController null 가드는 BuildList:60 early return으로 보장됨. AutoWire 이벤트 짝은 모든 시나리오(null/동일/새 인스턴스)에서 정상. BuildList 재생성은 사용자 액션 시만 호출 (매 프레임 아님). + +### SaveService 글로벌 atomic write 라운드 (P1:8, 2026-05-21) + +신규 AtomicFileWriter (Core/AtomicFileWriter.cs, 38줄) — tmp 파일 쓰기 + File.Replace로 OS 레벨 atomic rename. 8곳 일괄 교체: PlayerProgressSaveService/PlayerCandyInventory/PlayerCurrencyWallet/PlayerItemInventory/PlayerInsectCollection/BattleTeamManager/DexSaveService/CloudSaveManager.SaveLocalFile. 게임 종료 직전 크래시/SIGKILL 시 부분 쓰기로 인한 세이브 손상 차단. CashShopManager는 PlayerPrefs 사용으로 글로벌 라운드 제외. DexSaveService에 Save(null) 가드도 일관성 추가. + +### CollectionUI 핫스팟 (P0:1, P1:3, 2026-05-21) + +(a) P0: DrawInsectItem 핫스팟 5개 GUIStyle + 4개 Color 캐시화 — owned.Count × 5 = 최대 100+/프레임 → 0. InitItemStyles + itemNameStyle/itemInfoStyle/itemGradeStyle/itemStatMiniStyle/itemViewStyle + 6 static readonly Color. nameStyle/gradeStyle textColor 동적 갱신(BattleScreenUI 패턴). (b) P1: GetAllOwned 캐싱 — cachedOwned + ownedCacheDirty 플래그, HandleInsectUpdated에서 invalidate. line 116(DrawInsectList) + line 320(DrawStats) 매 프레임 List 할당 제거. (c) P1: OnEnable/OnDisable/AutoWire에 InsectUpdated -=/+= 짝 추가. 옛은 OnDisable이 ModalUIRegistry.Unregister만 호출, 이벤트 해제 누락. AutoWire가 OnEnable 후 호출되는 경우 isActiveAndEnabled 가드로 구독. (d) P1: DrawCenteredLabel의 new Color 2곳 static readonly. **잔여**: DrawPanel(P1:5)/DrawDetailPanel(P1:10)/DrawStats(P1:5)/DrawLevelUpSection(P1:10)/DrawStatBar(P1:5) — 호출 빈도 1회/프레임으로 P1, 별도 라운드 권고. + +### TrainingUI 핫스팟 (P0:1, P1:3, 2026-05-21) + +(a) P0: DrawInsectSelect loop 4 GUIStyle + 4 Color 캐시 — owned.Count × 4 = 최대 80+/프레임 → 0. InitInsectSelectStyles + insectSelectSub/Name/Info/BtnStyle + 4 static readonly Color. NameStyle textColor 동적 갱신. (b) P1: GetAllOwned 캐싱 — cachedOwned + ownedCacheDirty + HandleInsectUpdated invalidate (CollectionUI 패턴). (c) P1: OnEnable/OnDisable/AutoWire에 InsectUpdated -=/+= 짝 추가. 옛은 OnDisable이 ModalUIRegistry.Unregister만 호출, 이벤트 해제 누락. AutoWire가 OnEnable 후 호출되는 경우 isActiveAndEnabled 가드. (d) **잔여**: DrawPanel/DrawMethodSelect/DrawSkillLearn/DrawSkillEquip/DrawSkillReplace — 약 40+ new GUIStyle/Color, 호출 빈도 1회/프레임으로 P1, 별도 라운드 권고. + +### CollectionUI 잔여 영역 (P1:27, 2026-05-21) + +DrawPanel/DrawDetailPanel/DrawStats/DrawLevelUpSection/DrawStatBar/DrawCenteredLabel 27개 new GUIStyle 일괄 캐시화. InitDetailStyles + 25 캐시 필드(panelTitle/Close/TabActive/TabInactive, detailBack/Name/Rarity/GradeDisp/GradePerc/Desc/Hint, statsLabel/Value/CandyVal, luLvLabel/Num/XpLabel/XpVal/MaxLv/Btn/CandyInfo/Msg, barLabel/Iv/Total/IvLabel, centeredLabel) + 25 static readonly Color. 5개 메서드 모두 textColor 동적 갱신 패턴(detailName/Rarity/GradeDisp/GradePerc + luCandyInfo/Msg + barIv + centeredLabel). DrawStats에 GetCachedOwned 사용 추가(매 프레임 GetAllOwned 회피). 동적 rarityCol scaled/alpha scaled new Color는 struct stack 거짓양성. OnGUI 본문 잔존 new GUIStyle = 0. + +### TrainingUI 잔여 영역 (P1:34, 2026-05-21) + +DrawPanel/DrawBackButton/DrawMethodSelect/DrawSkillLearn/DrawSkillEquip/DrawSkillReplace/DrawSkillCard/DrawFeedback 34 new GUIStyle 일괄 캐시화. InitTrainDetailStyles + 33 캐시 필드 + 38 static readonly Color. 8 메서드 textColor 동적 갱신 패턴(methodName/CardName/Cost + learnHeader + equipName/SlotName/LearnedName + replaceNew/OldName + cardSkillName + feedback). themeColor scaled new Color (line 246)는 struct stack 거짓양성, feedback alpha 동적 new Color도 거짓양성. OnGUI 본문 잔존 new GUIStyle = 0. + +### PlayerStatusHUD (P0:1, P1:2, 2026-05-21) + +(a) P0: OnGUI 본문 new Color 16곳 → 16 static readonly Color 일괄 추출 (PanelBg/AccentBlue/Divider, LvBadgeBgDark/Accent, XpBarBg/FillDark/Light, StatCandyPink/CoinGold/GemBlue/TeamOrange/OwnedGreen/DiscoveredBlue/CapturedGold, RegionDefault). DrawResourceSection 4 + DrawCollectionSection 3 + DrawLevelSection 5 + DrawPanel/DrawRegionSection 3+1. (b) P1: GetAllOwned 캐싱 — cachedOwnedCount + ownedCountCacheDirty + HandleInsectUpdated invalidate (CollectionUI/TrainingUI 패턴). (c) P1: OnEnable/OnDisable에 InsectUpdated 구독/해제 짝 추가 (subscribedInsects 플래그). 옛은 매 프레임 DrawCollectionSection이 GetAllOwned() 호출하여 새 List 할당. shine alpha 동적/regionCol scaled/accent scaled/alertCol alpha 동적 new Color는 struct stack 거짓양성. new Rect 좌표 동적도 거짓양성. + +### TrainingManager (P1:1, 2026-05-21) + +TrainSkill의 SpendCandy 호출을 LearnSkill/ReplaceSkill 성공 후로 이동 + 실패 시 early return. 옛은 ReplaceSkill 실패(replaceSkillId가 learnedSkillIds에 없는 경우 등) 시 candy 차감 후 학습 안 됨 → candy 손실. 정상 흐름(TrainingUI.DrawSkillReplace의 learned 리스트에서 선택)에선 안전하나 방어 부재. LearnSkill은 line 64(HasLearnedSkill) + line 65(ContainsKey) + line 75(IsSkillsFull) 사전 가드로 실패 불가(거짓양성)이나 일관성 차원에서 동일 패턴 적용. CanTrain vs SpendCandy race / 자동 장착 NotifySkillEquipped 미발화 / TrainingCompleted vs NotifyTraining 순서 모두 거짓양성. + +### CashShopManager 잔여 영역 (P1:3, 2026-05-22) + +(a) PurchaseWithGems line 119 stale `gems` 캐시 → `Gems` 프로퍼티(wallet 우선) 사용 — 외부 wallet 변경 후 잘못된 잔액 검사 차단. (b) line 137 `gemsBefore = gems` → `Gems` — 환불 액수 stale 방지. (c) AddGems(0) early return — 무의미한 GemsChanged 발화 + 구독자 무의미한 갱신 차단. 단일 스레드 race는 모두 거짓양성, FindFirstObjectByType 매 호출은 결제 빈도 낮음으로 P2, shopItems 데이터 검증은 inline 정의로 수동 책임 P2. + +### InsectSpawner Spawn/Despawn 다중 호출 검증 (clean, 2026-05-22) + +진짜 P0/P1 회귀 없음. 직전 라운드 InsectEntity.despawnedThisCycle 플래그 동작과 일관. DespawnAllActiveInsects snapshot + Clear 패턴 안전(콜백 Remove no-op), DespawnFarInsects 역순 for + Remove 안전(i-- 인덱스 보존), 풀 재사용 시 Initialize의 cache/플래그 리셋으로 안전, OnSubAreaChanged 빠른 Exit는 currentSubArea != subArea 가드(line 698), DespawnEntity 외부 직접 호출 없음, activeInsects 중복 추가 불가, Unity 단일 스레드로 모든 race 거짓양성. SpawnSubAreaInsects maxActiveTotal 검사 부재는 DespawnAll 직후 호출이라 P2 수용. Covered 이동만. + +### RegionMapUI 깊은 점검 (clean P0, P1 보류, 2026-05-27) + +Explore P0:2(이벤트 누락/null 가드) 모두 거짓양성. 라인 143 `regionManager == null || regionManager.Regions == null` early return 가드 있음. CurrentRegion stale은 OnGUI 매 프레임 regionManager.CurrentRegion 직접 조회로 정합. 30+ new GUIStyle 매 OnGUI는 진짜 P0이나 변경 폭 크고 RegionMapUI는 모달 토글 시만 진입(매 프레임 아님) → P1 별도 라운드 권고. + +### TutorialQuestUI DrawQuestPanel 캐시 (P0:1, 2026-05-27) + +DrawQuestPanel은 OnGUI에서 매 프레임 호출되어 핫스팟. 5 GUIStyle(doneStyle/title/desc/prog/hint) 매 프레임 new → InitQuestPanelStyles + 5 캐시 필드 + 3 static readonly Color (DoneTextCol/QuestTitleCol/QuestHintBaseCol). hintStyle은 alpha 동적이라 textColor만 갱신 (BattleScreenUI 패턴). AutoWire 이벤트 중복 구독은 라인 499-504 `-=` + `+=` idempotent로 거짓양성. 잔존 8곳 new GUIStyle은 일시 표시(DrawCompletionNotification 3초 / DrawNewQuestNotification 2초 / DrawDetailPanel 수동 토글)이라 별도 라운드. + +### WorldChannelManager 깊은 점검 (clean P0/P1, 2026-05-27) + +Explore P1:3 모두 거짓양성/P2 강등. BestEffortLeaveWorld dispose 누락은 OnApplicationQuit 직후 OS 프로세스 정리. LeaveWorld race는 leaveInProgress 플래그 미적용이나 LeaveWorld 호출 빈도 매우 낮음(사용자 의도 액션 1회) + isUpdatingPresence 이전 처리로 PATCH 일반 race 차단됨 → P2 수용. 401 자동 갱신은 WorldChannel 영역 외(이전 AuthManager TryRefreshTokenForRetry 처리는 CloudSaveManager 한정 의도된 범위). + +### **RegionTerrainBuilder c.y 이중 합산 버그 + +진짜 원인** (P0:21, 2026-05-27) — 사용자 명시 "고대유적/숲 layer 층이 위에 떠있어 캐릭터/곤충 가려짐" 보고. WorldTerrainBuilder.ApplyElevation(라인 22-39)이 region center.y를 forest:4 / ruins:8 / mountain:12 / pond:-3 / swamp:-2로 변경한 후, BuildXxxTerrain의 `c + new Vector3(x, c.y + offset, z)` 패턴이 c.y를 또 더해 결과 Y = **2 × c.y + offset**. ruins(c.y=8) 환경 모두 Y=16m로 솟아 캐릭터(Y=8.2 머리) 위 8m 떠있던 게 "층이 위에" 인상의 진짜 원인. 21곳 일괄 수정: `, c.y, ` → `, 0f,`, `, c.y + ` → `,`, `, c.y - ` → `, -`. Pond/Swamp/Mountain/Garden 동일 버그 함께 수정. Forest/Ruins 환경 축소 이전 라운드들(P1:4 + P0:11 누적)은 효과 거의 없었음 — 진짜 원인은 이 이중 합산 버그. + +### Forest/Ruins 환경 추가 축소 + +"layer 층 전체 위에" (P1:2, 2026-05-27) — 사용자 추가 보고 "layer 층 전체가 위에 떠있어 캐릭터/곤충이 가려짐". 이전 절반 축소(treeH 1.2~2.5, wh 0.5~1.5, ph 1~2)로 부족 → 추가 강제 축소. (a) Forest treeH 0.8~1.5(낮은 관목), leafS 1~1.8 → 잎사귀 Y 1~2.5m로 캐릭터 머리(2.2) 이하/근처. (b) Ruins wh 0.3~0.8(잔해), ph 0.7~1.3(캐릭터 어깨 아래). 25 트리/8 기둥이 평면 layer로 모이지 않음. + +### 메인 월드 Forest/Ruins region 환경 높이 (P1:2, 2026-05-27) + +사용자 명시 "고대유적과 숲이 메인 월드에서 하늘에 있는 것처럼 보임" 보고. RegionTerrainBuilder.BuildForestTerrain/BuildRuinsTerrain (이전 SubArea 변경은 사용자 의도 영역 외였음, 영향 없으나 무관). (a) **Forest**: treeH 3~6→1.2~2.5(절반 축소), leafS 2~4→1.5~2.5. 트리 trunk Y 0~6m→0~2.5m, 잎사귀 Y 3.6~7.2m→1.8~3.5m로 캐릭터 머리(2.2) 부근. 잎사귀 ShadowsOnly 제거 — 옛 ShadowsOnly가 leaf mesh 안 보이고 그림자만 공중에 떠 보이는 "환경이 하늘에 떠있어" 인상의 진짜 원인이었음. 정상 렌더링 + leafS 축소로 카메라 시야 차단 최소화. (b) **Ruins**: wh 1~3→0.5~1.5, ph 2~4→1~2. 벽 Y 0~3m→0~1.5m, 기둥 standing Y 0~4m→0~2m로 캐릭터와 비슷한 평면감. + +### SubArea 덮개 가시성 (P1:2, 2026-05-27) + +사용자 명시 "고대유적/숲이 하늘처럼 덮여서 캐릭터 안 보임" 보고. (a) DeepForest 잎사귀 ShadowsOnly 적용 — 그늘 인상 유지하며 카메라(Y=10)와 캐릭터(Y=1.04) 사이 시야선 차단 해제 (Cave 천장 동일 패턴). (b) CameraFollower SubAreaOffset (0,16,-12)→(0,10,-14) 측면화 — 부감 53도→36도로 줄이고 z 거리 -12→-14로 늘려 캐릭터 옆에서 보이도록. 환경 위 평면에서 캐릭터 가려지던 회귀 차단. Temple 기둥은 양옆 배치라 측면 시야로 충분히 보임 (ShadowsOnly 미적용). + +### 어깨 추가 + SubArea 부유/환경 높이 후속 (P1:4, 2026-05-27) + +사용자 명시 후속 보고. (a) **어깨 추가 하향**: ArmL/R Y 1.55→1.40 (캡슐 범위 1.15~1.65m, Body 중간 위치). HandL/R Y 0.98→0.95. (b) **SubArea 부유 해결**: SubAreaOrigin Y 0→0.5로 한 줄 변경 → 8개 모든 환경(subAreaRoot 자식)이 world Y=0.5로 일괄 올라가서 캐릭터/곤충(Y=0.5 텔레포트→Y=1.0)과 같은 평면감. (c) **DeepForest 높이 축소**: 트리 기둥 Y 3→1.5 scale 3→1.5, 잎사귀 Y 6.5→3 scale (3.5,2.5,3.5)→(2.5,1.5,2.5). (d) **Temple 기둥 축소**: Y 3→1.8 scale 3→1.8. 카메라 SubAreaOffset (0,16,-12) 유지 — 환경이 낮아져 차폐 가능성 추가 감소. 다른 6개 환경(Cave/Underwater/FogSwamp/MountainPeak/Reeds/Greenhouse/GenericArea) 높이 축소는 사용자 추가 보고 시 별도 라운드. + +### 캐릭터 시각 버그 3종 (P1:3+구조 변경, 2026-05-27) + +사용자 명시 요청. (a) **어깨**: ArmL/R Y 1.62→1.55 + scale Y 0.55→0.50 + HandL/R Y 1.0→0.98. 캡슐 상단 1.895→1.80m로 Body 상단(1.79)과 일치, 머리(2.20) 영역 침투 차단. (b) **발 swing**: BootL/R을 Player 직접 자식 → LegLPivot/LegRPivot 빈 컨테이너 자식으로 구조 변경 (Capsule scale 비균등이라 Pivot scale=1 유지 필수). PlayerMovement cachedLegL/R → cachedLegPivotL/R, AnimateWalk 회전 대상 Pivot으로 교체 → Leg+Boot 둘 다 자동 전파. CharacterOutfitManager.ApplyPartColor는 FindDeep 재귀 검색이라 호환. (c) **SubArea 차폐**: SubAreaWorldBuilder에 GetSubAreaEnvLayer(Layer 31 fallback) + SetLayerRecursively 헬퍼 추가, EnterSubArea의 8개 환경 빌드 끝에 1회 호출(개별 빌드 메서드 미수정). CameraFollower.ResolveObstruction hit loop에 layer 제외 1줄. CameraFollower.SetSubAreaMode(bool) 추가 (NormalOffset (0,12,-8) ↔ SubAreaOffset (0,16,-12)). SubAreaWorldBuilder EnterSubArea/ExitSubArea에서 SetSubAreaMode(true/false) 호출 (ResetBaseline 내장). + +### TutorialQuestUI 잔존 캐시 (P1:8, 2026-05-27) + +DrawCompletionNotification 3 + DrawNewQuestNotification 1 + DrawDetailPanel 4 (header/close/row/status) GUIStyle 매 호출 new → InitNotifStyles + InitDetailStyles + 8 캐시 필드 + 9 static readonly Color. 알림 4 textColor alpha 동적, row/status textColor 분기 동적(완료/활성/잠금/대기). 17 quest×2(row+status)=34 GUIStyle/프레임 차단. OnGUI 본문 잔존 new GUIStyle=0. + +### CapturePopupUI 캐시 (P1:12, 2026-05-27) + +DrawSuccessPopup 8 + DrawFailPopup 2 + DrawMiniIVBar 2 GUIStyle 매 OnGUI(popupTimer>0 5초) new → InitPopupStyles + 12 캐시 필드 + 7 static readonly Color (SubGrayBase/GradeTitleGrayBase/RewardCandyBase/RewardExpBase/FailMsgBase/FailSubBase/IvLblBase). 12 textColor alpha+rarityCol+gc 동적 갱신. OnGUI 본문 잔존 new GUIStyle=0. + +### RegionMapUI 캐시 + OnDisable 정리 (P1:28+1, 2026-05-27) + +(a) DrawMap 5 + DrawErrorMessage 1 + DrawMiniMap 5 + DrawRegionList 5 + DrawRegionDetail 6 + DrawDexItem 6 + DrawMapTerrain symStyle 1 = 28 GUIStyle 매 OnGUI new → InitMapStyles + 28 캐시 필드 + 3 static readonly Color. DrawMap/DrawRegionDetail 진입에 InitMapStyles 호출. textColor 분기/alpha/themeColor 동적 갱신. (b) OnDisable에 isOpen=false + selectedRegionId=null + ModalUIRegistry.Unregister 추가 (CharacterOutfitUI 동일 P1 패턴, GO SetActive 토글 시 stale 모달 차단). OnGUI 본문 잔존 new GUIStyle=0. + +### WorldLobbyUI MakeTex 누수 (P0:7, 2026-05-27) + +DrawWorldSelectPanel/DrawWorldRow/DrawJoiningPanel/DrawInWorldPanel의 OnGUI 매 프레임 7곳 `MakeTex(1,1,...)` 새 Texture2D 생성 → UIHelper.GetCachedTex 교체. static readonly Color 10개 추출(BgOverlayCol/RowFull/Almost/OkCol/BarBgCol/FillFull/Almost/OkCol/LineSepCol/MeRowBgCol). 월드 1개당 4 Texture2D(rowTex/barBg/fillTex)/프레임 + 배경 1 = 월드 수×4+1 객체/프레임 누적 누수 차단. InitStyles의 4곳 MakeTex(라인 397/426/429/430)은 1회만 호출이라 유지. + +### CharacterOutfitUI infoStyle 캐시 + OnDisable 정리 (P0:1+P1:1, 2026-05-27) + +(a) P0: OnGUI 라인 234/239의 infoStyle/infoNameStyle 매 프레임 new GUIStyle 생성 → InitStyles에 infoStyleCache/infoNameStyleCache 캐시 추가 + DrawPanel에서 직접 참조. InfoLabelCol static readonly로 추출. (b) P1: OnDisable이 ModalUIRegistry.Unregister만 호출하고 isOpen=true 잔존 → 같은 GO SetActive 토글 시 isOpen=true이지만 Registry 미등록 → HandleEscape가 모달 무시. isOpen=false 추가. + +### CapturePopupUI 점검 (clean P0, P1:1 보류, 2026-05-27) + +알파 계산 라인 117은 의도된 fade-in(animTime/0.3, 0.3초)/fade-out(popupTimer/0.5, 마지막 0.5초) 분기로 P2 수용. OnGUI 매 프레임 new GUIStyle 14개+new Color 다수는 popupTimer>0(약 5초 표시 시간)에만 진입하여 일시적 → P1이나 다른 핫패스 처리 후 별도 라운드 권고. 이벤트 구독/null 가드/UIHelper 사용 모두 정합. + +### CharacterPortraitRenderer 깊은 정합성 (clean, 2026-05-27) + +Explore P0:2+P1:2 모두 거짓양성. hatId 처리 분기는 hat_none 시 customHat=true(라인 332)→Draw 내부 모자 투명 + 라인 501 hat_none 조건 거짓→형태 분기 안 들어감 → 모자 미표시 일관. cutlass(라인 435-441) DrawCharacterTool은 손잡이+칼날 2단계, DrawToolPreview의 차이는 별도 미리보기 컨텍스트(아이템 카드 vs 캐릭터)이라 의도된 시각적 차이. InvalidateCache 트리거는 LoginUI에서 직접 호출(라인 590)로 처리됨. 9종 도구 결합도는 구조적 P2. + +### PlaySceneBootstrap 깊은 점검 (clean, 2026-05-27) + +Explore P0:1+P1:2 모두 거짓양성. RegionChanged 람다 누수는 RegionManager가 DontDestroyOnLoad 아님(grep 결과 AudioManager/Auth/CloudSave 3개만) → 씬 재로드 시 옛 RegionManager+옛 람다 함께 GC. AuthManager.Instance 라인 401은 != null 가드 있음. playerMovement.AutoWire는 라인 330에서 실제 호출됨(Awake → Build() → Update 라이프사이클상 안전). + +### SubAreaWorldBuilder 깊은 점검 (clean, 2026-05-27) + +Explore P0:1+P1:6 모두 거짓양성. EnterSubArea race는 Unity Input 단일 프레임 폴링이라 같은 프레임 F2 불가능. ShowMainWorld 복원은 hiddenMainObjects 기반(HideMainWorld가 add한 것만)이라 이름 규칙 벗어난 오브젝트는 add도 안 되어 복원 필요 없음(정합). sticky 회피는 0.01s < 1.5s 쿨다운으로 유지. Destroy 비동기는 Unity 표준 동작. 코루틴 누수는 현재 횃불 애니메이션 없음(future bug). cachedPlayerTransform은 Player Transform 자체라 위치만 변하지 stale 참조 아님. InsectSpawner 부모 mismatch는 SubAreaWorldBuilder 영역 외(별도 라운드 후보). + +### TutorialQuestManager 깊은 점검 (clean, 2026-05-27) + +Explore P0:1+P1:3 모두 거짓양성. NotifyAction 비활성 무시는 prerequisite 체인 강제 순차 진행으로 비순차 시나리오 발생 불가. CloudSave race는 pendingSave 플래그로 단일 대기 처리. 옛 세이브 activeQuestId 불일치는 LoadProgress가 completedQuests 함께 로드 후 ApplySaveData 적용 + Start의 ActivateNextQuest 흐름이 일관. 마스터 계정 activeQuestId=null은 17 퀘스트 모두 완료 상태에서 의도된 동작(퀘스트 다 끝낸 유저는 UI 표시 안 하는 게 자연스러움). 의도 변경 필요 시 별도 디자인 작업. + +### BattleScreenUI 깊은 로직 (clean, 2026-05-27) + +Explore P0:2+P1:3 모두 거짓양성. cachedRegionMgr는 라인 313에 `regionMgr == null` 가드 있음. EndBattle 라인 3094-3099 finally 블록에 ExitBattleMode + SetFrozen(false) + Time.timeScale 복구 완비. SwapSelect↔PlayerTurn 키 처리는 phase별 격리(라인 379-384/405-412) + OnGUI Event 라인 726-748 phase 체크 후 set + Update phase 체크 후 consume + 즉시 reset(라인 411-412)으로 안전. InsectBattleUIController.ShowResult는 별개 Canvas UI 시스템, BattleScreenUI(OnGUI)와 무관. lastDamageToEnemy/Player phase 전환 미초기화는 다음 OnBattleUpdated에서 덮어쓰니 P2 수용. nameTagCache.fontSize 동적 할당은 GUIStyle 자체 캐시 + 프로퍼티만 갱신이라 P2. + +### RaidBattleUI 깊은 로직 (clean, 2026-05-27) + +Explore P0:3+P1:5 모두 거짓양성. EndRaid 라인 2380-2384 displayTeamHp/teamShake/selectedSlot/cameraFollower/playerMovement 모두 정리(이전 라운드 P0:2 처리분과 정합). AutoWire 라인 3005 `raidController != rc` 가드로 동일 rc 재호출 시 중복 구독 차단, null rc 시 라인 3005 false라 raidController=null 미설정 안전. Result→None은 EndRaid에서만 세팅, 라인 309 None 진입은 새 레이드 시작이라 정상. lastDmgToBoss 게이지 누적은 RaidController 턴 단위 관리로 race 없음. SelectInsect KO 차단은 TrySelectInsect 라인 585-586 CurrentHp > 0 체크 있음. + +### BattleArenaController 이펙트 (clean, 2026-05-27) + +Explore P0:3+P1:3 모두 거짓양성. Material 누수는 Unity가 GameObject Destroy 시 attached Renderer.material 인스턴스 자동 정리. StopAllCoroutines 후 arenaRoot Destroy하면 child GameObjects + 그 Material 인스턴스 일괄 정리. ExitBattleMode/SetFrozen(false)는 호출자(BattleScreenUI EndBattle:3097-3098, RaidBattleUI EndRaid:2383-2384)에서 책임 분리 패턴으로 처리. 다중 SetupNormalBattle 호출은 라인 57/92에서 CleanupArena가 진입 첫 줄이라 안전. 투사체는 라인 623 arenaRoot.SetParent로 자동 정리. 팀 배치 정적 계산은 게임이 runtime 팀 변경 미지원으로 비해당. + +### PlayerMovement 모달 차단 + InputAction 흐름 (clean, 2026-05-27) + +559줄, 8 책임 매트릭스 점검 결과 진짜 P0/P1 회귀 0건. 모달 차단: HandleEscape() + IsAnyOpen() 이중 방어(Line 137-143 ESC return + Line 169 마우스 클릭 차단). Frozen ESC 처리는 모달 우선(Line 121-131). 이중 ESC 회귀는 ModalUIRegistry.IsAnyOpen 자동 정리(Line 37-40)로 거짓양성. InputAction 흐름은 Unity 구식 Input.GetKey + OnGUI Event 백업(guiKey*/guiEsc/guiClick) 이중 경로로 F9/F11 OS 인터셉트·Editor·접근성 장치 우회. KeyUp에서 guiKey* 명시 false로 누적 없음(Line 318-330). OutfitChanged 동기는 InvalidateToolBase 즉시 호출로 stale base 1프레임 지연 차단(2026-05-20 P1:1 완료). CameraFollower 점프는 SetTarget baselineValid 리셋(2026-05-21 P1:2 완료). FindFirstObjectByType 호출 0건. cachedArm/Leg/Body/HeadPivot은 transform.Find 1회 후 캐싱(Line 42-46) 정상. outfitBonus null 가드(Line 240) 있음. **Covered 이동만**. + +### InsectSpawner (P1:2, 2026-05-20) + +OnGUI debugStyleCache lazy 캐싱(매 프레임 new GUIStyle 차단), GetPlayerTransform lazy 캐싱(매 호출 FindWithTag+Find 회피). Explore P0:2 거짓양성 (CleanupDeadEntities는 외부 SetActive(false) 직접 호출 없음 + DespawnFarInsects null entity는 이미 destroy 상태라 RemoveAt만 정상), P1 Sin/Cos는 8초당 8회 = 초당 1회 거짓양성. + +### RegionManager (P1:3, 2026-05-20) + +IsRegionAccessible 마스터 우회 (AuthManager.IsMasterAccount race 차단), DefeatGuardian 중복 가드 (HashSet idempotent 명시 + SaveUnlockState 중복 PlayerPrefs.Save 회피), LoadUnlockState unlockedRegions Split RemoveEmptyEntries (옛은 빈 항목 누적). P2 Time.time vs realtimeSinceStartup은 일반 게임 timeScale 영향 적어 수용. + +### RegionTerrainBuilder + RegionMapUI (UI:2, 2026-05-21) + +사용자 명시 요청. BuildBoundaries 신규 (60-segment fence + 자동 인접 검출 gateway gap + 노란 마커, 환경별 fence shape). RegionData.connections 선택 필드 추가. RegionMapUI: 사각 테두리 → 원형 ring(32 sample) + 현재 리전 펄스 alpha + gateway 노란 점. + +### ItemEffectManager (clean, 2026-05-21) + +Explore P0:1+P1:2+P2:1 모두 거짓양성/자체 모순. ActivateItem 옛 item 덮어쓰기는 의도된 동작, 게임 종료 시 메모리 해제, ActiveItemChanged 구독자가 OnDisable에서 해제. + +### WorldStateProvider (clean, 2026-05-21) + +Explore P0:2+P1:1+P2:1 모두 거짓양성. WorldState는 struct(stack 할당, GC 없음), pull 모델이라 이벤트 구독 디자인상 불필요, null 가드 fallback(DayPhase.Day/hour 12/Clear) 정상. + +### WorldChannelManager (P1:1, 2026-05-21) + +Update 코루틴 중복 실행 차단(isRefreshingWorld/isUpdatingPresence 플래그). 네트워크 지연으로 30초 timeout 초과 시 매 프레임 새 코루틴 시작 → CurrentWorld 동시 접근/PATCH race. Explore P0 AuthManager null 거짓양성(모든 public API에 IsFirebaseReady 가드), WorldLobbyUI OnDestroy 거짓양성(Unity OnDestroy 전 OnDisable 자동), BestEffortLeaveWorld 중복 거짓양성(RemoveAll idempotent). + +### BattleTeamManager (P1:2, 2026-05-21) + +SetSlot no-op 가드(같은 값 재설정 시 TeamChanged 발화 안 함), MigrateLegacySlots TeamChanged 발화 제거(시스템 내부 정리는 사용자 액션 아님). 옛은 TutorialQuestManager가 TeamChanged 구독해 q_team 진행도 자동/중복 가산 위험. Explore P0:2+P1:1+P2:1 모두 거짓양성(슬롯 부족 자동 채움, saveData fallback, 사본 반환, single execution). + +### BattleTeamUI (P1:16+22, 2026-05-21) + +16개 GUIStyle 캐시 필드 + InitTeamStyles + 21개 static readonly Color. DrawTeamPanel(3) + DrawSlot(7) + DrawInsectPicker(2) + DrawPickerItem(4) 모두 캐시 사용. railCol/pickerNameCache.textColor 동적 갱신. OnGUI 본문 잔존 new GUIStyle/Color = 0. 매 프레임 owned.Count × 5+ 객체 → 0. + +### PlayerInsectCollection (P1:3, 2026-05-21) + +(a) Awake에서 instanceId 중복 시 새 GUID 발급 + dirty mark — 옛은 dictionary가 1개만 보존되어 BattleTeam.GetByInstanceId가 나머지 인스턴스 못 찾는 회귀(옛 세이브 마이그레이션 시). (b) GainXp(data, insect, amount)에 insect null 가드 — 형제 오버로드(line 169)와 비대칭으로 line 185 NRE 위험. 호출자 0건 확인했으나 방어 코드 유지. (c) GetAllOwned null 항목 sanitize — 손상 세이브 시 호출자 6곳(BattleTeamUI/CollectionUI/DexScreenUI/TrainingUI/PlayerStatusHUD/PlayerInsectSelectionUIController) NRE 차단. 슬롯 인덱스 race는 Unity 단일 스레드 + BattleTeamManager.SetSlot 중복 가드(line 59-63)로 거짓양성. + +### PlayerProgressController (P1:3, 2026-05-21) + +(a) Awake sanitize: 손상 세이브 시 level 1~maxLevel 클램프 + currentXp ≥ 0 클램프 + dirty 시 즉시 Save. 옛은 외부 JSON 편집/손상 파일로 GetXpToNextLevel 음수 진입 위험. (b) 만렙 도달 시 currentXp = 0 클램프 — `Lv 50 / XP 99999` UI 불일치 차단. (c) 만렙 후 GainXp 호출 시 early return — 잉여 XP 누적 + 무의미한 디스크 쓰기/SaveCloud 차단. level/xp 동기 race는 단일 writer(GainXp) + private data로 거짓양성. 디스크 IO 디바운스는 호출 빈도 낮아(배틀/캡처/튜토리얼 시점만, burst 없음) P2 수용. CloudSaveManager.Instance null 가드는 SaveToCloud 내부 3중 가드(IsFirebaseConfigured + IsLoggedIn + IsSaving)로 거짓양성. + +### PlayerProgressSaveService (P1:1, 2026-05-21) + +Save(null) 가드 추가. static service라 임의 호출자가 진입 가능, 옛은 JsonUtility.ToJson(null)이 빈 문자열 → WriteAllText로 빈 파일 → 다음 Load 데이터 손실. atomic write 부재(P2)는 다른 SaveService(PlayerInsectCollection/BattleTeamManager/CashShopManager 등) 모두 동일 패턴이라 일관성 차원에서 별도 글로벌 라운드로 이관. 단일 writer(PlayerProgressController.GainXp)라 동시 쓰기 race 없음, FromJson 손상 회복은 catch + ?? new로 충분. + +### PlayerCandyInventory (P1:2, 2026-05-21) + +(a) AddCandy/SpendCandy 내부 data null 가드 추가 — Candies 프로퍼티(line 17)는 null 가드 있으나 두 변경 메서드는 비대칭, Awake 실패/순서 race 시 NRE. (b) Save(null) 가드 — PlayerProgressSaveService와 일관성. 호출자 6곳(InsectBattleController/CaptureController/RaidBattleController/CashShopManager/GachaBoxManager/UI)은 외부 `candyInventory?.` 가드 사용 중. 디스크 IO 디바운스는 호출 빈도 낮음(배틀/캡처/가챠 보상만)으로 P2 수용, atomic write는 글로벌 라운드로 이관. + +### PlayerCurrencyWallet (P1:5, 2026-05-21) + +(a-d) AddGems/SpendGems/AddCoins/SpendCoins 4개 변경 메서드 data null 가드 추가 — Gems/Coins 프로퍼티(line 18-19)와 비대칭 회귀. (e) Save() 내부 data null 가드 — 빈 파일 쓰기 + CurrencyChanged 구독자에게 null 전파 차단. CashShopManager gems 단일 소스 패턴(이전 audit)과 정합 유지. atomic write는 글로벌 라운드로 이관. + +### PlayerItemInventory (P1:3, 2026-05-21) + +(a) Awake sanitize: 손상 세이브 null record / 빈 itemId / 중복 itemId 처리. 중복 시 count 합치고 stale record 제거 — 옛은 lookup이 마지막 record만 보존해 AddItem 변경이 stale로 동기화 깨짐. (b) GetSnapshot 얕은 복사 반환 — DexScreenUI:659 등 호출자가 save.items 직접 변경 시 lookup race 차단. (c) Save(null) 가드 — 다른 SaveService와 일관성. UseItem 0 잔량 제거 로직(line 78-82)은 정상, GetCount 음수 클램프(line 96)도 정상. + +### PlayerInsectLevelUpUIController (P1:3, 2026-05-21) + +(a) Refresh에서 current null 가드 — selectedInstanceId가 stale(곤충 삭제/강화 후 변경)이면 GetByInstanceId null 반환 → line 75 current.insectId NRE. (b) OnEnable/AutoWire 이중 구독 차단 — subscribed 플래그 + Subscribe/Unsubscribe 헬퍼. 옛은 AutoWire 먼저 → 구독 → OnEnable → 또 구독 시나리오에서 HandleInsectUpdated 중복 발화, Refresh 2회 호출. (c) AutoWire가 isActiveAndEnabled 시에만 Subscribe — OnDisable 동안 구독 잔존 차단. 호출자 곤충 변경 시 자동 갱신 정상. + +### ShopUIController (P1:1, 2026-05-21) + +Start line 65 itemDatabase null 가드 추가 — EnsureDatabase의 Resources.Load("ItemDatabase") 실패 시 itemDatabase는 여전히 null → FindById NRE. UpdatePriceLabels(line 188-205) dead code (호출처 없음, allowCoinPayment [SerializeField]라 런타임 불변)이나 제거 risky로 P2 보류. 토글 무한 루프는 SetCoinPayment/SetGemPayment의 `if (!enabled) return;` first guard로 거짓양성. CashShopManager.Instance race는 단일 스레드로 거짓양성, wallet 동기화는 이전 CashShopManager audit에서 단일 소스 정리 완료. + +### GachaBoxUI → CashShopUI 가챠 영역 (P0:11, 2026-05-21) + +GachaBoxUI 클래스는 존재하지 않음(가챠 코드가 CashShopUI에 통합). DrawGachaTab/DrawBoxCard/DrawGachaResultScreen 매 OnGUI new GUIStyle 11개 + new Color 5개 회귀. InitGachaStyles + 12개 캐시 필드 + 5개 static readonly Color (BoxBronzeCol/BoxSilverCol/BoxGoldCol/GachaFlashBlue/GachaFlashGold/GachaPriceAffordCol/GachaRateGrayCol/GachaCandyPinkCol). boxPriceStyle/gachaTitleStyle은 textColor만 동적 갱신(BattleScreenUI 패턴). Phase 2의 new Color(alpha 동적)와 new Rect는 struct stack 할당으로 거짓양성. **잔여 작업**: CashShopUI 보석 충전(Tab 0)/아이템 상점(Tab 1) 영역도 동일 회귀 가능성, 별도 라운드 권고. + +### ModalUIRegistry (clean, 2026-05-21) + +진짜 회귀 없음. IModalUI 구현체 7개(BattleTeamUI/CaptureChoiceUI/CharacterOutfitUI/CashShopUI/CharacterViewerUI/CollectionUI/DexScreenUI) 모두 OnDisable에서 Unregister 호출 일관. HandleEscape는 PlayerMovement:127 단일 진입점. Register LRU 패턴 정상(Remove + Add), IsAnyOpen/TopModal null/IsOpen 자동 정리, Unregister null 가드 있음. static state scene 잔존은 IsOpen 체크로 회복(P2 수용), race는 Unity 단일 스레드로 불가, TopModal 역순 반복은 stack 변경 안전. Covered 이동만. + +### CashShopUI 보석 충전/아이템 상점 영역 (P0:16, 2026-05-21) + +Tab 0/Tab 1 + OnGUI 본문 new GUIStyle 16개 + new Color 다수 → InitMainStyles + 16 캐시 필드 + 14 static readonly Color (Backdrop/Panel/CharArea/ResTitle/CoinGold/BonusGreen×2/GemLabel/BuyButton/GemGlow/Border/GradTop/GradBot/Highlight + ItemBuyBlue). resStyle/itemPriceStyle textColor 동적 갱신 패턴(BattleScreenUI 동일). Phase 2 new Color(alpha 동적)와 new Rect(좌표 동적, struct stack)는 거짓양성으로 수용. OnGUI 본문 잔존 new GUIStyle = 0. + +### UIHelper (P0:1, 2026-05-21) + +GetCachedTex 캐시 가득(256개) 시 texCache.Clear()만 하고 styleCache 미invalidate 회귀. styleCache의 GUIStyle.normal.background이 Destroy된 텍스처를 참조 → 다음 렌더링에서 MissingReferenceException 또는 invisible 버튼. GetButtonStyle(line 189)이 정확히 그 패턴(`style.normal.background = GetCachedTex(bgColor)`). GetCachedTex 캐시 클리어 분기에 styleCache.Clear() 동반 호출 추가. new Color/Rect는 struct stack 거짓양성, Color32 정밀도 손실은 시각적 무해, 동시 호출 race는 Unity 단일 스레드로 거짓양성. + +### AudioManager (P1:1, 2026-05-21) + +StopBGM이 crossfadeCoroutine만 중단하고 deferredBgmCoroutine 미중단 회귀. PlayBGM(X) → deferred yield 대기 → 즉시 StopBGM 호출 시 fade out 시작 그 후 deferred가 다음 프레임에 CrossfadeBGM(X) 시작 → StopBGM 의도 깨짐 + 페이드 충돌. StopBGM에 deferredBgmCoroutine 중단 추가. FindFirstObjectByType(GameClock) 1초 폴링은 캐시 후 안 호출(P2 수용), ApplyVolumes fade 중 덮어쓰기는 사용자 설정 변경 빈도 낮음(P2 수용), gameClock stale은 Unity == null overload로 자동 재캐시(거짓양성), 다중 PlayOneShot은 AudioSource 특성상 동시 재생(거짓양성). + +### ProceduralAudioGenerator (clean, 2026-05-21) + +1957줄 모놀리스이나 구조 단순. public 진입점 3개(GetBGM/GetSFX/GetAmbient) + 단일 Dictionary 캐시 + 나머지는 PCM 생성 알고리즘(audit 범위 외, 캐시 hit 시 호출 안 됨). 모든 진입점이 TryGetValue → switch → cache 저장 패턴 일관, default LogWarning + null 반환, 호출자(AudioManager) null 처리 OK. Unknown key LogWarning spam은 정상 흐름에서 발생 안 함(P2), 캐시 무제한 증가는 총 ~64개로 안정 상태 도달 후 변동 없음(P2), Unity 단일 스레드로 동시 호출 race 거짓양성. Covered 이동만. + +### InsectEntity 외부 로직 (P0:1, P1:3, 2026-05-21) + +BuildModel은 visual-dev 영역으로 제외. (a) P0: Update의 Camera.main 매 프레임 호출 → static cachedMainCam lazy 캐싱. 최대 20마리×매 프레임 FindGameObjectWithTag 핫패스 회피. (b) P1: Despawn 다중 호출 가드(despawnedThisCycle 플래그) — Battle/Capture 동시 Despawn 시 풀 중복 반환 + onDespawn 두 번 발화 차단. (c) P1: Initialize/BuildForBattle에 cachedNameLabel/cachedShinySparkle/despawnedThisCycle 리셋 추가 — 풀 재사용 시 stale Transform 참조 회피. (d) P1: cachedMainCam과 일괄 처리로 NameLabel 미존재 인스턴스 매 프레임 Find 절감. new Vector3/Color는 struct stack 거짓양성, BuildModel/AnimateWings/AnimateShinySparkle 내부 알고리즘은 visual-dev 영역. + +### CameraFollower (P1:2, 2026-05-21) + +(a) SetTarget에 baselineValid=false 리셋 추가 — SubArea 좌표 전환 시 옛 baselinePos 기준 Lerp로 첫 프레임 카메라 점프 회귀. (b) EnterBattleMode의 battleTransition=0 무조건 리셋 제거 — fade-out 진행 중 재진입 시 transition 보존으로 시각적 끊김 차단(배틀→이탈→배틀 빠른 전환). new Vector3 다수는 struct stack 거짓양성, Shake 다중 호출 timer 리셋은 의도된 동작. + +### InsectDatabase (P1:1, 2026-05-21) + +GetWeightedRandom/GetWeightedRandomWithRareBoost 4곳 foreach에 `if (data == null) continue;` 가드 추가. GetCandidates는 null 필터링하나 외부에서 직접 candidates 전달(테스트/디버그 코드) 시 data.spawnWeight NRE 위험. insects 중복 insectId/null/빈 ID 검증은 ScriptableObject Editor 시점 보장(P2), Weight 두 번 계산은 결과에 영향 없음(P2), 부동소수점 누적 오차는 일반 범위에서 무해 + fallback 있음, new List 매 호출은 InsectSpawner 8초 burst 없음. + +### OutfitSetData (clean, 2026-05-21) + +진짜 P0/P1 회귀 없음. GetAllSets()는 OutfitBonusProvider.AutoWire에서 1회만 호출되어 allSets 필드에 캐시(매 프레임 호출 거짓양성). 12개 세트 정적 데이터 정합성 시각 review 결과 정상: requiredItemIds 중복/null 없음, partialThreshold ≤ requiredItemIds.Length 모두 만족(set_wizard threshold=2 items=4), partial/full Bonus 모두 정의, setColor 시각용 OK. GetAllSets static readonly 필드화는 변경 risky(P2 보류). Editor 시점 검증 도구는 별도 디자인 작업. Covered 이동만. + +### CatcherKidNpc (P1:1, 2026-07-17) + +**P1 — Rare+ 곤충이 12m 안에 있으면 아이가 영구 고착.** `TryScanForInsect`가 잡기 후보와 +구경 후보를 **한 루프에서 최근접 하나**로 뽑았다. Rare+는 `ShouldWatchOnly` 통과 시 +`CanKidTarget` 검사를 우회해 무조건 후보가 되므로, 최근접이 Rare면 `best`로 확정된다 → +Watch(2~4s) → `EnterIdle` → Idle이 매 0.25s 스캔을 무조건 먼저 호출 → **같은 Rare가 여전히 +최근접** → Watch. Wander로 빠져나가지도 포획하지도 못한다. 같은 원인으로 Rare 뒤의 잡을 수 +있는 Common도 영영 가려진다(`bestSq`가 줄어 뒤쪽 후보가 탈락). 탈출은 그 곤충이 플레이어 +60m 밖으로 나가 `DespawnFarInsects`될 때뿐 — **플레이어가 근처면 무기한**이다. +`KidSpotRadius=12f`, 아이 앵커 `wanderRadius=25f`로 고정 배치이고 Rare/Epic/Legendary는 전 +리전에서 스폰되므로 도달성이 높다. + +수정: 후보 루프를 `bestCatch`/`bestWatch`로 분리하고 **잡을 수 있는 쪽을 우선**한다(Rare가 +시야를 막지 않음). 여기에 `lastWatchedInsect`로 직전 구경 대상을 재스캔에서 제외해 +Watch→Idle→Watch 사이클을 끊었다. 해제는 **Wander 완주 시점**에 한다 — Wander 진입 시점에 +풀면 그 케이스의 스캔이 곧바로 같은 곤충을 다시 잡아 제자리 Watch로 되돌아간다(고착 재발). + +상태 전이를 모사해 검증: before는 `Watch→Idle→Watch→Idle…`로 Wander에 도달하지 못하고 포획 +0회, after는 `Watch→Idle→Wander→완주→Idle→Watch…`로 배회를 거치며, Common이 함께 있으면 +40틱에 20회 포획(before는 0회). + +**공정성 축은 clean(실측)**: 가로채기 방어가 3중이다 — `CaptureChoiceUI:48`/ +`CaptureMinigameController:95`가 진입 즉시 `SetEngaged(true)` → `CanBeEngaged=false`가 +스캔·`TickApproach`·`ResolveCatch` 세 지점에서 전부 탈락시키고, `PlayerClaimRadius=8f`도 같은 +3지점에서 검사된다. **플레이어가 미니게임 중인 곤충은 아이가 가로챌 수 없다.** 유령 곤충도 +없음(`ResolveCatch` → `Despawn` → `onDespawn` → `DespawnEntity`가 `activeInsects.Remove` + +풀 반환을 모두 덮음). 쿨다운은 `[Range(10f,120f)]`가 하한을 강제해 0초 주입 불가. + +**미처리 P2**: `watchInsect`에 `ResolveCatch`와 달리 풀 재사용 ABA 가드가 없어 재초기화된 +인스턴스를 최대 4초 주시(먼 좌표로 몸이 홱 돌아감), `SampleGround`의 `MaxGroundStep` 비교 +기준이 "마지막 채택 groundY"라 한 번 거부되면 복구 경로가 없음(mountain 바위는 콜라이더 +유지), `SampleGround`가 `InsectEntity` 콜라이더를 배제하지 않아 곤충 위를 지면으로 오인 가능, +Wander 타임아웃 8s × 2.5 = 20m인데 앵커 반경은 25f(실효 ~20m), `PlaySwing`은 거리 무관인데 +`swingTimer` 소진은 40m 게이트 안이라 지연 스윙 재생, `OnDisable`이 `watchInsect` 미정리. + +### VillagerNpc + NpcWalkAnimator (clean, 2026-07-17) + +**P0/P1 0건.** `Talking` 고착 가설을 세 경로 모두 반증했다: `BeginTalk` 진입점은 +`NpcDialogueUI` 2곳뿐이고, (a) 대화 중 `SetActive(false)`돼도 `NpcDialogueUI`가 참조를 유지해 +`CloseModal`의 `EndTalk()`가 정상 호출된다(**비활성 GameObject에서도 C# 메서드 호출은 된다** — +Unity 메시지가 아니다), (b) 씬 전환은 `OnDisable` → `CloseModal`, (c) 대화 중엔 +`SetFrozen(true)`라 플레이어 이탈이 불가능하다. 재활성 시 상태 리셋이 없지만 자가 치유된다 +(`Wander`로 꺼졌다 켜지면 `stateEndTime`이 과거라 첫 틱에 Idle 복귀, `Talking`으로 꺼지는 건 +위 경로상 불가). + +좌표도 clean — `SampleGround`의 `MaxGroundStep` 클램프가 지붕 오인을 차단하고(레이 시점 +3m라 +처마 밑면이 first hit이어도 |ΔY| > 0.75로 거부), `PickWanderTarget`이 항상 앵커 반경 내부를 +뽑아 이탈이 불가능하며, NPC 캡슐은 `isTrigger=true`라 `QueryTriggerInteraction.Ignore`로 +자동 제외된다. 애니메이션 정지 리셋 정상, 이벤트/싱글턴 없음, Update 힙 할당 없음. + +**미처리 P2**: `IsSwinging` 소비처 0건(데드 프로퍼티. 단 `swingTimer` 감소가 40m 게이트 안이라 +훗날 소비하면 40m 밖에서 true로 래치됨), `Tick(time, ...)`의 `time` 미사용, +`netHandleBaseRot`가 `default(Quaternion)` = (0,0,0,0)으로 identity가 아님(현재 null 가드 +안에서만 읽어 무해), `VillagerNpc:56` 주석("첫 틱에 Idle 타이머 시작") vs 동작(즉시 Wander) +불일치, `PickWanderTarget`의 Y 대입이 `TickMovement`에서 지워지는 데드 대입, +`TickMovement`의 `rng` 가드가 전이적. + +### CaptureItemPickup + CaptureItemSpawner + CaptureProximityTrigger (clean — dead code, 2026-07-17) + +**P0/P1 0건 — 점검 전제가 성립하지 않는다. 세 파일 모두 실행되지 않는다.** + +`CaptureItemSpawner`/`CaptureItemPickup`은 완전 사문이다. 유일한 부트스트랩 +(`PlaySceneBootstrap:335-338`)이 주석 처리돼 있고(`// 필드 아이템 스폰 비활성화 — 아이템은 +샵/보상에서만 획득`), `CaptureItemPickup`을 만드는 곳은 스포너의 `AddComponent` 하나뿐이라 +스포너가 안 뜨면 픽업도 없다. 씬/프리팹 GUID 역참조도 0건. **필드에 아이템을 뿌리고 줍는 +흐름은 현재 존재하지 않는다.** + +`CaptureProximityTrigger.TryStartCapture()`는 호출자가 0건이다. 실제 포획은 +`CaptureInputController:198`의 동명 메서드가 담당하고 `FindNearestInsect`가 +`RefreshNearby`/`GetNearest`를 재구현해뒀다. 이 컴포넌트가 살아있는 용도는 +`transform.position`(원점 앵커)과 `col.radius`(반경 8) 제공 **둘뿐**이라 삭제할 수는 없고, +`TryStartCapture`/`RefreshNearby`/`GetNearest`/`inRange`/`autoStart` 등은 죽은 멤버다. + +**이 라운드가 채점기 구멍을 드러냈다**: `code_mentions`가 raw text를 읽어 **주석 안의 언급도 +"살아있음"으로 셌다**. `strip_cs`를 적용해 수정(같은 커밋). 다만 참조를 1단계만 보므로 +"dead인 Spawner가 부르는 Pickup"까지는 못 따라간다 — 알려진 한계로 주석에 명시. + +**미처리 P2(재활성화 시 터질 함정)**: `CaptureProximityTrigger:34-38`이 `StartMinigame` 전 +`CanBeEngaged`를 검사하지 않아(살아있는 경로는 검사함) 다시 배선하면 **전투 중 곤충에 중첩 +발동** — 이 프로젝트가 P0:4건으로 고쳤던 바로 그 클래스다. `CaptureItemPickup:72`는 픽업마다 +`new Mesh()`를 `sharedMesh`에 넣는데 Unity는 sharedMesh를 GameObject Destroy 시 정리하지 +않는다(Material과 다름) → 스폰당 메시 1개 영구 누수. 그 외 리전 전환 정리 훅 부재, +`spawnWeight` null 가드 부재, `Debug.Log` 인자 안 `FindObjectsByType` 전체 씬 스캔. + +### NpcManager (P1:1 + 튜닝 프로필 동반 수정, 2026-07-17) + +**P1 — 스폰 캡이 앵커 수보다 작아 뒤쪽 리전 NPC가 통째로 누락.** `SyncSpawns`는 앵커를 +앞에서부터 잘라 쓰는데, `VillageBuilder`는 본마을(주민 8)을 먼저 넣고 전초기지를 리전 +순서(meadow/pond/forest/swamp/mountain/garden/ruins)로 뒤에 붙인다. 캡이 10이면 인덱스 +10~13이 버려져 **swamp·mountain·garden·ruins 전초기지 4곳에 오두막·모닥불·통나무 의자는 +지어지는데 주민이 영구히 0명**이 된다. 잡기 아이도 캡 6 대비 앵커 7이라 mountain 몫이 누락. +실측: 주민 앵커 14(본마을 8 + 전초기지 6), 아이 앵커 7(meadow 2 + KidSpots 5). +도달 경로는 `PlaySceneBootstrap:518` → `SpawnFromAnchors`로 **매 세션 1회 예외 없이** 실행. +수정: `villagerCount` 10 → 14, `catcherKidCount` 6 → 7. + +**동반 수정 — `GameplayTuningProfile`의 기본값도 10/6이었다.** 지금은 `Resources`에 프로필 +에셋이 없어 `GameplayTuningApplier`의 `Resources.Load`가 null → `Apply()`가 조기 return하므로 +`ApplyTuning`이 죽은 경로다(그래서 SerializeField 기본값이 그대로 살아 있었다). 하지만 +`NpcManager:131-132`가 프로필 값으로 덮어쓰는 구조라, **누군가 프로필 에셋을 만드는 순간 +같은 버그가 재발**한다. 프로필 기본값도 14/7로 올려 근원을 막았다. + +**검증 후 clean**: 좌표 이중 합산 없음(`CreateNpcObject`가 `SetParent(transform, false)` 후 +월드 position 대입, `WorldTerrainBuilder`가 전 리전 평탄화라 c.y=0), 예약 누수 없음 +(`TryReserveInsect` 진입점 1개에 대해 `EnterGiveUp`/`ResolveCatch`/`OnDisable`이 전 이탈 +경로를 덮고 5초 sweep이 백스톱, 풀 재사용 ABA도 거리·레어도 재검증으로 차단), 중복 스폰 +없음(호출부 1곳, RegionChanged 구독 자체가 없음 — NPC는 부팅 시 전 리전분을 한 번에 만들고 +영구 유지하는 설계), Update 힙 할당 없음(`DeadReservation`은 캡처 없는 static 델리게이트). + +**미처리 P2**: `ApplyTuning` 자체가 죽은 경로(프로필 에셋 부재 — spawner/captureController도 +동일 영향), `TickAI`는 거리 무관인데 `TickMovement`가 40m 컷이라 40~60m 대역의 아이가 +예약만 점유하다 5초 타임아웃 반복, `total == 0` 조기 return이 예약 sweep을 건너뜀, +`hasPlayer &&` 단축평가로 playerTransform이 null이면 40m 컬링이 통째로 무효화, +라운드로빈이 비활성 슬롯에 틱 예산 낭비. + +### VillageBuilder (P1:2, P2:1, 2026-07-17) + +**가설 반증 먼저** — `RegionTerrainBuilder`에서 21곳을 고쳤던 **c.y 이중 합산은 이 파일에 +없다**. `Polar()`가 `center + new Vector3(cos*d, 0f, sin*d)`로 Y를 0만 더하고, `FacingRoot`는 +`SetParent(parent, true)` 후 월드 position을 대입해 부모 재합산이 없다. 게다가 +`WorldTerrainBuilder.GetRegionElevation`이 이제 전 리전을 0으로 평탄화해 c.y=0이다. + +대신 **"지면을 Y=0으로 가정한" 다른 계열**이 나왔다. + +**P1 — 광장이 리전 평면과 같은 평면이라 사라짐.** Plaza는 Cylinder(메시 높이 2)라 +`scale.y=0.05` → 반높이 0.05, `localPos.y=0.03` ⇒ 상면 **0.08**. 그런데 +`PlaySceneBootstrap:1265`가 `Region_{id}` Plane을 **정확히 Y=0.08**에 둔다. 둘 다 Standard +불투명이라 지름 16m 원반 전체가 z-fighting하거나 뒤에 그려져 통째로 소실된다. 랜덤 없이 +상수 산술로 두 값이 일치하고, 광장은 상점·훈련소·가챠 상호작용이 모인 허브라 마을 체류 +내내 100% 가시. 수정: `y` 0.03 → 0.10(상면 0.15 — `RegionTerrainBuilder`의 SteppingStone과 +동일한 여유). + +**P1 — 상점만 지붕이 없어 간판이 허공에 뜸.** Shop Wall은 `y=1.9, scale.y=3.8` ⇒ 상단 3.80, +간판 plate 하단은 4.10 → **0.30m 공백**. 9개 건물 중 상점만 `Roof` 프리미티브가 없어 벽 큐브 +윗면이 그대로 노출돼 있었다(House는 RoofR/L/Ridge, 훈련소는 Roof, 오두막은 Roof1~3). +훈련소는 Roof 상단 3.50 = 간판 하단 3.50으로 **정확히 밀착**하는 게 작성자 관례다. +수정: `Roof`(y=3.95, height 0.3) 추가 → 하단 3.80 = 벽 상단, 상단 4.10 = 간판 하단으로 양쪽 밀착. + +**P2 동반 수정** — TentCamp `GroundMat`도 같은 원인(상면 0.055 < 0.08)으로 리전 평면에 +매몰돼 있었다. `y` 0.03 → 0.12. + +**검증 후 clean**: 콜라이더(`keepCollider:true` 8곳 전부 Cube→BoxCollider라 MeshCollider 잔존 +0, 가챠 육각벽은 광장 방향 1면을 생략해 입구 개방, 상호작용 3곳 모두 벽 바깥이라 도달 가능), +중복 생성 없음(`Build()`는 Bootstrap `Awake` 1회 경로), Update/OnGUI 자체가 없음, +이벤트/싱글턴 없음, NPC 앵커 순서가 `NpcManager` 계약과 일치. + +**미처리 P2**: 가챠 간판이 Roof1을 0.2m 관통, `ChooseOutpostPosition`의 회피 탐색이 주석과 +달리 리전 게이트웨이 각도를 재검사하지 않음, `Build()` 멱등 가드 없음(현재 도달 불가), +`TargetDummy` 2개가 동일 부모에 같은 이름. + +### NpcDialogueUI (P1:1, 2026-07-17) + +**7연속으로 나온 모달 소프트락 패턴이 여기엔 없다.** 각 가설을 반증했다: `GetLines`가 항상 +정확히 3개를 반환해(폴백 배열이 전부 non-empty + `Mod` 가드) `OnGUI`의 빈 배열 분기가 도달 +불가이고, `Show()`가 null npc를 isOpen 설정 **전에** early-return하므로 `IsOpen ⟺ 그릴 조건`이 +성립한다. 대화 상대 소멸도 처리돼 있다 — `NpcManager`가 실제로 `SetActive(false)`로 주민을 +끄지만 `Update:78`이 `activeInHierarchy`를 잡아 `CloseModal`로 정리한다. 대상은 인덱스가 아닌 +**직접 참조**라 오배송 계열도 해당 없음. `GUI.enabled`는 이 파일에서 단 한 번도 쓰지 않아 +"닫기가 죽는" 패턴도 없다. `IsAnyOpen()` 가드 부재는 자신이 모달이므로 **정상**이다. + +**P1 — `OnDisable`이 `Unregister`만 하고 `isOpen`을 되돌리지 않음(latent).** 다시 켜면 +"열린 것으로 아는데 레지스트리엔 없는" 상태가 되어 (a) `HandleEscape`가 이 모달을 무시 → +ESC가 frozen만 풀고 `Update:86-87`이 즉시 재프리즈 → **ESC 영구 무력화**, (b) `IsAnyOpen()`이 +false라 `WorldInteractionController`의 재진입 가드가 뚫려 이전 `currentNpc`를 `EndTalk` 없이 +덮어씀 → `VillagerNpc.CanTalk`가 영구 false → **그 주민과 다시는 대화 불가**. +현재 이 GO를 끄는 코드가 없어 latent이지만, 수정 비용이 1줄이고 `CloseModal`이 이미 완전한 +정리를 한다. 옛 주석이 인용하던 `CaptureChoiceUI` 관례는 이 프로젝트가 **이미 P1으로 두 번 +폐기한 방식**이다(CharacterOutfitUI/RegionMapUI 라운드). + +**미처리 P2**: `$"{lineIndex + 1}/{lines.Length}"`가 OnGUI 매 패스 힙 할당(대화 중에만), +`GUI.depth` 미설정(최하위 모달이라 의도대로지만 관례상 명시 권장), 이름 라벨 폭이 진행표시 +영역 침범, 재프리즈 루프가 `AutoUnfreeze(20s)` 최후 안전망을 무력화(향후 isOpen-stale 버그를 +"20초 후 회복"에서 "영구"로 승격시키는 구조적 취약점). + +### QuickAccessBarUI (P0:1, 2026-07-17) + +**P0 — 핫키 경로에 모달 가드가 없어 채팅·친구코드 입력이 망가짐.** 이 파일은 그동안 +*모달 가드 관례의 근거*로 인용돼왔는데(`:113`의 `IsAnyOpen()`), 정작 그 가드는 **모바일 렌더 +경로에만** 있었다. 핫키 경로(Update, OnGUI KeyDown)는 `IsInputBlocked()`만 거치는데 그건 +battle/raid/frozen 셋만 본다. 코드 주석의 전제 — *"모든 모달이 SetFrozen을 거니 frozen 하나로 +커버된다"* — 가 깨져 있었다: `SocialPvpUI`(:53)와 `WorldFieldMultiplayerUI`(:422)는 모달로 +등록하면서 `SetFrozen`을 부르지 않는다(전역 grep 확인). + +증상 두 가지: (1) 친구코드(12자)·채팅(80자) 입력에 N/T/G/C/Q/M/V/P를 타이핑하면 **글자마다 +화면이 토글**된다(`Input.GetKeyDown`은 IMGUI 키보드 포커스와 무관). (2) 반대로 OnGUI의 +`e.Use()`가 같은 키를 전역 소비해 **두 텍스트필드가 그 8글자를 입력조차 받지 못한다**. +프로젝트의 다른 입력 게이트(WorldInteractionController:115, VirtualJoystickUI:37, +CaptureInputController:62/92, SubAreaWorldBuilder:84)는 예외 없이 `IsAnyOpen() || IsFrozen` +쌍을 쓰는데 이 파일만 절반이 빠져 있었다. + +수정: `TryHotkey(index) → bool` 신설. `IsAnyOpen() && !IsActive(index)`면 무시하고 **false를 +반환해 `e.Use()`를 막는다**(그래야 텍스트필드가 글자를 받는다). 열린 화면 자신의 키는 +통과시켜 "N으로 도감 닫기" 토글을 유지한다. +**렌더 경로에는 이 가드를 넣지 않았다** — `IsInputBlocked()`에 `IsAnyOpen()`을 통째로 넣으면 +OnGUI가 조기 return해 데스크톱 바가 사라지고 active 하이라이트 설계가 죽는다(회귀). + +**점검 결과 clean**: 핫키 문자열 10개가 KeyCode와 1:1 일치(KeyGuideHUD류 불일치 없음), +`buttons[0..9]` ↔ `OnClick` switch ↔ `IsActive` switch 3자 일치, RaidBattleUI의 Q 중복은 +`IsRaidActive`로 차단됨, Update/OnGUI 이중 처리는 `lastToggleFrame`이 막음. + +**미처리 P2**: `mobileMenuOpen`이 true가 되는 곳이 없어 IModalUI 구현 전체가 사문(2탭 메뉴가 +제거된 잔재), `DrawMobileQuickBar`의 active 분기 도달 불가, `:148`의 `$"{label}\n..."`는 +버튼 10개 × 프레임당 다중 패스로 **자동 채점 32건 중 유일한 실제 힙 할당**(ButtonDef 캐시 권장). + +### SocialPvpUI (P0:1, P1:2, 2026-07-17) + +**P0 — `GUI.enabled`가 "매칭 취소"까지 덮어 큐에서 못 빠져나옴.** `GUI.enabled = !IsBusy && +teamReady`가 취소 버튼에도 걸려, 큐 대기 중 곤충을 방출하거나 편성을 풀면 +(`BuildTeamSnapshot` throw → `teamReady=false`) 취소가 죽는다. `CancelQueue()`는 팀을 쓰지도 +않는다. **`state.queued`는 서버 권위값이라 재접속해도 유지되므로 영구 감금**이다. +AccountLinkUI·AccountSettingsUI에 이어 **세 번째로 확인된 동일 패턴**. 수정: 취소 분기는 +`GUI.enabled = !IsBusy`만 적용. + +**P1 — 매치 중 2.5초마다 탭이 배틀로 튕김.** `OnStateChanged`가 level-trigger라 +`status=="active"`이면 **매번** `page = Page.Battle`로 되돌렸는데, 매치 중에는 폴링이 +2.5초 간격으로 `StateChanged`를 발화한다 → 친구/랭크 탭에 머무를 수 없어 친구 요청 수락이나 +랭킹 확인이 불가능했다. 수정: `lastMatchId`/`lastMatchStatus`로 edge-trigger화해 "새로 active가 +된 순간"에만 1회 전환. + +**P1 — `HasValidTeam`을 OnGUI 이벤트마다 호출.** `BuildTeamSnapshot`이 매번 `List`, +`List`, 곤충당 `List`+`ToArray()` 등을 할당하고(**전부 +class라 진짜 힙 할당** — 자동 채점이 잡은 struct와 다르다), 팀이 미완성이면 **예외까지 매 +이벤트 반복해 던진다**(스택 트레이스 캡처). 랭크 탭은 매칭 대기 중 계속 켜두는 화면이고 +OnGUI는 프레임당 2회 이상 호출된다. 수정: `RefreshTeamState()`로 0.5초 스로틀 캐싱. + +**점검 결과 clean**: 모달 정합(`IsOpen` ↔ 그릴 조건 일치, Register/Unregister 짝), +**대상 고정도 정상** — `ChallengeFriend(friend.uid)`, `RespondChallenge(challenge.challengeId)` 등 +전부 foreach + id 고정이라 WorldFieldMultiplayerUI의 인덱스 오배송 계열이 없다. + +**미처리 P2**: `SocialPvpManager:404`의 `&& match.match != null` 때문에 **`CurrentMatch`가 절대 +null로 돌아가지 않아** 첫 매치 종료 후 "배틀 ●" 표시와 종료 화면이 영구 잔류(닫기 버튼도 없음), +`AuthManager.Instance == null`이면 uid=""로 **내 팀이 상대 팀 자리에** 그려짐, 무승부/공백 +winnerUid 미처리로 오탐 "승리", 액션 버튼이 폴링 중(IsBusy) 클릭을 조용히 무시. + +### SceneAutoWire (clean — dead code 확인, 2026-07-17) + +**P0/P1 0건.** 채점이 이 파일을 상위로 올린 "미캐싱 조회 40건"은 **이중으로 거짓양성**이었다. +① 40건 전부 `Awake()` 하나에만 있다(파일 106줄이 통째로 `Awake()`이고 Update/OnGUI/코루틴이 +없다) → 인스턴스당 최대 1회. ② 더 결정적으로 **이 컴포넌트는 어디에도 붙어 있지 않다** — +GUID 참조 0건, 유일한 씬의 MonoBehaviour는 `PlaySceneBootstrap` 하나뿐, `AddComponent` 0건, +런타임 진입점(`PlaySceneAutoInit`)도 Bootstrap만 생성한다. **`Awake()`는 절대 실행되지 않으므로 +런타임 비용은 정확히 0**이다. + +즉 **파일 전체가 dead code**다. 모든 배선(`worldState.AutoWire`, `spawner.AutoWire`, +`capture.AutoWire` 등)이 `PlaySceneBootstrap:165-235`에 동일하게 재구현돼 있고, Bootstrap이 +이 파일을 완전히 흡수·대체했다. + +**미처리 P2(보고만)**: ① 파일 삭제 권고 — 참조가 0건이라 무위험이고, 방치하면 **누군가 이 +컴포넌트를 GameObject에 붙이는 순간 Bootstrap과 이중 배선되는 잠복 지뢰**다. ② 삭제 시 +`.claude/scripts/verify_coverage.py:12`, `.claude/agents/architect.md:20`, +`.codex/agents/architect.toml:10`이 이 파일을 **활성 아키텍처 파일로 등재 중**이라 3곳 동반 정리 +필요. ③ `:54`가 엔트리 프리팹 자리에 `null` 하드코딩(방치의 방증). + +**채점기 개선 후보**: "미캐싱 조회"가 이 파일을 1위로 올린 원인은 (a) `Awake` 스코프 미판별, +(b) 씬/GUID 참조 여부 미확인이다. 두 필터가 없으면 dead code와 부트스트랩 파일이 계속 상위를 +점유한다. "프레임 할당"과 동일한 실패 모드(실행 컨텍스트를 보지 않고 구문만 셈). + +### AccountSettingsUI (P0:1, P1:2, 2026-07-17) + +**P0 — 게스트 로그아웃이 계정을 영구 소실시킴(확인 0단계).** 게스트(익명)는 refresh token이 +유일한 재진입 수단인데 `Logout()` → `ClearAuth()`가 `TokenKey`/`UidKey`를 `PlayerPrefs`에서 +지운다. 이메일이 없어 재로그인이 불가능하고 `LoginAsGuest()`는 매번 새 uid를 발급하므로, +곤충·레벨·재화가 인증 불가능한 uid에 묶여 **영구 고아화**된다(`SaveScope`가 `users//`로 +스코핑하므로 파일은 디스크에 남지만 접근 경로가 사라진다 — "삭제"도 "복구 가능"도 아닌 최악). +결정적 정황 둘: 같은 파일 163줄이 *"게스트 (이메일 미연동) / 앱 삭제 시 데이터 복구 불가"*라고 +직접 경고해놓고 124줄에서 동일한 피해를 주는 원클릭 버튼을 제공했고, **안전장치가 역전**돼 +있었다 — 파괴가 의도인 계정 삭제는 2단계 확인 + 명시 경고를 받는데 게스트 로그아웃은 무방비. +수정: `confirmLogout` 단계 신설. `IsGuestAccount()`일 때만 경고 화면("다시 들어올 수 없습니다 / +먼저 정식 계정 전환 권장")을 거치고, 정식 계정은 이메일로 재로그인 가능하므로 즉시 로그아웃. + +**P1 — `GUI.enabled = !processing`이 "취소"까지 덮음 + `processing` 리셋 경로 부재.** +AccountLinkUI와 문법적으로 동일한 구조의 버그. `confirmDelete = false`가 실행 불가능한 죽은 +코드였고, `Instance`가 null이거나 삭제 in-flight 중 GameObject가 토글돼 `AccountDeleted`를 +놓치면 "삭제 중..."과 "취소"가 모두 비활성인 채로 굳어 **앱 재시작 전까지 계정 삭제가 +불가능**해졌다(스토어 정책 위반 소지). 수정: `prevEnabled` 보존/복원으로 취소를 블록 밖으로 빼고, +`CloseModal()`/`DrawOpenButton()`에 `processing = false` 추가. + +**P1 — `IsAnyOpen()` 가드 + `GUI.depth` 둘 다 없음.** 다른 전체화면 모달 위에 116×46 "계정" +버튼이 계속 그려지고 클릭을 가로챘다. 수정: `OnGUI`에 `if (!open && ModalUIRegistry.IsAnyOpen())` +가드(자기 자신 제외), `DrawPanel`에 `GUI.depth = -20`. + +**채점 근거 반증**: 싱글턴 11건은 **전건 정상**(각 호출부에 가드 존재) — 채점 1위 근거가 무효였다. +프레임 할당 37건도 전부 `new Rect`/`new Color` struct. + +### SaveConflictUI (P0:1, P1 보류, 2026-07-17) + +**P0 — 로컬 카드의 "마지막 저장"이 항상 더 오래된 것처럼 표시돼 최신 로컬을 버리게 유도.** +요약의 `lastSaveUnix`는 `PlayerPrefs["InsectGame.LastSaveTs"]` = **마지막 클라우드 푸시 성공** +시각이지 로컬 저장 시각이 아니다. 그런데 충돌 분기 자체가 `클라우드 ts > 로컬 ts`일 때만 +발화하므로 **모든 충돌 프롬프트에서 100% 로컬이 더 오래된 것으로 렌더**된다. 오프라인으로만 +진행해 푸시 이력이 없는 기기는 `localTs == 0` → **"기록 없음"**으로 표시되는데, 정작 그 로컬에는 +실제 진행이 있다(`HasMeaningfulLocalProgress`가 그걸 보장해야 프롬프트가 뜬다). 여기에 "선택하지 +않은 쪽은 덮어쓰여집니다" 문구가 겹쳐 플레이어가 클라우드를 고르면 `ResolveConflict(true)` → +`ApplyResolved(forceReplace:true)` → `DeleteLocalFile`이 곤충/팀/도감/아이템을 **복구 불가로 삭제**한다. +수정: 실제 세이브 파일 7개의 `File.GetLastWriteTimeUtc` 중 최신값을 쓰는 `ComputeLocalSaveUnix()` +신설. `OnConflict`에서 1회만 계산해 캐시한다(OnGUI에서 파일 I/O를 하면 매 프레임 디스크를 읽음). +`DrawCard`에 `isLocal` 파라미터를 추가해 로컬 카드만 이 값을 쓴다. + +**P1 보류 — 확인 단계·백업·취소 경로 부재.** 동일 크기 64px 버튼 2개가 나란히 있고 한 번의 탭이 +즉시 확정되며, `CloseModal()`은 의도된 no-op이라 탈출구가 없다. 삭제가 복구 불가임도 확인됐다 — +`AtomicFileWriter`의 `File.Replace(temp, path, null)`은 3번째 인자가 null이라 백업본을 남기지 않고 +`DeleteLocalFile`은 하드 삭제다. **P0 수정으로 오도(誤導)는 제거됐으므로**, 2단계 확인 추가는 +UX 디자인 판단이라 사용자 결정에 맡긴다. + +**확인 결과 정상**: `GUI.depth = -50`으로 프로젝트 최전면이라 `IsAnyOpen()` 가드 부재가 맞다. +`IsOpen`(visible) ↔ 그릴 조건(`visible && info != null`)이 일치해 1라운드의 빈 화면 소프트락은 +재현되지 않는다. 중복 클릭은 `ResolveConflict`가 `pendingCloudData`를 먼저 비우고 null이면 +return해 멱등. 선택 전 자동저장 끼어들기도 `SaveToCloud()`의 조기 반환 가드로 차단됨. + +### KeyGuideHUD (P1:2, 2026-07-17) + +**P1 — 키 안내가 실제 바인딩과 어긋남.** 컬렉션을 `TAB`으로 안내했으나 실제 키는 **C**다 +(`QuickAccessBarUI:58,74,97`). `KeyCode.Tab` 전수 검색 결과 컬렉션 바인딩은 존재하지 않고, +Tab이 실제로 하는 일은 미니게임 confirm(`CaptureMinigameController:250`)과 로비 오버레이 +토글(`WorldLobbyUI:121`)이며 IMGUI에선 포커스 이동 키다 — 안내대로 누르면 엉뚱한 게 동작했다. +나머지 8행(WASD/E/ESC/1~4/T/G/N/M)은 코드와 일치 확인. 과거 F11→F9 변경과 같은 계열. + +**P1 — 모달 가드 누락 + `GUI.depth` 미설정.** 외부에서 이 컴포넌트를 끄는 코드가 없어 OnGUI가 +무조건 매 프레임 돌고, depth 0이라 depth를 안 거는 전체화면 모달들(CollectionUI/TrainingUI/ +RegionMapUI 등)과 렌더 순서가 미정의였다 — 불투명 패널 3개가 모달 위로 튀어나올 수 있다. +수정: `UIScale.Begin()` **앞에** `if (ModalUIRegistry.IsAnyOpen()) return;`(Begin/End 균형 유지, +MinimapUI:52 관례). 단 이 파일은 `GUI.Button`이 없고 Label/DrawTexture만 써서 **클릭은 가로채지 +않으므로** 시각적 오버드로만 발생 → P0이 아닌 P1. + +**미처리 P2 3건**: `OnDisable`에서 `battleActive` 미리셋(재활성 시 "1~4 스킬" 행 잔존), +비활성 상태 `AutoWire` 시 중복 구독 가능(핸들러가 멱등이라 현재 무해), 안내가 QuickAccessBar +핫키 10개 중 5개만 표시(의도적 축약일 수 있음). + +### SubAreaEnvironment (P1:1, 2026-07-17) + +**P1 — `cameraBg` 파이프라인 전체가 무동작.** 11개 프리셋이 동굴 `(0.1, 0.08, 0.06)`, +수중 `(0.1, 0.2, 0.34)` 같은 어두운 배경을 정의하고 `ApplyLerp`가 매 전환 프레임 +`mainCamera.backgroundColor`에 보간해 넣지만, **Unity는 `clearFlags`가 `Skybox`면 +`backgroundColor`를 무시한다.** `PlaySceneBootstrap:565`가 카메라를 `Skybox`로 고정하고 +코드베이스 어디에도 되돌리는 곳이 없어(전체 `clearFlags` writer는 그 1곳과 별개 프리뷰 +카메라뿐), 서브지역 배경색이 **한 번도 렌더된 적이 없다**. + +그 결과 동굴·수중·신전에서 의도한 어둠 대신 밝은 하늘색이 그대로 보였다. `BuildCave`만 +천장이 있고(그마저 `NoCollider` 큐브 1개) `BuildUnderwater`/`BuildTemple`/`BuildDeepForest`/ +`BuildReeds` 등은 천장이 아예 없어 하늘이 확실히 노출된다. **Unity 내장 fog는 skybox에 +적용되지 않으므로** 작성자가 정밀 튜닝한 fog로도 가릴 수 없었다 — 파일 주석의 +"너무 어두워 안 보임" 개선 의도와 정면으로 어긋나 있었다. + +수정: `defaultClearFlags`를 `CaptureDefaults`에서 캡처하고, `OnSubAreaChanged`에서 +서브지역 진입 시 `SolidColor` / 메인 복귀 시 원래 플래그로 전환(`ambientMode`를 +Skybox↔Flat로 전환하는 기존 패턴과 동일한 이유·동일한 자리). `defaultCameraBg`에 +빠져 있던 필드 초기자도 형제 필드와 맞춰 추가. + +전환 자연스러움 확인: `PlaySceneBootstrap:566`이 `backgroundColor`를 하늘색 +`(0.5, 0.8, 1)`로 설정해두므로, 진입 첫 프레임의 solid 색이 skybox 하늘색과 사실상 +같아 깜빡임이 없고 이후 0.5초간 어두운색으로 페이드된다. 이탈은 즉시 Skybox 복원으로 +기존 동작과 동일. + +**자동 채점 46건은 전부 거짓양성**이었다 — `EnvironmentProfile`이 **struct**라 11개 프리셋의 +`new Color`/`new Quaternion.Euler`/`new EnvironmentProfile`이 모두 스택 할당이고, `Update()`는 +전환 0.5초 동안만 돌며 그 경로에 힙 할당이 0건이다. 미캐싱 조회 1건도 `initialized` 래치로 +Start에서 1회만 실행된다. 진입/이탈 정합도 clean — 구독 순서상 `RestoreLastSubArea`가 +`Start()` 전에 발화해도 `if (!initialized) CaptureDefaults()`가 서브지역 빌드 전 메인 필드 +상태를 정확히 캡처한다. 좌표/갇힘 로직은 이 파일에 없다(전부 `SubAreaWorldBuilder` 소유). + +**미처리 P2 4건**(전부 현재 도달 불가로 격하, 잠재 위험으로만 기록): `fogMode`가 +`CaptureDefaults`에 없어 씬 원래 값이 영구 덮어써짐(이탈 시 `fog=false` 강제라 무해), +`OnEnable` 재구독 부재(`HideMainWorld`가 `"SubArea_"` 언더스코어 prefix만 끄므로 이 오브젝트는 +비활성화되지 않음), `FindFirstObjectByType`에 `Directional` 필터 없음 +(`PlaySceneBootstrap:592`는 같은 호출에 가드가 있는데 여기만 없음 — Start 시점엔 월드 +Directional Light가 하나뿐이라 현재 정상 바인딩). + +### AccountLinkUI (P1:3, P2:2, 2026-07-17) + +**P1 — 처리 중 "닫기"가 죽어 해제 불가 모달.** `GUI.enabled = !isProcessing`이 "연동하기"와 +"닫기" **양쪽**을 덮어, `if (!isProcessing) SetOpen(false)`가 실행될 수 없는 죽은 코드였다 +(`GUI.enabled=false`면 `GUI.Button`은 항상 false). 여기에 `LinkEmailCoroutine`은 타임아웃이 +없었다 — Unity 기본값 0은 무제한이라 모바일 네트워크가 물리면 OS TCP 타임아웃까지 수 분간 +전체화면 딤 모달이 "처리 중..."에 갇히고 탈출구는 ESC뿐(모바일에선 사실상 없음). +수정: 닫기를 `GUI.enabled` 블록 밖으로 빼 항상 누르게 하고(처리 중 닫아도 응답은 +`OnLinkCompleted`가 받아 토스트로 표시), `prevEnabled` 보존/복원으로 바꿔 호출부 상태를 +덮지 않게 했다. **AuthManager의 UnityWebRequest 7곳 전부에 `timeout = 15` 추가** — +코드베이스에서 타임아웃을 걸던 곳은 `WorldChannelManager`(12초) 하나뿐이었고, 로그인·회원가입· +토큰갱신·연동이 모두 무한 대기였다. + +**P1 — 배지가 모달 위에 그려지고 클릭을 가로챔.** `OnGUI`에 모달 가드가 없어 게스트인 동안 +배지가 전체화면 모달 위에 매 프레임 그려졌다. 같은 프로젝트의 `MinimapUI:52`, +`QuickAccessBarUI:113`은 `if (ModalUIRegistry.IsAnyOpen()) return;`으로 자신을 숨기는데 여기만 +빠져 있었다. 또 `GUI.depth` 미설정(기본 0)이라 `DexScreenUI`(-10)/`SaveConflictUI`(-50)에 가려 +보이지 않으면서도 입력은 먹는 상태가 가능했다. 수정: 배지에 `IsAnyOpen()` 가드, +`DrawForm` 진입 시 `GUI.depth = -20`. + +**P1 — `isProcessing` 리셋 경로 부재.** false로 되돌리는 곳이 `OnLinkCompleted` 하나뿐이라, +요청 중 GameObject가 토글되면 `OnDisable`이 구독을 끊어 응답을 놓치고 `isProcessing=true`가 +남아 "연동하기"가 세션 내내 비활성이 된다. `OnEnable`에 초기화 추가(1줄). + +**P2 — 닉네임 무검증으로 이메일이 공개 표시명이 됨.** `ValidateCredentials`는 email/password만 +보고, 닉네임이 비면 `AuthManager`가 `DisplayName = email`로 채운다 → 월드/친구 목록에 이메일이 +그대로 노출. `Submit()`에 공백 검증 추가. + +**P2 — 버튼 눌림 상태가 반투명.** `bg * 1.15f` / `bg * 0.85f`가 알파까지 곱했다. RGB만 조절하고 +알파는 보존하도록 수정. + +**Explore 거짓양성(검증 후 제외)**: `MakeTex` 11개는 `stylesReady` 가드로 1회만 호출 — +`WorldLobbyUI MakeTex 누수(P0:7)` 라운드가 *"InitStyles의 4곳 MakeTex는 1회만 호출이라 유지"*로 +이미 판정한 것과 같은 패턴. `msgStyle.normal.textColor` 매 프레임 갱신은 프로젝트 표준 해법. +이벤트 짝 정합, Bootstrap 등록(:156), 싱글턴 8건 전부 가드 존재. 연동은 uid를 보존하므로 +`CloudSaveManager`의 `LocalOwnerKey`/`targetUid` 캡처와 충돌 없고, `LinkGuestWithEmail`의 실패 +경로 3개 전부 `LinkCompleted`를 발화해 데이터 유실 벡터 없음. + +### UIHelper 텍스처 캐시 오버플로 (P1:1, 2026-07-17) + +`AccountLinkUI` 라운드 중 횡단 이슈로 발견. `DrawRarityBorder`는 `pulse`로 RGB를, +`DrawRarityGlow`는 `breathe`로 alpha를 **연속 변조**한 뒤 그 색을 `GetCachedTex`에 넣었다. +프레임마다 새 `Color32` 키가 생겨 `MaxCacheSize`(256)를 실제로 넘긴다. 넘치면 캐시된 텍스처를 +전부 `Destroy`하는데, UIHelper가 무효화할 수 있는 건 자기 `styleCache`뿐이다 — 각 UI가 자기 +필드로 들고 있는 `GUIStyle.normal.background`는 그대로 파괴된 텍스처를 가리키고, 그 UI들은 +`stylesReady` 가드 때문에 재생성되지 않아 **영구히 배경이 깨진다**. + +수정: 동적 색은 텍스처를 만들지 않고 빌트인 `Texture2D.whiteTexture`에 `GUI.color`로 입히는 +`DrawTinted`로 전환(`DrawBorder`/`DrawProgressBar`/`DrawDimOverlay`/`DrawRarityGlow`). +`GetCachedTex`는 `GUIStyle.normal.background`용 정적 색 전용으로 한정하고, 오버플로 시 +`Destroy`를 제거해 stale 참조 가능성 자체를 없앴다(1x1 RGBA32는 개당 4바이트라 정적 색만 +담기는 구조에서 누적량은 무시할 수준). + +**중요**: `GUI.color`는 대입이 아니라 **곱셈**이어야 한다. `AccountLinkUI:124`, +`AccountSettingsUI:105` 등이 딤 오버레이로 `GUI.color`를 미리 설정하고, 색 텍스처를 그리던 +옛 동작도 그 값과 곱해졌다. 대입했다면 그 페이드가 전부 사라지는 회귀였다. + +### WorldFieldMultiplayerUI (P0:1, P1:1, 2026-07-17) + +**P0 — 채팅 대상이 uid로 고정되지 않아 입력 소프트락 + 사설 메시지 오배송.** +`chatOpen`은 bool인데 대상은 매 Update 재계산되는 `nearestPlayer`였다(Update 첫 줄이 +무조건 `nearestPlayer = null`). 결과 두 가지: (a) 상대가 대화 범위를 벗어나거나 접속을 +끊으면 composer는 사라지는데 `chatOpen`이 true로 남아 `IsOpen`→`ModalUIRegistry` 등록이 +유지되고, `IsAnyOpen()`이 PlayerMovement/VirtualJoystickUI/WorldInteractionController/ +CaptureInputController의 입력을 전부 차단 — **화면에 아무것도 없는데 입력만 잠긴다** +(모바일은 조이스틱까지 죽어 ESC/백버튼 외 복구 불가). (b) 작성 중 다른 탐험가가 더 +가까워지면 `SendPrivateChat(nearestPlayer.uid, ...)`가 **엉뚱한 상대에게 전송**. +수정: `chatTargetUid` 필드로 "대화" 클릭 시점의 상대를 고정하고, 신설 `ResolveChatTarget()`이 +매 프레임 `remoteAvatars[chatTargetUid]`로 해석(없음/범위밖/차단이면 null) → null이면 +`CloseModal()`로 모달 잠금 해제. 모달 전환 지점(친구 패널 토글, 차단 확정, 전송 완료)을 +전부 `CloseModal()` 경유로 통일해 `chatTargetUid`/`chatInput` 초기화 누락을 구조적으로 차단. + +**P1 — OnGUI 매 프레임 문자열 보간.** OnGUI는 프레임당 Layout/Repaint/입력 이벤트로 여러 번 +호출되는데 `DrawFieldStatus`(월드 타이틀), `DrawNearbyInteraction`(근처 탐험가 라벨), +`DrawMessages`(최대 5줄)가 매번 `$"..."`로 string을 힙 할당했다(struct인 Rect/Color와 달리 +진짜 GC 압박). 내용은 서버 이벤트 시점에만 바뀌므로 `cachedWorldTitle`(HandleWorldState), +`messageLines`(HandleMessages), `cachedNearbyLabel`(Update에서 대상 uid 변경 시에만) 캐싱으로 +전환. Draw 경로의 문자열 보간 0건. + +**자체 발견 회귀 1건**: `messageLines` 도입 후 `HandleWorldLeft`가 `messages.Clear()`만 하고 +`messageLines.Clear()`를 빠뜨려 두 리스트의 인덱스가 어긋날 수 있었다. 같은 커밋에서 수정하고 +`cachedWorldTitle`/`cachedNearbyLabel`/`nearbyLabelUid` 초기화도 함께 추가. + +**Explore 거짓양성(검증 후 제외)**: `InitStyles()`는 이미 `stylesReady` 가드 + 캐시 필드 + +`UIHelper.GetCachedTex` 표준 패턴이라 정상(자동 채점의 "프레임 할당 51"은 대부분 struct인 +`new Rect`/`new Color`). 싱글턴 5곳 전부 null 가드 있음. 이벤트 `+=`/`-=` 짝 정상(Subscribe가 +idempotent). AutoWire는 PlaySceneBootstrap:398에 존재. 200줄+ 메서드 없음. 세이브 표면 없음. + +### PlayerItemInventoryGridUIController (P1:1, 2026-05-21) + +HandleActiveChanged에서 UpdateRemainingTime() 즉시 호출 + remainingTextTimer=0 리셋 추가. 옛은 activeItemText만 갱신하고 남은 시간 표시는 Update의 1초 디바운스 대기 → 만료 시 "남은 시간: 00:00" 표시 최대 1초 지연. 주석(line 56)이 "만료 시점은 ActiveItemChanged 즉시 처리" 명시했으나 실제 호출 누락 회귀. OnEnable/AutoWire 이중 구독은 -=/+= 패턴으로 안전(거짓양성), BuildGrid Destroy+Instantiate 1프레임 중복은 정상 Unity 동작, EnsureDatabase 매 진입은 null 가드로 1회만 Load. diff --git a/.claude/audit-archive/round-log-2026H1.md b/.claude/audit-archive/round-log-2026H1.md new file mode 100644 index 0000000..58f518f --- /dev/null +++ b/.claude/audit-archive/round-log-2026H1.md @@ -0,0 +1,78 @@ +# Round Log 아카이브 (2026-05-20 ~ 2026-06-06) + +최근 10건은 `.claude/audit-progress.md`에 있다. + +- 2026-05-20: 누적 15개 시스템 처리. P0:5, P1:16, P2:1, 총 ~22건 수정. 회귀 자체 발견 6건(매번 즉시 수정). +- 2026-05-20: RaidBattleUI — P0:2 (EndRaid 타이머/캐시 누수), P2:1 (Find Player 캐싱). 거짓양성 1건 (DrawUniteGaugeBar 게이지 정합성 — CanUniteAttack 내부에 AliveCount 검사 이미 포함). +- 2026-05-20: BattleScreenUI 나머지 — P1:2 (DrawSwapSelect new Color 매 프레임 → SwapHeaderBase static, DisableCanvasBattleUI FindFirstObjectByType 캐싱), P2:1 (CheckGuardianDefeat FindFirstObjectByType 캐싱). Explore P0 분류는 호출 빈도 1회/배틀이라 P1로 재분류. +- 2026-05-20: RaidBattleController — P1:1 (UseUniteAttack 데미지 표시 정합성). 거짓양성 3건: 라인 157 UniteGauge AOE 가드는 이미 `if (LastDamageToBoss > 0)` 있음, 라인 231 KO 자동전환은 정상, 라인 203 +18 고정값은 의도된 디자인. +- 2026-05-20: 곤충 Despawn 회귀 (사용자 명시 요청) — P0:4건. (a) InsectBattleController 패배 (b) InsectBattleController 도주 성공 (c) CaptureController 캡처 실패 50%→100% (d) RaidBattleController 레이드 패배. 곤충 숫자(maxActiveTotal=20)/레벨(메인 power=3.5, sub=2.0)/위치(60m 디스폰, 8초 재배치) 점검 — 모두 정상. +- 2026-05-20: F11 unstick 안 됨 → F9로 변경 (OS 인터셉트 회피) + frozen 상태 우회 + OnGUI Event 백업. 도구 mesh 회귀 (P1:1) — ApplyToolShape이 scale만 변경하고 mesh는 Cylinder 고정 → 어떤 도구든 막대기. 도구별 PrimitiveType (Cube/Cylinder/Sphere) 교체 + sharedMesh 캐싱. +- 2026-05-20: CaptureFeedbackController — P0/P1 회귀 없음. 이벤트 구독/해제 짝 정상, null 가드 모두 있음, OnGUI 미사용(TextMeshPro). Covered로 이동만. +- 2026-05-20: 튜토리얼 진행 회귀 (사용자 명시 요청) — P0:4건. q_equip/q_item/q_training/q_team이 Notify 호출 누락으로 영구 정지 → 후속 13개 퀘스트 연쇄 정지. (a) BattleTeamManager.TeamChanged 구독 + OnTeamChanged 핸들러 (b) ItemEffectManager.ActivateItem 단일 진입점 NotifyItemUsed (c) TrainingManager.TrainSkill 성공 NotifyTraining (d) TrainingUI 사용자 EquipSkill 후 NotifySkillEquipped. 정상 동작 9개 (Movement/Capture/BattleWon/GuardianDefeated/DexOpened/CollectionOpened/LevelUp/RaidCompleted/Region) 확인. +- 2026-05-20: BattleArenaController — P1:1 (OnDisable ↔ CleanupArena 중복 → 위임으로 단일화). 거짓양성 2건 (Rect struct 매 프레임 new Rect는 stack 할당이라 GC 없음, SetupNormalBattle 첫 줄에서 CleanupArena 호출되므로 rendererCache 이미 Clear됨). P2:1 (effectText shadow new Color 매 프레임 — struct로 영향 작음). +- 2026-05-20: DexController — P0/P1 회귀 없음. Explore 4건 모두 거짓양성 또는 범위 외. SaveAndNotify는 RegisterEncounter/Capture 끝에 무조건 호출. encounter/capture 별개 dictionary로 race 없음. Covered 이동만. DexListUIController는 별도 audit 대상으로 Uncovered 추가. +- 2026-05-20: DexDetailUIController — clean. Canvas 기반 UI라 OnGUI new GUIStyle 회귀 없음, 이벤트 구독 없음, null 가드 견고. Explore가 부수적으로 DexScreenUI의 OnGUI new GUIStyle 다수 P0 발견 → Uncovered 1순위로 분리. +- 2026-05-20: DexScreenUI — P0:31 (매 OnGUI new GUIStyle 31개 + Color 다수). InitDexStyles + 캐시 필드 31개 + 정적 Color 38개. OnGUI 본문 잔존 new GUIStyle 0. 매 프레임 GC 객체 105~150 → 0. 단일 파일 819 → ~1080줄로 증가하나 모놀리스 아님. +- 2026-05-20: SubArea UI + 도구 미리보기 정합성 (사용자 명시 요청). (a) SubAreaWorldBuilder — 진입/퇴장 토스트 알림 (3초 페이드, NotifyEnter/Exit 색), F2 수동 Exit 단축키 (Input + OnGUI Event 백업), 우측 하단 안내 + RegionManager.ForceExitSubArea(). (b) CharacterPortraitRenderer — DrawCharacterTool 9종 분기 신규 (옛은 magnify만), 필드/카드/캐릭터 미리보기 3곳 분기 순서 1:1 정합. +- 2026-05-20: SubArea 바닥 빠짐 P0:8 (사용자 명시 요청). 9개 환경 boundary wall halfSize를 floor 안쪽으로 조정 (Cave 16→14, DeepForest 18→16, Underwater 16→14, FogSwamp 16→14, MountainPeak 14→11, Temple 14→13, Reeds 14→11, GenericArea 12→9), BuildGreenhouse CreateGlassWall NoCollider 제거(유리 벽 collider 보존), Y 안전망(Y<-3 → 자동 RequestExit + 알림 + cachedPlayerTransform lazy 캐싱). 첫 위치 끼임 재검증: FindSafeSpawnPosition spiral radius 7m가 새 벽(halfSize 9~16) 안에 안전하게 위치 — 회귀 없음. +- 2026-05-20: DexListUIController — clean. Explore P0/P1 거짓양성 (SortList null 가드는 BuildList early return으로 보장, AutoWire 이벤트 짝 정상). Covered 이동만. +- 2026-05-20: 인증/로그인 흐름 (사용자 명시 요청) — P1:2. (a) AuthManager.ValidateCredentials 헬퍼 — 빈 이메일/짧은 비밀번호 Firebase API 호출 전 차단, AuthFailed + LoginCompleted/RegisterCompleted false 이중 보고. (b) LoginUI OnGUI 17개 GUIStyle 변형 → base.fontSize 동적 갱신, OnGUI 본문 new GUIStyle 0. 인증 흐름 전반 검증 (TryAutoLogin race 없음, idTokenAcquiredAt 모든 경로, AuthFailed→Login 복귀, 회원가입→CharacterCreate) 정상 확인. +- 2026-05-20: InsectSpawner — P1:2 자체 발견 (Explore P0:2 + P1:1 모두 거짓양성). OnGUI debugStyleCache lazy 캐싱(매 프레임 new GUIStyle), GetPlayerTransform lazy 캐싱(매 호출 FindWithTag+Find). CleanupDeadEntities 풀 회수 누락 거짓양성은 직전 라운드와 동일 (외부 SetActive(false) 직접 호출 없음 grep 검증). +- 2026-05-20: RegionManager — P1:3. IsRegionAccessible 마스터 우회 (AuthManager.IsMasterAccount race 차단), DefeatGuardian 중복 가드 + SaveUnlockState 비용 차단, LoadUnlockState unlockedRegions Split RemoveEmptyEntries (defeatedGuardians만 있던 옵션을 일관 적용). P2 Time.time vs realtimeSinceStartup은 수용. +- 2026-05-21: PlayerInsectCollection — P1:3 자체 발견. (a) Awake instanceId 중복 가드: 옛 세이브에 같은 GUID 인스턴스 2개 이상 있을 시 옛은 dictionary가 1개만 보존 → BattleTeam.GetByInstanceId가 나머지를 못 찾고 슬롯 표시 빈 칸. 충돌 감지 시 새 GUID 재발급 + needsSave 트리거. (b) GainXp(data, insect, amount) insect null 가드: 형제 GainXp(InsectData, int) 오버로드는 line 169에 null 가드 있으나 line 185는 insect.levelCurve 직접 접근 — NRE. 현재 호출자 0건이나 방어 코드 유지. (c) GetAllOwned null sanitize: saveData.insects에 null 항목 있을 시 호출자 6곳 NRE 위험 — foreach + null skip. 슬롯 인덱스 race는 Unity 단일 스레드 + BattleTeamManager.SetSlot 중복 가드(line 59-63) + ResolveLegacyOrInstanceId 정규화로 거짓양성. 가챠 10연 디바운스 저장은 의도된 디자인(OnApplicationQuit/Pause flush 있음). +- 2026-05-21: PlayerProgressController — P1:3. (a) Awake sanitize (level 1~maxLevel, currentXp ≥ 0 클램프 + dirty Save) — 손상 세이브 음수 level/xp 진입 차단. (b) 만렙 도달 시 currentXp = 0 클램프 — UI Lv 50 / XP N>0 불일치 차단. (c) 만렙 후 GainXp early return — 잉여 누적 + 무의미한 Save/SaveCloud 차단. 디스크 디바운스는 호출 빈도 낮음(배틀/캡처/튜토리얼만 burst 없음)으로 P2 수용. CloudSaveManager 가드는 SaveToCloud 내부 3중 가드로 거짓양성. +- 2026-05-21: PlayerProgressSaveService — P1:1. Save(null) 가드 추가. static service라 임의 호출자가 진입 가능, JsonUtility.ToJson(null)이 빈 문자열 → 빈 파일 → 다음 Load 데이터 손실. atomic write 부재는 다른 SaveService 모두 동일 패턴이라 글로벌 라운드로 이관 (P2 보류). +- 2026-05-21: PlayerCandyInventory — P1:2. (a) AddCandy/SpendCandy data null 가드 (Candies 프로퍼티는 가드 있으나 변경 메서드 비대칭). (b) Save(null) 가드 — PlayerProgressSaveService와 일관성. 호출자 6곳 외부 가드 OK. +- 2026-05-21: PlayerCurrencyWallet — P1:5. AddGems/SpendGems/AddCoins/SpendCoins 4곳 + Save() data null 가드. Gems/Coins 프로퍼티(line 18-19)와 비대칭 회귀 복구. Save 안의 CurrencyChanged 발화도 null 전파 차단. CashShopManager 단일 소스 패턴 정합 유지. +- 2026-05-21: PlayerItemInventory — P1:3. (a) Awake sanitize: null record / 빈 itemId / 중복 itemId 합치기 — 옛은 중복 시 lookup이 마지막만 보존해 AddItem 변경이 stale로 동기화 깨짐 (드물지만 손상 세이브/옛 마이그레이션 시 가능). (b) GetSnapshot 얕은 복사 — DexScreenUI:659 외부 호출자 race 차단. (c) Save(null) 가드 — 다른 SaveService와 일관성. +- 2026-05-21: PlayerInsectLevelUpUIController — P1:3. (a) Refresh current null 가드 — selectedInstanceId stale 시 NRE. (b) OnEnable/AutoWire 이중 구독 차단 (subscribed 플래그 + Subscribe/Unsubscribe 헬퍼). (c) AutoWire isActiveAndEnabled 가드 — OnDisable 동안 구독 잔존 차단. +- 2026-05-21: ShopUIController — P1:1. Start line 65 itemDatabase null 가드 — Resources.Load 실패 시 FindById NRE. UpdatePriceLabels dead code(allowCoinPayment 런타임 불변)는 P2 보류. 토글 무한 루프, 인덱스 OOB, CashShop race 모두 거짓양성. +- 2026-05-21: GachaBoxUI → CashShopUI 가챠 영역 — P0:11. GachaBoxUI 클래스 자체 미존재(CashShopUI 통합). DrawGachaTab+DrawBoxCard+DrawGachaResultScreen new GUIStyle 11개+new Color 5개 → InitGachaStyles + 12 캐시 + 5 static readonly Color. boxPriceStyle/gachaTitleStyle textColor 동적 갱신. Phase 2 new Color/Rect는 struct stack로 거짓양성. CashShopUI Tab 0/Tab 1 영역은 별도 Uncovered 추가. +- 2026-05-21: ModalUIRegistry — clean. 진짜 회귀 없음. IModalUI 구현체 7개 OnDisable Unregister 일관, HandleEscape PlayerMovement:127 단일 진입점, Register LRU/IsAnyOpen 자동 정리/Unregister null 가드 모두 정상. Covered 이동만. +- 2026-05-21: CashShopUI 보석 충전/아이템 상점 영역 — P0:16. Tab 0/Tab 1 + OnGUI 본문 new GUIStyle 16개 + new Color 다수. InitMainStyles + 16 캐시 필드 + 14 static readonly Color. resStyle/itemPriceStyle textColor 동적 갱신. Phase 2 new Color(alpha 동적)와 new Rect(좌표 동적, struct stack)는 거짓양성. OnGUI 본문 잔존 new GUIStyle = 0. 같은 라운드에 가챠 영역(P0:11) + Tab 0/Tab 1(P0:16) 총 P0:27 처리. +- 2026-05-21: UIHelper — P0:1. GetCachedTex 캐시 256개 도달 시 texCache.Clear() 후 styleCache.Clear() 동반 호출 추가. 옛은 GUIStyle.normal.background이 Destroy된 텍스처 stale 참조 → MissingReferenceException 또는 invisible 버튼. GetButtonStyle이 정확히 그 패턴. new Color/Rect struct는 stack로 거짓양성. +- 2026-05-21: AudioManager — P1:1. StopBGM에 deferredBgmCoroutine 중단 추가. 옛은 PlayBGM → 즉시 StopBGM 시 deferred coroutine이 다음 프레임에 CrossfadeBGM 시작 → StopBGM 의도 깨짐 + 페이드 충돌. FindFirstObjectByType 1초 폴링 / ApplyVolumes fade 덮어쓰기 / gameClock stale / 다중 PlayOneShot 모두 거짓양성 또는 P2 수용. +- 2026-05-21: ProceduralAudioGenerator — clean. 1957줄 모놀리스이나 GetBGM/SFX/Ambient + 단일 Dictionary 캐시 패턴 일관, generator 본문은 캐시 hit 시 호출 안 되어 audit 범위 외. Covered 이동만. +- 2026-05-21: InsectEntity 외부 로직 — P0:1 + P1:3. (a) Camera.main 매 프레임 → static cachedMainCam (최대 20마리 핫패스). (b) Despawn 다중 호출 가드 (Battle/Capture 동시 호출 시 풀 중복 반환 + onDespawn 이중 발화 차단). (c) Initialize/BuildForBattle cache 리셋 (풀 재사용 stale Transform 회피). (d) cachedMainCam과 NameLabel Find 일괄 처리. BuildModel/AnimateWings/AnimateShinySparkle 내부는 visual-dev 영역. +- 2026-05-21: CameraFollower — P1:2. (a) SetTarget baselineValid=false 리셋 — SubArea 전환 시 첫 프레임 점프 차단. (b) EnterBattleMode 무조건 transition=0 리셋 제거 — fade-out 중 재진입 시각적 끊김 차단. +- 2026-05-21: InsectDatabase — P1:1. GetWeightedRandom/GetWeightedRandomWithRareBoost 4곳 foreach data null 가드. 외부 직접 candidates 전달 시 spawnWeight NRE 차단. 중복 ID 검증/weight 재계산/부동소수점 오차 모두 거짓양성 또는 P2 수용. +- 2026-05-21: OutfitSetData — clean. GetAllSets는 AutoWire 1회 호출 후 캐시. 12 세트 정적 데이터 정합성 정상. Covered 이동만. +- 2026-05-21: PlayerItemInventoryGridUIController — P1:1. HandleActiveChanged에 UpdateRemainingTime + remainingTextTimer 리셋. 옛은 만료/시작 시점 "남은 시간: 00:00" 표시 1초 지연. 주석이 즉시 처리 명시했으나 실제 호출 누락 회귀. +- 2026-05-21: SaveService 글로벌 atomic write — P1:8 + 신규 38줄. AtomicFileWriter 헬퍼(.tmp 쓰기 + File.Replace OS atomic) 신설 + 8곳 File.WriteAllText 일괄 교체(PlayerProgress/Candy/Currency/Item/InsectCollection/BattleTeam/Dex Save + CloudSaveManager.SaveLocalFile). 게임 종료 직전 크래시 시 부분 쓰기 손상 차단. CashShopManager는 PlayerPrefs라 제외. 누적 SaveService 일관성 100%. +- 2026-05-21: CollectionUI 핫스팟 — P0:1 + P1:3. DrawInsectItem 5 GUIStyle + 4 Color 캐시(InitItemStyles + 6 static readonly), GetAllOwned 캐싱(cachedOwned + InsectUpdated invalidate), OnEnable/OnDisable/AutoWire 이벤트 짝 + AutoWire isActiveAndEnabled 가드, DrawCenteredLabel new Color 2곳 static. 잔여 DrawPanel/Detail/Stats/LevelUp/StatBar는 별도 라운드(Uncovered 추가). +- 2026-05-21: TrainingUI 핫스팟 — P0:1 + P1:3. DrawInsectSelect loop 4 GUIStyle + 4 Color 캐시(InitInsectSelectStyles + 4 static readonly), GetAllOwned 캐싱(CollectionUI 동일 패턴), OnEnable/OnDisable/AutoWire 이벤트 짝. 잔여 DrawPanel/MethodSelect/SkillLearn/SkillEquip/SkillReplace는 별도 라운드(Uncovered 추가). +- 2026-05-21: CollectionUI 잔여 영역 — P1:27. DrawPanel/DrawDetailPanel/DrawStats/DrawLevelUpSection/DrawStatBar/DrawCenteredLabel 27 new GUIStyle 캐시화. InitDetailStyles + 25 캐시 필드 + 25 static readonly Color. DrawStats GetCachedOwned 추가. OnGUI 본문 잔존 new GUIStyle = 0. CollectionUI 핫스팟+잔여 합산: P0:1 + P1:30 (전체 처리 완료). +- 2026-05-21: TrainingUI 잔여 영역 — P1:34. DrawPanel/Back/MethodSelect/SkillLearn/SkillEquip/SkillReplace/SkillCard/Feedback 34 new GUIStyle 캐시화. InitTrainDetailStyles + 33 캐시 필드 + 38 static readonly Color. 8 메서드 textColor 동적 갱신. OnGUI 본문 잔존 new GUIStyle = 0. TrainingUI 핫스팟+잔여 합산: P0:1 + P1:37 (전체 처리 완료). +- 2026-05-21: PlayerStatusHUD — P0:1 + P1:2. OnGUI 본문 new Color 16곳 → 16 static readonly Color. GetAllOwned 캐싱(cachedOwnedCount + InsectUpdated invalidate), OnEnable/OnDisable에 InsectUpdated 짝 추가(subscribedInsects 플래그). GUIStyle 캐시는 이미 InitStyles 패턴으로 기존 구현됨. shine/regionCol scaled/accent scaled/alert alpha 동적 new Color는 struct stack 거짓양성. +- 2026-05-21: TrainingManager — P1:1. TrainSkill의 SpendCandy를 LearnSkill/ReplaceSkill 성공 후로 이동 + 실패 early return. 옛은 ReplaceSkill 실패 시 candy 손실 위험. LearnSkill은 사전 가드로 실패 불가이나 일관성 차원에서 동일 패턴 적용. +- 2026-05-22: CashShopManager 잔여 — P1:3. PurchaseWithGems의 잔액 검사(line 119)와 환불 기준값(line 137) 모두 stale `gems` 캐시 → `Gems` 프로퍼티로 교체(wallet 단일 소스 일관성). AddGems(0) early return 추가로 무의미한 GemsChanged 발화 차단. +- 2026-05-27: PlayerMovement 모달 차단 + InputAction 흐름 — clean. Explore 8 책임 매트릭스 점검 결과 진짜 P0/P1 회귀 0건. 모달 차단(HandleEscape+IsAnyOpen 이중 방어), InputAction(Input.GetKey+OnGUI Event 백업 이중 경로), OutfitChanged 동기(2026-05-20 완료), CameraFollower 점프(2026-05-21 완료) 모두 견고. FindFirstObjectByType 0건, transform.Find 1회 캐싱 정상, outfitBonus null 가드 있음. Covered 이동만. **누적 Uncovered 0개 도달.** +- 2026-06-06: **치비 잔여 P2 폴리시**. ① **필드 도구 과대 스케일 축소**(ApplyToolShape): 치비(키 1.43) 대비 솟구치던 net 손잡이 0.55→0.40·망 오프셋 hy+0.85→hy+0.62·망 0.26→0.20, wand 0.55→0.40·오브 hy+0.78→hy+0.58, cutlass/sword 칼날 0.55→0.40(끝 1.49>머리 1.43이던 것)·오프셋 hy+0.42→hy+0.32. PlayerVisualBuilder 기본 NetHandle/Ring도 정합. ② **포트레이트 안경 렌즈** 9×7s 고정→headW*0.32×headH*0.30(큰 치비 눈 덮음)+기본눈 위치 정합+브릿지. ③ **포트레이트 도구 가로위치** toolX 12s→8s(짧은 팔 손 중앙 정합). ④ **포트레이트 헤어 옆가닥(단발)** 4s→headW*0.13 굵게(큰 머리 빈약 해소). **제외(위험)**: CapsuleCollider/OverlapSphere 높이(자기가드 회귀 이력). **잔여(경미)**: 포트레이트 도구 silhouette 길이(20~28s)·헤어 옆가닥 case1~3·field 헤어 가닥 y(×0.83 미세)·bag_science·목걸이 오프셋. 8항목 PASS(new Rect struct 거짓양성). +- 2026-06-06: **체크포인트 커밋 + 서브지역 진입 버그 + 도감 곤충 크기/회전** (사용자 요청). 커밋: feat/character-dex-overhaul 브랜치에 작업트리 121파일 체크포인트(임시·정크 제외). **서브지역 진입 버그**: 원인 = E 진입이 Update의 Input.GetKeyDown만 의존(F2 퇴장엔 OnGUI Event 백업 있는데 E엔 없음 — focus/IME로 키 누락) + 마우스 미구현. 와이어링/구독(SubAreaChanged→OnSubAreaChanged→EnterSubArea)·검출(ContainsPoint XZ)은 정상 확인. 수정(SubAreaWorldBuilder.OnGUI): E 키 OnGUI Event 백업 추가(F2 동일 패턴) + 진입 안내를 GUI.Button(라벨스타일)으로 바꿔 마우스 클릭 진입 지원. **도감 곤충 크기**: InsectModelPreviewRenderer orthoSize 1.1→0.85(곤충 박스 더 채움). **도감 회전**: GetPreview(data, yAngle) 시그니처로 변경, (id,angle) 변화 시에만 단일 RT 재렌더(Update). DexScreenUI에 previewAngle 필드 + 박스 양끝 ◀▶ 버튼(±30°). 단일 호출처(DexScreenUI)라 시그니처 변경 안전. new Rect는 struct 거짓양성. 8항목 PASS. +- 2026-06-06: **모델 심화 잔여 + 치비 폴리시 2차**. **모델**: Butterfly(몸통 0.15,0.4→0.18,0.34 짧고통통·머리 0.25→0.3 확대·WingTip 0.12,0.18→0.18,0.24 둥글게·눈 z 0.4→0.45), Bee(MakeTopGloss 광택). 날개는 ApplyColor 반투명 경로의 _Glossiness 0.2로 자동 무광. **치비 폴리시(감사 통합목록 P1)**: ① 필드 HairTop 정수리 덮기(ShortHair y0.15→0.22 scale 0.52,0.3,0.5→0.62,0.34,0.60 / UpHair y0.2→0.26 scale→0.55,0.30,0.52) 두피 노출 해소 ② 포트레이트 모자 본체 높이 8s→headH*0.22·오프셋 headH*0.20(두 곳, 큰 머리 정수리 덮음) ③ 포트레이트 헤어 윗덮개 10~12s→headH*0.32~0.36·시작 headH*0.10(얇은 띠→볼륨) ④ 포트레이트 도구 손위치 handY 58s→36s(짧아진 팔 armH 34와 정합, 옛 손보다 24s 아래 허공) ⑤ 카메라 lookTarget 1.0→0.85(치비 가슴~목 중앙, 머리 위 여백↓; 시선벡터 (0,-0.8,0.6) 불변→도구 디스크 정합 유지). **잔여(저우선/위험)**: 필드 도구 스케일(머리높이라 비긴급), 포트레이트 안경렌즈/옆가닥/toolX/bag_science, 필드 헤어 옆·뒷가닥 y, 콜라이더/OverlapSphere 높이(자기가드 회귀 위험). 8항목 PASS. +- 2026-06-06: **곤충 3D 모델 심화** (설계서 `Docs/DexModelPolish.md` 적용). **공용 헬퍼(전 34종 동시)**: ① dark 음영색 단순절반→HSV(채도 유지·명도만 ↓)로 색감 살림 ② MakeAntennae 직선 캡슐1개→2분절 굴절 S곡선 + feathered 깃털(나방/모기) ③ MakeLegs 발끝(tarsus) 6개 추가 + 앞·중·뒷다리 각도 변주(upAng 26+4i/loAng 8+3i)·z 부채꼴(기계적 동일각 해소) + 무릎 0.045→0.05 ④ MakeEyes xSpread 파라미터화(넓은 머리 종 대응, 기본 0.12 무변경) ⑤ MakeTopGloss 헬퍼 신설(등껍질 상단 반투명 광택). **대표 종**: GenericBeetle(가슴마디 prothorax 추가+머리 확대 0.4→0.46+직접눈→MakeEyes+TopGloss), HornBeetle(직접눈→MakeEyes xSpread 0.2), RhinocerosBeetle(뿔 3분절 곡선 HornCurve+Y자 분기 HornFork L/R+직접눈→MakeEyes 0.22). 직전 ApplyColor PBR·MakeEyes 하이라이트/글린트와 합쳐 무광 점토→광택 키틴+촉촉한 큰 눈. 신규 노드명(TopGloss/Foot/AntBase·Mid/HornCurve·Fork/Prothorax) WingL/R·NameLabel 예약어 비충돌 확인. MakePart/풀/forBattle 보존, 수치공식無→테스트 영향 없음. 8항목 PASS. **참고**: ApplyColor가 파트마다 new Material(기존 구조, 본 변경으로 종당 +~10파트) — 머티리얼 캐싱은 별도 리팩터(보고만). **잔여**: Butterfly/Bee 대표종 심화(공용헬퍼+MakeEyes 자동 수혜라 우선도 낮음). 실측 Unity 권장. +- 2026-06-06: **도감에 진짜 곤충 3D 표시 완료** (직전 미완 ③ 해결 — dex-shape 핵심). 신규 `InsectModelPreviewRenderer`(Dex): 플레이 영역 밖(Y-5000) 전용 레이어 30에 직교 카메라+전용 광원(cullingMask 30, 월드 이중조명 방지) 리그. `InsectEntity.BuildForBattle`로 34종 3D 모델 재사용 생성→RenderTexture 렌더→insectId별 캐싱. 렌더는 OnGUI 아닌 Update에서 프레임당 1개 lazy(GUI 중 Camera.Render 회피), 모델은 enabled=false로 정적 프레임, 렌더 후 Destroy, OnDestroy에서 RT Release. DexScreenUI.DrawDetail: 포획 곤충은 박스 자리에 3D 프리뷰(ScaleToFit, 알파합성), 미준비/미포획은 기존 DrawTinyInsect 폴백(미포획 실루엣 reveal 로직 보존). Bootstrap EnsureComponent+AutoWire 등록. 직전 PBR 광택+촉촉한 눈 개선이 프리뷰에도 반영. 레이어 30 미사용 확인. 8항목 검증 PASS(new Rect는 struct 거짓양성). **실측은 Unity 에디터 권장**(orthoSize 1.1/회전 150°/조명 미세조정 가능). +- 2026-06-06: **곤충 도감 품질 개선 (동적 워크플로 동시 진행)** — 사용자 "도감 곤충 모양·내용 퀄리티 저하". 진단: (1)도감(DexScreenUI)이 곤충 자리에 단색 박스(whiteTexture)만 그림, 스프라이트 0개, 정작 InsectEntity 34종 3D 빌더는 도감 미사용. (2)InsectLore.json 64종 설명이 건조한 1줄. **완료**: ① **콘텐츠 64/64 리라이트**(InsectLore.json) — 1줄→2~3문장 매력·정확·재미요소 한글(워크플로 병렬 53종 + 직접 11종, 원본ID 머지로 중복제거·누락방지). ② **3D 모델 셰이딩 개선**(InsectEntity): ApplyColor에 PBR(_Glossiness 0.55/_Metallic 0.15, 셰이더명 가드, 날개는 0.2 무광) → 전 34종 무광 점토→키틴 광택 동시 개선(최고 ROI). MakeEyes 하이라이트 0.2→0.28·동공 0.6→0.64·서브 글린트 2개 추가(치비 촉촉한 눈). **세션 한도로 미완**: ③ 도감에 진짜 곤충 표시(RenderTexture 프리뷰) 에이전트 실패 — **도감은 아직 색 박스, dex-shape 핵심 미해결**(다음 단계, 설계 `Docs/DexModelPolish.md` 참조). ④ 모델 심화(다리 발끝/더듬이 곡선/뿔 굴절/대표종) 설계만. +- 2026-06-06: **치비 부작용 감사 적용 (1차)** — 4-에이전트 감사로 치비 리디자인 부작용 23건 도출(포트레이트 디테일 고정크기, 액세서리 허공, 도구 과대, 헤어). **적용(P0)**: 포트레이트 얼굴 부품(눈/동공/하이라이트/코/입/귀/눈썹/볼터치) 전부 headW/headH 비례 전환(옛 7×8 고정 콩알눈 → headW×0.30 큰 치비눈, 필드와 정합). 필드 액세서리 Y(안경 2.20→1.20·목걸이 1.88→1.00·배지 1.65→0.85) 허공 부유 수정+안경 확대. **잔여**: 필드 도구 과대 스케일, 헤어 정수리/옆가닥, 포트레이트 모자/헤어/도구 비례, 카메라 lookTarget, 콜라이더 등 ~20건(P1/P2, 통합목록 워크플로 산출). +- 2026-06-06: **필드 캐릭터 귀여운 치비 리디자인** (사용자 명시 "필드 캐릭터 너무 못생기고 매력없음 개선"). 방향: 사용자가 ~3.5등신 치비 선택. 원인 분석: 사실적 ~6.8등신을 단순 프리미티브로 표현 → 작은 머리+긴 박스 몸통+막대 팔다리+달걀형 머리+작은 눈 = 뻣뻣·비매력. 변경(PlayerVisualBuilder.BuildAll+BuildFace): (a) headPivotScale 0.42→0.60(머리 확대, 얼굴부품 자식이라 함께 스케일=배치 자동유지), (b) 머리구체 달걀형(−0.10,+0.12,−0.04)→둥글게(−0.02,−0.04,−0.04) **Z유지로 얼굴 z재배치 회피**, (c) 눈 0.11→0.15/0.17·동공/하이라이트 확대(큰 눈), 코 축소·입 하향·볼터치 확대, (d) 몸통 Y0.78→0.46 짧고통통, 팔다리 단축(arm 0.50→0.23, leg 0.62→0.20)+좁은스탠스, 목 최소화, head Y2.20→1.22 → ~3.5등신(총높이 ~2.38→~1.43). 연동: 손 Y0.95→0.52 → ApplyToolShape hx/hy + 도구 기본좌표 동기, 카메라 (0,12,-8)→(0,9,-6) 줌인(**Y:Z=1.5 비율유지로 시선벡터·도구 디스크 회전 -20° 보존**)+lookTarget 1.4→1.0, 포트레이트 CalculateProportions도 치비(head42/body40/leg40, ~3.3등신)로 창=필드 정합, OutfitUI 미리보기 2.4→2.9. 노드명 전부 보존(ApplyPartColor/AnimateWalk 무영향). **잔여**: CashShopUI 미리보기 scale(박스폭 미확인으로 미변경), 헤어 정수리 미세 노출 가능, 콜라이더 height 2 유지(치비보다 큼, 자기가드 회귀 회피). 실측은 Unity 에디터 권장. +- 2026-06-06: **도구 필드 vs 캐릭터창 불일치 + "막대기 뒤에 보임"** (P1:6). 사용자 보고 "캐릭터창과 필드 도구가 매칭 안 됨, 도구 끼면 막대기가 뒤에". 3-에이전트 적대검증(high). 원인: 필드 잠자리채 망(NetRing) 디스크가 rot(0,0,90)이라 법선이 ±X(옆) → 부감 카메라(시선 0,-0.8,0.6)에서 edge-on(테두리만)으로 collapse → 망 소멸, 손잡이 Cylinder만 막대기로 잔존. 창(DrawCharacterTool 2D)은 정면 사각 망을 또렷이 그려 불일치. lasso(rot 0,0,70)·magnify(rot 60→투영12%)도 동일 결함. 추가: 손 기준상수 hx/hy(0.34,0.91)가 실제 HandR(0.29,0.95)와 어긋나 도구 분리, PlayerVisualBuilder 기본 NetHandle/NetRing 좌표가 머리높이(2.28)·뒤(Z<0) stale 레거시. 수정(CharacterOutfitManager.ApplyToolShape + PlayerVisualBuilder.BuildAll): (a) hx/hy → (0.29,0.95) 손 정합, (b) net/lasso/magnify 디스크 rot → (-20,0,0)로 법선을 위-카메라쪽 정렬(워크플로 제안 rot(37) 부호오류를 기하 재검증으로 -20 보정 — 플레이어 전 방향에서 57~95% 가시, swing ±25° 더해도 edge-on 없음), (c) 빌더 기본 NetHandle/NetRing을 net 분기 최종값과 일치(레이스 잔재 제거+안전망). 필드를 source of truth로 고쳐 창과 자동 정합. **잔여(경미)**: gun/blaster/tranq 필드 정면뷰 vs 2D 측면 권총, shuriken Y축45° 수평, web_shooter 발사구 방향 — 별도 라운드 후보. net/golden/diamond 등급이 형태 미구별(색만 차이)도 디자인 개선 여지. +- 2026-06-06: **숲/유적/산 캐릭터 차폐 — 진짜 근본원인 최종 확정·수정** (P0:1). 사용자 재신고 "고대유적과 숲에서 캐릭터 위 레이어가 점령해 안 보임". 3-에이전트 적대검증(반증 실패, confidence high) 결과: 차폐 주체는 장식물이 아니라 **상승된 리전 지면 평면 그 자체**. WorldTerrainBuilder.ApplyElevation이 Region_{id} 평면을 forest Y=4.08 / ruins Y=8.08 / mountain Y=12.08로 끌어올리는데, PlayerMovement는 아래방향 2유닛 raycast로만 지면을 따라가 4~8유닛 단차를 못 올라 Y=0에 갇힘 → 불투명 collider 평면이 카메라(0,12,-8) 하향 시선과 플레이어 사이를 가림. CameraFollower.ResolveObstruction은 Region_/Ground/Floor를 차폐검사에서 제외해 보정 불가. **이 고도는 리전판정(ContainsPoint=XZ만)·스폰(player.y 추종)에 영향 0인 순수 장식**이라 확인 → 2026-05-27 이중합산/장식축소/카메라오프셋 등 6+ 라운드 시도가 모두 증상만 건드려 실패한 진짜 원인. 수정: WorldTerrainBuilder.GetRegionElevation의 forest/ruins/mountain을 0f로 평탄화(pond -3/swamp -2 보존), BuildElevationSlopes의 Slope_Meadow_Forest(0→0)·Slope_Mountain_Ruins(0→0) 허공 램프 잔재 정리. 평면·장식·플레이어·곤충 모두 Y=0 수렴. **남은 권고**: GetRegionElevation 회귀 테스트(private+MonoBehaviour라 InternalsVisibleTo 또는 ApplyElevation 통합 테스트 필요 — architect 상담), PlayerMovement.cs:253 Mathf.Max 상향전용 스냅+scenery collider 핀고정 2차 증상(경미, 별도 라운드). +- 2026-06-27: **브랜치 변경 전체 심층 리뷰 (7클러스터 다각도 적대 워크플로, P1:8 전부 수정)** — 사용자 "문제점 심층 분석 및 개선". 32에이전트(7 finder + 3렌즈 검증 + 완전성비평)로 feat/character-dex-overhaul 변경(main 대비 +19.5k/워킹트리 +3.7k)을 검증, 확정 P1:8건(거짓양성 0, realCount 2~3) 일괄 수정. ① **AuthManager.ClearAllLocalData**: `PlayerPrefs.DeleteAll()` → 현재계정 스코프(SaveScope.ClearCurrentAccountLocal)+전역미러(player_*/Gems/CurrentWorldId/LastSaveTs/MigratedVer./LocalOwner 소유시) 개별 삭제. 공유기기에서 B 계정삭제가 A의 . 스코프키까지 파괴하던 격리 무력화 차단. ② **SaveScope.MigrateLegacyIfOwned**: ScopedPrefsKeys를 String/Int 배열로 분리, int형 캐릭터 외형 7키(Created/Skin/Hair/Gender/HairColor/Face/Outfit)를 GetInt/SetInt로 복사(옛 GetString→""→GetInt 0 → 외형 영구 초기화 버그). MigrationVersion 2→3. ClearCurrentAccountLocal도 양 배열 순회. AuthManager:252 마스터 Created SetString→SetInt 동반 수정. ③ **CashShopUI** 가챠 표시가 하드코딩 800/1200 → CashShopManager.GetItem(boxId).gemPrice 단일출처(실제 차감 600/750과 불일치 차단, 표시가/게이팅/차감 일치). ④ **WorldChannelManager** LeaveWorld vs SyncWorld 재진입: Update sync가드 `syncInFlight`→`syncInFlight||IsBusy`, SyncWorldRoutine 응답적용 전 `CurrentWorld==null` 재검사(leave 중 도착한 stale 응답이 월드 되살리는 '나가기 눌렀는데 재입장' 차단). ⑤ **CloudSaveManager.SaveToCloud** 상단 `pendingCloudData!=null` 가드 — 세이브충돌 모달 대기 중 120초 자동저장이 클라우드를 선택 전 덮어쓰던 오염 차단. ⑥ **InsectBattleController.StartBattle** + ⑦ **RaidBattleController.StartRaid** `enemy/boss.SetEngaged(true)` 추가 — 배틀/레이드 중 야생곤충/보스가 도주→Despawn→풀 재사용되어 종료 시 엉뚱한 곤충 포획/레벨·이로치 오등록되던 보상 무결성 손상 차단(13초+ 배틀에서 실트리거). 보상도 라이브참조→시작스냅샷(enemyStats.Data/Level/enemyShinyAtStart, BossStats.Level/bossShinyAtStart)으로 이중안전. ⑧ **PlaySceneBootstrap.ValidateBattleDefinitions** per-insect 데이터결함(타입/전용기/비용/중복)을 fatal throw→LogError/Warning+런타임보정(타입 None→Bug, 전용기 element→primaryType, cost→1)으로 완화 — 전용기 타입 손동기화 1건이 게임 전체 부팅을 막던 차단. 구조검사(MaxLearnedSkills/타입상성표)는 fatal 유지. **완전성비평**: 18개 PlayerPrefs 호출 파일 전부 SaveScope 경유 확인, GameSaveData 신규필드 sentinel 가드 확인, 보석 무결성(ApplyVerifiedGemBalance 서버권위) 확인 → 추가 P0/P1 없음. **P2 잔여(별도)**: LastSubAreaId 스코프 미적용, WorldChannel UnityWebRequest timeout 부재, 1v1 무팀원 패배 시 적 잔존. + + +## 본문에서 이관 (2026-07-17) + +- 2026-06-27: **2차 심층 리뷰 — 플로우/생명주기/성능/익스플로잇 통합레벨 (P0:1 + P1:12 전부 수정)** — 사용자 "문제점 심층 분석 및 개선" 재요청. 41에이전트(7 플로우 finder + 3렌즈 검증 + 완전성비평)로 파일경계 넘는 통합버그 발굴, 확정 P0:1+P1:12(거짓양성 0, 전부 3/3 real). 격리 도메인 4건은 전문에이전트 위임(battle/game-designer/capture/ui-dev), 영속성·모바일입력은 메인 처리. ① **[P0] 계정삭제 PII부활**: DeleteAccountCoroutine 2-step(문서DELETE→AuthDELETE) 사이 IsLoggedIn=true라 자동/Pause저장이 삭제된 Firestore 문서 재생성 → CloudSaveManager.SetDeletionInProgress 플래그(SaveToCloud 최상단 차단) + AuthManager.DeleteAccount 진입 set/실패시 복원. ② **로그아웃 무플러시**: Logout이 ClearAuth만 하고 클라우드 저장 안 함(마지막 120초 유실) → ClearAuth 전 SaveToCloud()(동기 코루틴 시작, DontDestroyOnLoad라 씬리로드 후 완료), 마스터 제외. ③ **충돌'클라우드선택' 혼합세이브**: ApplyResolved의 빈필드 보존 가드가 명시적 충돌해소에도 적용돼 빈 클라우드가 로컬 곤충 못 지움 → ApplyResolved(forceReplace) + ResolveConflict(useCloud:true)에서 true, 빈 컬렉션이면 ApplyCloudFile→DeleteLocalFile 치환. ④ **오프라인 동일ts 덮어쓰기**: localTs==cloud(오프라인진행, LastSaveTs 미갱신)에서 `localTs>cloud`만 봐 클라우드가 로컬 덮음 → `localTs>=cloud`로 동일ts 로컬우선. ⑤ **곤충 디바운스 누락**: 포획 0.5s 디바운스 중 Pause→CloudSave가 stale player_insects 읽음 → AddInsectInternal을 SaveNow()(즉시저장)로(빈번한 XP는 디바운스 유지). ⑥ **401→CharCreate 오진입**: 토큰갱신 실패 로그아웃 상태인데 LoadCompleted(false)가 CharacterCreate로 → LoginUI.OnCloudLoadCompleted에 IsLoggedIn 가드(phase=Login). ⑦ **부스터 배틀/레이드 미적용**(위임 battle-dev): ItemEffectManager AutoWire 주입 + 승리 캔디/경험치에 GetCandy/ExpMultiplier×아웃핏 적용(포획과 일치, 표기=지급). ⑧ **가챠 확률 표기 불일치**(위임 game-designer, 공시위반급): 골드 전설 실제45%인데 5%표기 → GachaBoxManager.GetRates/GetRateText(임계상수 단일출처) + CashShopUI 파생표기. ⑨ **SubArea 가드부재**(위임 capture-dev): [E]/F2/진입버튼에 모달/frozen 가드 없어 포획모달·배틀 중 강제진입 → IsSubAreaActionBlocked(ModalUIRegistry.IsAnyOpen||IsFrozen). ⑩ **QuickBar 전투중 가드부재**(위임 ui-dev): 핫키/OnClick에 배틀/frozen 가드 없어 전투중 모달오픈 → IsInputBlocked + 모바일바 숨김. ⑪ **멀티터치 잡기버튼**: 조이스틱이 finger0 점유시 IMGUI 합성마우스로 잡기버튼 안눌림 → 신규 FieldHudInput(가상↔화면 변환) raw터치 히트테스트, 터치기기는 GUI.Button 대신 Update raw처리. ⑫ **클릭이동 오발**: IMGUI 버튼 위 탭이 월드 클릭-이동 동시발화(캐릭터 화면밖 이동) → FieldHudInput.RegisterBlockingRect(잡기버튼) + PlayerMovement 클릭가드에 IsScreenPointOverHud 추가. **완전성비평 추가발굴**: 곤충 디바운스 flush(⑤로 처리). **P2 잔여**: 회원가입 후 ReloadAllLocalFromDisk 비대칭, ClearMasterDataIfNeeded UserId 세팅전 호출, QuickBar/SubArea 외 버튼 클릭이동(모달가드로 대부분 커버). +- 2026-06-27: **3차 심층 리뷰 — 세션 수정 회귀검증 + 미답영역 (P0:1 + P1:5 전부 수정)** — 사용자 "문제점 심층 분석 및 개선" 3차. 26에이전트(7클러스터: 회귀검증+데이터/밸런스/미니게임/비주얼/진행/UI레이아웃, 3렌즈 검증). 확정 P0:1+P1:5(거짓양성 0). 메인 직접 처리(세션한도로 위임 없이). ① **[P0] 레이드 가디언 격파 미등록 → 영구 진행차단**: CaptureChoiceUI가 Epic/Legendary는 [B]1v1 숨기고 [R]레이드만 제공하는데, DefeatGuardian 호출이 BattleScreenUI.CheckGuardianDefeat(1v1)에만 있어 연못/숲/습지/산 4개 수문장이 레이드로 처치해도 다음리전 미해금·무한리스폰 → RaidBattleUI.OnRaidEnded 승리분기에 CheckRaidGuardianDefeat 추가(BossStats 스냅샷으로 보스id/레벨, regionMgr.DefeatGuardian+NotifyGuardianDefeated). ② **[P1 회귀] 로그아웃 플러시 LocalOwner stale**: R2의 Logout 전 SaveToCloud가 SaveCoroutineInternal success에서 LocalOwnerKey를 라이브 UserId(=ClearAuth로 null)로 덮어써 계정전환 시 sameOwner 오판정→B 데이터 거부·유실 → targetUid를 첫 yield 전 캡처, success에서 IsLoggedIn&&UserId==targetUid일 때만 전역키 기록. ③ **[P1] 미니게임 입력 이중처리**: Update의 Input.GetKeyDown/GetMouseButtonDown 폴링 + OnGUI 이벤트가 같은 누름을 이중 큐잉, 프레임당 1소비로 누름1회당 ConfirmCapture 2회→2번째가 cursor=0 miss→즉시종료, 3단계 콤보·퍼펙트보너스 영구불가 → Update 폴링 제거, OnGUI 이벤트 단일 소스. ④ **[P1] 곤충 모델 머티리얼 누수(필드+도감)**: ApplyColorRaw가 파트마다 new Material(.material)인데 ClearChildren/Destroy(modelGo)가 인스턴스 머티리얼 미해제 → 풀재사용/리스폰·도감회전마다 수십개 누수(모바일 OOM) → InsectEntity.ClearChildren + InsectModelPreviewRenderer에서 자식 렌더러 .material Destroy(.material은 인스턴스만 반환→공유에셋 안전, root 제외). ⑤ **[P1] 가챠 풀등급↔DB rarity 불일치**: normalPool 티어배치가 DB rarity와 손동기화 어긋나(브론즈박스 전설누출/팝업≠수집 등급) → 풀 재배치(13종 이동: atlas_moth_giant·beetle_hercules→Legendary 등) + OpenBox에서 GetDbRarity로 결과등급 단일출처 보정(레벨/팝업/도감 일치). **P2 잔여(미처리)**: RollIV IV=16 off-by-one, q_visit_pond 임의리전 완료, garden 가디언레벨13<입장18, CashShopUI UIScale 미적용, TrainingUI scrollPos 공유, 대형모달 노치인셋. **완전성비평 에이전트는 세션한도로 미완**. +- 2026-06-27: **4차 심층 리뷰 — R3회귀 + P2백로그선별 + 미답영역(오디오/튜토리얼/안정성/Firestore/대형화면) (P1:5 + P2:1 수정)** — 사용자 "문제점 심층 분석 및 개선" 4차. 23에이전트(7클러스터, 3렌즈). 확정 P1:5(거짓양성 0) + critic P1:1(q_equip, medium 보류). CashShopUI는 ui-dev 위임, 나머지 직접. ① **[P1 R3회귀] 미니게임 이중확정 재현**: R3가 Update폴링은 제거했으나 OnGUI 내 전역 MouseDown(line262)+캡처 GUI.Button(line355) 두 confirm소스 잔존 → 모바일 버튼탭이 MouseDown(누름)+MouseUp(뗌) 이중확정(R3가 고치려던 바로 그 증상 재현) → 캡처버튼 시각전용화(wantConfirm 제거)+전역 MouseDown 단일소스+취소버튼 rect 제외(가상좌표 변환). ② **[P1 R3회귀] 가챠 water_strider_pond**: R3 재배치 때 1종(DB=Common)이 Uncommon풀 잔존 → GetDbRarity가 Common 보정하나 GetRates표기와 전달분포 괴리 → Common풀로 이동(13+exclusive10 전수 일치 확인). ③ **[P1] 볼륨 슬라이더 무반응**: SettingsPanel.ApplyVolume은 AudioMixer만 세팅하나 AudioManager는 믹서 미경유(masterVolume/sfxVolume 필드 직접믹싱)+DontDestroyOnLoad라 세션중 재초기화 안됨 → SetMasterVolume/SetSFXVolume이 데드메서드였음 → OnMaster/SfxVolumeChanged+LoadSettings에서 AudioManager.Instance.SetMaster/SetSFXVolume 호출 동기화. ④ **[P1] 튜토리얼 가디언 선격파 영구정지**: q_guardian1 활성 전 meadow 수문장 격파하면 NotifyGuardianDefeated가 ActiveQuest.type 불일치로 no-op+RegionManager 격파영구기록 → 재격파 불가로 q_guardian1+후속6퀘스트 연쇄정지 → ActivateNextQuest/ReloadFromDisk/BeginTutorial에 ReconcileActiveGuardianQuest(AnyGuardianDefeated면 자동완료) 추가. ⑤ **[P1] CashShopUI UIScale 미적용**(위임 ui-dev): 결제화면만 원시픽셀→저해상도 글자과대/오버플로 → UIScale.Begin/End(2 return경로 균형)+Screen.width/height 8곳→VirtualScreen, 세션변경(가격/확률/스타일캐싱) 보존. ⑥ **[P2] RollIV off-by-one**: Random.value=1.0시 IV=16 → Mathf.Min(MaxIV) 클램프. **P2 잔여**: q_visit_pond 임의리전(게이팅 마스킹), garden 게이트역전, TrainingUI scrollPos, 노치인셋, 다중머티리얼 2번째+(프로시저럴은 단일머티리얼이라 실무영향無), q_equip 자동장착(critic medium). **누적 4라운드: P0:2 + P1:25 수정**. +- 2026-06-27: **5차 심층 리뷰 — R4회귀+IAP/멀티/빌드/리전/상점/P2 (P1:1 + P2:1 수정, 세션한도로 일부 검증 중단)** — 사용자 "문제점 심층 분석 및 개선" 5차. 17에이전트(세션한도 8:20pm 리셋으로 find:region/CashShopManager·TutorialQuest verify·critic 중단). 확정 P1:1(거짓양성 0) + 다수 P2. 추가 워크플로 없이 직접 처리. ① **[P1] WorldChannelManager 무한 소프트락**: SendRequest의 UnityWebRequest에 timeout 미설정(코드베이스 전체 0건)+IsBusy/syncInFlight 리셋이 코루틴 본문끝(try/finally 아님) → half-open 연결/이벤트핸들러 예외 시 플래그 영구 true로 월드 나가기/새로고침/동기/채팅 전부 소프트락(앱재시작 외 복구불가) → SendRequest request.timeout=12 + 6개 라우틴(Refresh/Join/Leave/Sync/Mutation/RespondInvite) try/finally로 플래그 복구 + ClearWorldState invites.Clear. ② **[P2] garden 게이트 역전**: guardianLevel(13)에 Directional 필터 없음(PlaySceneBootstrap:592는 같은 호출에 가드 있음), defaultCameraBg 초기자(이번에 함께 추가). 검증: Unity PlayMode 38/38, error CS 0건. +- 2026-07-17: **3라운드 병렬** (AccountSettingsUI / SaveConflictUI / KeyGuideHUD) — P0:2, P1:4 처리. **AccountSettingsUI P0**: 게스트 로그아웃이 확인 0단계로 계정을 영구 소실시켰다 — 게스트는 refresh token이 유일한 재진입 수단인데 Logout()이 그걸 지우고 익명 재로그인은 매번 새 uid를 발급하므로 곤충·레벨·재화가 인증 불가능한 uid에 묶여 고아화된다(파일은 users//에 남지만 접근 불가). 같은 파일 163줄이 "앱 삭제 시 데이터 복구 불가"라고 경고해놓고 124줄에서 동일 피해 버튼을 무방비 제공했고, 파괴가 의도인 계정 삭제는 2단계 확인을 받는데 이쪽만 없는 **안전장치 역전** 상태였다 → confirmLogout 단계 신설(게스트만). P1:2는 AccountLinkUI와 동일 패턴(취소 죽은 코드 + processing 리셋 부재 → 계정 삭제 영구 불가, IsAnyOpen 가드 + depth 없음). **SaveConflictUI P0**: 로컬 카드의 "마지막 저장"이 실은 마지막 클라우드 푸시 시각이라, 충돌 분기(cloud > local)의 전제상 **100% 로컬이 더 오래된 것으로 표시**되고 오프라인 진행 기기는 "기록 없음"으로 나와 플레이어가 최신 로컬을 버리도록 유도됐다(선택 시 DeleteLocalFile 하드 삭제, AtomicFileWriter의 File.Replace도 백업 미생성) → 세이브 파일 7개 mtime 최신값을 쓰는 ComputeLocalSaveUnix() 신설, OnConflict에서 1회 캐시(OnGUI 파일 I/O 회피). P1(2단계 확인/백업)은 P0로 오도가 제거됐고 UX 판단이라 보류. **KeyGuideHUD P1:2**: 컬렉션 안내가 TAB인데 실제 키는 C(Tab은 미니게임 confirm/로비 토글에 쓰임 — 안내대로 누르면 엉뚱한 동작), 모달 가드/depth 부재로 패널이 모달 위 오버드로(단 Button이 없어 클릭은 미탈취라 P1). **채점 반증**: AccountSettingsUI 싱글턴 11건 전건 가드 정상, 프레임 할당은 4라운드 연속 전부 struct 거짓양성. 검증: Unity PlayMode 38/38, error CS 0건. +- 2026-07-17: **3라운드 병렬** (QuickAccessBarUI / SocialPvpUI / SceneAutoWire) — P0:2, P1:2 처리 + clean 1. **QuickAccessBarUI P0**: 이 파일은 그동안 *모달 가드 관례의 근거*(:113)로 인용돼왔는데 그 가드가 **모바일 렌더 경로에만** 있었다. 핫키 경로는 `IsInputBlocked()`(battle/raid/frozen)만 거치는데, 주석의 전제 "모든 모달이 SetFrozen을 거니 frozen으로 커버된다"가 깨져 있었다 — SocialPvpUI·WorldFieldMultiplayerUI는 모달 등록만 하고 SetFrozen을 안 부른다. 결과: 친구코드·채팅 입력에 N/T/G/C/Q/M/V/P를 치면 **글자마다 화면이 토글**되고, e.Use()가 그 글자를 삼켜 **입력조차 안 됐다**. TryHotkey(index)→bool 신설로 막힌 키는 e.Use()를 하지 않게 하고, 열린 화면 자신의 키는 통과시켜 토글 유지. 렌더 경로엔 가드를 넣지 않았다(넣으면 데스크톱 바가 사라지고 active 하이라이트가 죽는 회귀). **SocialPvpUI P0**: `GUI.enabled = !IsBusy && teamReady`가 "매칭 취소"까지 덮어, 큐 대기 중 팀을 바꾸면 취소가 죽었다. queued는 서버 권위값이라 재접속해도 유지 → **영구 감금**. AccountLinkUI·AccountSettingsUI에 이은 **세 번째 동일 패턴**. **SocialPvpUI P1:2**: (a) OnStateChanged가 level-trigger라 매치 중 폴링(2.5초)마다 탭이 배틀로 튕겨 친구/랭크 탭에 머물 수 없었다 → edge-trigger화. (b) HasValidTeam을 OnGUI마다 호출해 BuildTeamSnapshot이 매번 List·배열(전부 class = 진짜 힙 할당)을 만들고 팀 미완성 시 **예외까지 반복** → 0.5초 스로틀 캐싱. **SceneAutoWire clean**: "미캐싱 조회 40건"이 이중 거짓양성 — 40건 전부 Awake 하나에 있고(파일이 통째로 Awake), 게다가 **이 컴포넌트는 어디에도 붙어있지 않아 Awake가 실행되지 않는다**(GUID 참조 0, 씬 MonoBehaviour는 Bootstrap 하나). 즉 **파일 전체가 dead code**이며 Bootstrap이 배선을 전부 흡수했다. 삭제 권고(P2) — 방치 시 누가 붙이면 이중 배선되는 잠복 지뢰이고, verify_coverage.py:12/architect.md:20/architect.toml:10이 활성 파일로 등재 중이라 동반 정리 필요. **채점기 실패 모드 확정**: "프레임 할당"(6라운드 연속)과 "미캐싱 조회"(이번) 둘 다 **실행 컨텍스트를 보지 않고 구문만 세는** 같은 병 — Awake 스코프·씬 참조 필터가 필요하다. 검증: error CS 0건. +- 2026-07-17: **3라운드 병렬** (VillageBuilder / NpcManager / NpcDialogueUI) — P1:4, P2:1 처리. **NpcManager P1**: 스폰 캡(주민 10, 아이 6)이 VillageBuilder가 저작한 앵커 수(14, 7)보다 작은데 SyncSpawns가 앞에서부터 잘라 쓴다. 본마을 8개가 먼저 들어가고 전초기지가 리전 순서로 뒤에 붙으므로 **swamp·mountain·garden·ruins 전초기지 4곳에 오두막·모닥불은 지어지는데 주민이 영구히 0명**이었다(매 세션 1회 예외 없이 실행). 캡을 14/7로 상향. **동반 수정**: GameplayTuningProfile 기본값도 10/6이라, 지금은 프로필 에셋이 없어 ApplyTuning이 죽은 경로지만 누군가 에셋을 만드는 순간 NpcManager:131-132가 덮어써 재발한다 → 프로필도 14/7로. **VillageBuilder P1:2**: c.y 이중 합산 가설은 반증(Polar가 Y를 0만 더하고 WorldTerrainBuilder가 전 리전 평탄화). 대신 "지면을 Y=0으로 가정한" 계열 2건 — (a) 광장 상면이 정확히 0.08로 Region_ 평면(Bootstrap:1265가 Y=0.08 배치)과 **같은 평면**이라 지름 16m 원반이 z-fight/소실, y 0.03→0.10. (b) 9개 건물 중 **상점만 Roof 프리미티브가 없어** 간판이 벽 상단 위 0.3m 허공에 떴다(훈련소는 Roof 상단=간판 하단 밀착이 관례) → Roof 추가로 벽·간판 양쪽 밀착. P2 동반: TentCamp GroundMat도 같은 원인으로 매몰(0.055<0.08) → 0.12. **NpcDialogueUI P1:1**: 7연속 모달 소프트락 패턴은 여기 없다(각 가설 반증 — GetLines가 항상 3개 반환, Show가 null을 isOpen 전에 차단, NpcManager가 SetActive(false)해도 Update:78이 잡아 CloseModal, GUI.enabled 미사용). OnDisable이 Unregister만 하고 isOpen을 안 되돌려 재활성 시 ESC 영구 무력화 + 그 주민과 영구 대화 불가가 되는 latent 버그만 수정(CloseModal 위임). 옛 주석이 인용한 CaptureChoiceUI 관례는 이미 두 번 폐기된 방식이다. 검증: Unity PlayMode 38/38, error CS 0건. +- 2026-07-17: **3라운드 병렬** (CatcherKidNpc / VillagerNpc+NpcWalkAnimator / Capture아이템 3종) — P1:1 처리 + clean 2, 채점기 구멍 1건 수정. **CatcherKidNpc P1**: Rare+ 곤충이 12m 안에 있으면 아이가 영구 고착했다 — 스캔이 잡기/구경 후보를 한 루프에서 최근접 하나로 뽑는데 Rare+는 CanKidTarget을 우회해 무조건 후보가 되므로, 최근접이 Rare면 Watch(2~4s)→Idle→재스캔→같은 Rare가 여전히 최근접→Watch로 순환한다(Wander 불가, 포획 영구 중단). 같은 원인으로 Rare 뒤의 Common도 bestSq에 밀려 영영 가려졌다. 탈출은 그 곤충이 60m 밖으로 사라질 때뿐이라 플레이어가 근처면 무기한. 수정: bestCatch/bestWatch 분리(잡기 우선) + lastWatchedInsect로 직전 대상 제외, **해제는 Wander 완주 시점**(진입 시점에 풀면 그 케이스 스캔이 곧바로 같은 곤충을 다시 잡아 고착 재발). 상태 전이 모사로 검증: before는 Watch↔Idle 무한·포획 0회, after는 Watch→Idle→Wander→완주→Idle 순환이며 Common 동반 시 40틱 20회 포획. **공정성은 clean** — SetEngaged/PlayerClaimRadius 3중 방어로 플레이어 미니게임 중 곤충은 가로챌 수 없고, 유령 곤충도 없으며, 쿨다운 하한은 [Range]가 강제. **VillagerNpc+NpcWalkAnimator clean**: Talking 고착 3경로 전부 반증(비활성 GO에서도 EndTalk은 C# 호출이라 정상 동작, 씬 전환은 OnDisable→CloseModal, 대화 중 SetFrozen이라 이탈 불가). 좌표도 MaxGroundStep 클램프가 지붕 오인 차단. **Capture 아이템 3종 clean(dead code)**: PlaySceneBootstrap:335-338이 주석으로 꺼놨고(`// 필드 아이템 스폰 비활성화 — 아이템은 샵/보상에서만`) 씬 GUID 역참조도 0건이라 필드 아이템 흐름 자체가 없다. CaptureProximityTrigger.TryStartCapture도 호출자 0(실제 포획은 CaptureInputController가 재구현) — 단 position/radius 제공용으로 살아있어 삭제 불가. **이 라운드가 채점기 구멍을 드러냈다**: code_mentions가 raw text를 읽어 **주석 안의 언급도 살아있음으로 셌다** → strip_cs 적용(같은 커밋). 1단계 참조만 보는 한계는 주석에 명시. 검증: Unity PlayMode 38/38, error CS 0건. diff --git a/.claude/audit-archive/round-log-2026H2.md b/.claude/audit-archive/round-log-2026H2.md new file mode 100644 index 0000000..11e52d1 --- /dev/null +++ b/.claude/audit-archive/round-log-2026H2.md @@ -0,0 +1,132 @@ +# Round Log 아카이브 (2026-07-19 ~ 2026-08-03) + +`.claude/audit-progress.md`에는 최근 5건만 둔다. 그 이전은 여기, 2026-06-06 이전은 +`round-log-2026H1.md`. + +이 로그는 **쓰기 전용**이다 — audit 스킬 Step 4가 추가하지만 어느 단계도 읽지 않는다. +그런데 Step 1이 progress 파일을 통째로 Read하므로, 쌓아두면 매 라운드 컨텍스트를 먹는다. +영역별 처리 이력은 progress의 Covered 인덱스가 이미 갖고 있다. + +- 2026-07-19: HospitalUI (신규 병원 치료 UI, 이번 세션 추가) — clean, P0/P1 0건. audit_candidates score 36(프레임할당9/싱글턴9)이 **둘 다 거짓양성**: `new GUIStyle`은 stylesReady로 1회 캐시, `new Rect/Color`는 struct(스택 할당), 싱글턴 역참조는 렌더 경로 전부 삼항 가드 + `UITheme.Instance`는 CreateInstance 폴백이라 null 불가. 이 프로젝트 반복 버그 7종(모달 ESC 무력화·이벤트 짝·결제 원자성·stale 아이템·미캐싱 조회) **전부 회피** — TrainingUI 패턴을 정확히 따름. **P2:1 보류**(OnDisable은 InsectUpdated 해지하나 OnEnable 재구독 없음 — Hospital GO가 SetActive 토글 안 돼 현재 미발현). 이 세션 빌드 블로커 2건 동반 수정: `InsectDatabase.FindById`→`GetById`(컴파일 CS1061), 신규 테스트 4개 `#if UNITY_EDITOR` 가드(IL2CPP nunit 링크 실패). 검증: error CS 0건, APK 112MB 빌드 성공. **P2 후속**(7265b37): OnEnable에서 InsectUpdated 재구독 추가. +- 2026-07-19: GuidedTutorialController (가이드형 튜토리얼 오버레이, 이번 세션 신규) — clean, P0/P1 0건. score 15 "프레임 할당 5"는 거짓양성(`new Rect`×5·`new Color`×2는 struct 스택, `new GUIStyle`×2는 stylesInit 1회 캐시). 항목 1~7 전부 통과: Find는 Start 1회, null 가드 완비, **구독은 Start↔OnDestroy 페어링**이라 SetActive 토글 소실버그에 애초 해당 안 됨(HospitalUI와 다른 정당한 설계 — 씬 상주 오버레이). Dictionary 기반이라 스텝 범위초과 불가, OnQuestCompleted가 항상 ExitGuided로 clear해 영구정지·완료후잔존 없음. 코루틴 없음, 비모달(IsAnyOpen로 겹침 회피). 검증: error CS 0건. +- 2026-07-19: StoryDirector (스토리 트리거 디렉터, score 2) — P1:1 처리 + P2:1 보류. **P1**: OnInsectUpdated가 InsectUpdated(포획+XP+치료+진화 전부 발화)를 CaptureInsect 트리거로 매핑 → 이번 세션 치료경제가 발화지점(HealInsect/CurePoison/SetAfterBattle)을 늘려 pond/swamp/mountain에서 곤충 **치료만 해도** 빈-param 스토리 비트(ch2_water/ch4_bond/ch5_thesis)가 오발화. PlayerInsectCollection에 포획 전용 InsectCaptured 이벤트(AddInsectInternal에서만 발화) 신설, StoryDirector가 그걸 구독(1884971). 싱글턴 참조 2는 거짓양성(CloudSaveManager.Instance 동일라인 null 가드). Update/OnGUI/코루틴 없음, 이벤트 Start↔OnDestroy 페어링. **P2 보류**: Immediate 인트로 ch1_intro의 렌더러(NpcDialogueUI) 구독이 buildWorld+try 안이라 빌더 예외 시 구독 누락→헤드리스 CompleteBeat로 대사 미표시+보상 소모+oneShot 영구소실(buildWorld 기본 true라 정상 플레이 무해). 검증: ci_check 통과, error CS 0건. **P0 동반**(사용자 신고, 커밋 8cb7da8): 부팅 canary가 MaxLearnedSkills(6)를 오검사해 "핵심 시스템 초기화 실패"로 게임 중단 → MaxEquipSlots(4) 정정. +- 2026-07-22: GameConstants — P0:0, P1:2 처리. MaxLearnedSkills=6과 불일치하던 테스트를 6/7번째 기준으로 갱신하고, 누락된 story_progress.json을 SaveScope MigrationVersion 4의 마이그레이션·삭제 파일 목록에 추가. 회귀 테스트 3건 추가, 전체 Unity PlayMode 73/73 통과. 자체 발견 회귀 0건. +- 2026-07-22: PlayerProgressUIController — P0:0, P1:1 처리. Bootstrap이 텍스트 참조를 늦게 연결해 저장 레벨·XP·사탕이 기본값으로 남던 문제를 Refresh 호출과 회귀 테스트로 수정. 자체 발견 회귀 0건. +- 2026-07-23: InsectElement (score 0 표면 점검) — clean, P0/P1 0건. 현재 enum 전체가 상성 switch에 포함되고, `None`·중복 보조 타입·이중 상성 결과를 안전하게 처리함. 미등록/향후 enum 값은 중립값으로 폴백해 배열·인덱스·null 위험이 없으며, 1v1·레이드 호출부도 누락 Data를 None으로 대체함. 파일 수정 없음. +- 2026-07-24: InsectExpansionDefinitions — clean, P0/P1 0건. 신규 ID 64개의 내부·기존 ID 비중복, 전 항목 리전/서브에리어 풀 배정, 기존 insectId 저장 호환성을 확인. 파일 수정 및 자체 발견 회귀 0건. +- 2026-07-27: InsectLoreService — clean, P0/P1 0건. DB/Lore ID 128/128 일치, 중복·누락 없음. Bootstrap AutoWire 순서와 정적 캐시 초기화·조회 경로를 확인. 파일 수정 및 자체 발견 회귀 0건. +- 2026-07-31: ItemRarityPalette (score 0 표면 점검) — clean, P0/P1 0건. `ItemRarity` 5값이 6개 switch 전부에 4 case + default(Common)로 정합. **`Assets/Resources/ItemRarityPalette.asset`이 존재하지 않아 `PlaySceneBootstrap:3938`의 `Resources.Load`가 항상 null을 반환**하지만, 소비자 3곳이 모두 안전: `ItemRarityTuningUIController.Start`는 `palette == null`이면 즉시 return(`UpdateLabels`의 무가드 역참조에 도달 불가), `ItemInventoryGridItem`의 색·펄스·그라디언트 3경로는 전부 null 가드 + 하드코딩 폴백. 파일 수정 0건. **P2 보류**: 튜닝 패널(슬라이더 5 + 라벨 5 + 패널 1 + 컨트롤러)이 매 세션 생성되나 palette가 null이라 영구 무동작이고 `PlaySceneBootstrap:3967`에서 항상 `SetActive(false)` — 개발용 잔재. **P2 후속 처리(사용자 선택: 애셋 생성)**: `Assets/Editor/ItemRarityPaletteBuilder.cs` 신설로 `Assets/Resources/ItemRarityPalette.asset`을 재현 가능하게 생성(색·펄스·두께·글로우·속도는 SO 필드 초기값, 인스펙터 없이는 비는 Gradient 5개만 등급색 기반으로 채움). `CreateInventoryItemPrefab`(`PlaySceneBootstrap:4441`)에 `rarityPalette` 주입 2줄 추가 — `Resources`에 UI 프리팹이 없어 `TryBuildUIFromPrefab`이 항상 false를 반환하므로 절차 생성 경로가 실제 경로다. 검증: error CS 0, ci_check 통과, PlayMode 168/168. +- 2026-07-31: PlayUIRefs (score 0 표면 점검) — **P1:1 처리**. 순수 참조 홀더라 프레임 할당·이벤트·싱글턴은 애초에 없다. 실질 점검은 **3자 계약 대조**: 필드 90개 ↔ `PlayUIPrefabGenerator` 대입 77개 ↔ `WireFromRefs` 읽기 88개. 읽는데 안 채우는 13개 중 **12개는 `Text`/`TMP_Text` 이중화로 정상** — `WireFromRefs`가 양쪽을 각각 리플렉션 주입하고 생성기는 TMP만 만들므로 non-TMP가 null인 건 설계대로다. **남은 1개 `itemRarityPalette`가 진짜 누락**: TMP 짝이 없는 유일한 참조인데 생성기가 채우지 않아, 프리팹 경로로 뜨면 직전 라운드에 만든 `Resources/ItemRarityPalette.asset`이 무시되고 하드코딩 폴백으로 되돌아간다(절차 생성 경로는 Bootstrap이 주입 중이라 현재는 무해). `PlayUIPrefabGenerator`에 `Resources.Load` 1줄 추가로 두 경로를 일치시켰다. 컨벤션상 `public` 필드 90개는 `unity-csharp.md` 위반이지만 인스펙터 배선 홀더의 정당한 예외라 보고만 하고 수정하지 않았다(audit는 컨벤션 자동 수정 금지). 큐 소진 → `audit_candidates.py`로 12건 재생성. +- 2026-07-31: NpcVisualBuilder (score 0 표면 점검) — clean, P0/P1 0건. **암묵 계약 2개가 핵심**이라 파일 밖 상대편까지 대조: (1) `NpcWalkAnimator`가 `root.Find`로 캐시하는 8개 노드명(ArmL/ArmR/LegLPivot/LegRPivot/Body/HeadPivot/NetHandle/NetRing)이 `Build`의 생성명과 전부 일치하고 **모두 root 직계 자식** — `Find`는 직계만 보므로 이 조건이 깨지면 애니메이션이 조용히 죽는데 충족됨. `BuildLeg`의 `$"Leg{side}Pivot"` 보간까지 확인. NetHandle/NetRing은 아이만 생성되고 애니메이터가 null 가드. (2) 머티리얼 생명주기 — 스폰 3경로(`SpawnVillager`/`SpawnKid`/`SpawnStoryNpc`)가 각각 VillagerNpc/CatcherKidNpc/**VillagerNpc 재사용**이라 셋 다 `OnDestroy`의 `CleanupMaterials`로 커버(스토리 NPC 누락 없음). **`OnDisable`이 아니라 `OnDestroy`에 건 것이 정확** — `NpcManager`엔 `Destroy` 호출이 아예 없고 수 캡·컬링을 `SetActive` 토글로만 처리하므로, OnDisable이었다면 NPC가 꺼질 때마다 머티리얼이 파괴돼 재활성화 시 마젠타가 됐을 것. `CleanupMaterials`의 자식 렌더러 전수 스캔은 현재 안전(`CatcherKidNpc`가 `targetInsect`를 참조만 하고 `SetParent`하지 않아 풀 곤충의 sharedMaterial이 딸려 들어갈 경로 없음). `PlayerVisualBuilder.SafeDestroyMat`의 "사용 중이면 스킵"을 안 따른 것도 정당 — 플레이어는 영구 객체라 의상 재빌드 때 캐시를 버리지만 NPC판은 파괴 시점 전용. 팔레트 하드코딩 인덱스 15개 전부 범위 내, `hairStyle` 0~2가 `BuildHair` switch(case 1/2 + default)와 정합. OnGUI/Update·이벤트·싱글턴 없음, 세이브 무관(seed 기반 결정적 재생성). 파일 수정 및 자체 발견 회귀 0건. +- 2026-07-31: NpcDialogueDatabase (score 0 표면 점검) — clean, P0/P1 0건. **리전 키 정합이 핵심**: `RegionLines`의 7개 키(meadow/pond/forest/swamp/mountain/garden/ruins)가 `RegionManager`의 진행 체인 리전 ID와 정확히 일치 — 누락 리전이 조용히 폴백 대사만 말하는 품질 저하 없음. `StableHash`는 FNV-1a(string.GetHashCode의 런타임 변동 회피, 결정적 시드 유지), `Mod`는 음수·length 0 가드 완비라 `h >> 4`/`h >> 8` 산술 시프트의 음수 결과도 안전. `GetLines`의 `new[]` 3원소 할당은 `NpcDialogueUI.ShowDialogue` 진입 시 1회(프레임 할당 아님), `GetVillagerName`은 NPC 생성 시 1회. 파일 수정 및 자체 발견 회귀 0건. +- 2026-08-03: OpeningSceneController (score 48, 큐 1순위) — **P1:2 + P2:1 처리**. 채점 근거 "프레임 할당 16"은 대부분 거짓양성(`new Rect`/`new Color`는 struct, `new GUIStyle`×6과 `new RectOffset`은 `EnsureStyles` 1회 가드 안). **진짜는 `FitFontSize`의 `new GUIContent`** — GUIContent는 class라 GC 할당이고, 문구 3개가 전부 `const`인데도 매번 새로 만들어 `style.CalcSize`에 넘겼다. OnGUI는 프레임당 Layout/Repaint/이벤트로 여러 번 도므로 타이틀 구간(6.2~10초) 내내 프레임당 6~12개 할당 + 텍스트 실측 6~12회. `static readonly GUIContent` 3개로 승격하고 fit 결과를 `(minSide, availableWidth)` 키로 메모이즈해 화면 크기가 바뀔 때만 재측정하도록 했다. **P1-2**: `OnDestroy`가 `StopOpeningAudio` 없이 `Resources.UnloadAsset(openingTheme)`을 호출 — replay가 완주/스킵으로 끝나면 `OnSequenceCompleted`가 먼저 멈추지만 **중단(Destroyed) 경로**에선 AudioSource가 클립을 문 채로 언로드된다(coordinator의 `OnDisable`/`OnDestroy` → `CleanupImmediately`). 언로드 앞에 `StopOpeningAudio()` 추가. **P2 처리**: `UpdateAudioVolume`이 매 프레임 `PlayerPrefs.GetFloat`을 치던 것을 재생 시작 1회로 캐시(페이드 반영은 매 프레임 유지) — cold start엔 설정 UI가 없고 replay 중엔 PlayScene UI 루트가 비활성이라 재생 중 변경 경로가 없다. **거짓양성/무해 확인**: `activeInputHandler: 0`이라 레거시 `Input` 정상, 오프닝 이미지 6장·테마 wav 전부 존재, OpeningScene에 자체 AudioListener가 있어 replay 중 `gameplayListener` 비활성화에도 무음 아님, `MasterVolume` 키는 계정 스코핑 없이 AudioManager/SettingsPanel과 동일하고 `AudioListener.volume`을 건드리는 코드가 없어 이중 적용 없음, `OpeningReplayCoordinator` AutoWire는 `PlaySceneBootstrap:349`에 배선됨, `ApplyKenBurns`의 UV는 항상 coverUv 안에 갇힘. 검증: error CS 0, ci_check 통과, PlayMode 198/198(OpeningSequenceTests 17건 포함). 변경이 전부 OnGUI 렌더/생명주기라 `rules/testing.md`상 신규 테스트 대상 아님. +- 2026-08-03: UISurface (score 3, 이번 세션 UI 개편으로 신설된 공용 서피스) — **P1:1 처리 + P2:3 보고**. 채점 근거 "싱글턴 참조 3"은 거짓양성(`UITheme.Instance`는 `Resources.Load` 실패 시 `CreateInstance` 폴백이라 null 불가). **진짜 결함은 스타일 캐시의 키 만드는 방식**: `ChipStyle`/`StatLabelStyle` 등 6개가 `UIHelper.CachedStyle("surf_chip_" + fontSize, () => new GUIStyle(…){ fontSize = fontSize })` 형태였다. 이 프로젝트의 다른 7개 호출부(`CharacterOutfitUI`)는 전부 **상수 키 + 무캡처 람다**라 컴파일러가 델리게이트를 정적 재사용해 공짜지만, 여기는 키를 폰트 크기로 결합하고 람다가 `fontSize`를 캡처해 **캐시 적중이어도 호출마다 문자열 1 + 클로저 1 + 델리게이트 1 = 3개를 할당**한다. `Dictionary` 하나 + `(kind << 16 | size)` 키로 통합해 적중 경로를 무할당으로 만들고, 6개 스타일의 정렬·굵기·색을 `SurfaceText` enum switch로 1:1 이관(원본과 대조 확인). 폰트 크기는 Rect 높이 파생이라 안전 영역이 극단적으로 좁으면 0·음수가 들어올 수 있어 `Mathf.Clamp(1, 400)` 추가. **현재는 잠복 결함**이다 — `Chip`/`StatRow`/`Header` 호출부가 아직 0이라 실행 경로에 없지만, `rules/ui-layout.md`가 이 셋을 칩·스탯행·헤더의 표준 API로 명시하고 있어 첫 채택 화면이 그대로 프레임 할당을 물려받는다. **거짓양성/무해 확인**: 실제 hot path인 `Rounded`/`Card`/`Button`은 적중 시 `MakeKey`(struct) + long 딕셔너리 조회뿐이라 무할당이고, 호출부 46곳의 색 인자를 전수 확인한 결과 전부 토큰·등급색·상수 Lerp 파생이라 캐시 키가 유한하다(애니메이션 색 없음 → 리베이크 폭주 없음). `MakeKey`는 색 32비트를 8비트 왼쪽 시프트 후 반경을 OR해 충돌 없음. 둥근 알파 생성식은 반경 3/8/12 전부에서 `size = radius*4`가 딱 맞아 중앙 불투명·모서리 투명이 정확. **P2 보고(미처리)**: ①상한 256 초과 시 `Clear()`만 하고 텍스처를 파괴하지 않아 사이클마다 ~2MB 고아 발생(`GetRoundedStyle` 외부 호출이 0이라 파괴해도 안전하지만, 상한에 닿지 않는 현재는 투기적 변경) ②`Rounded`가 `GUI.color`를 흰색으로 덮어 호출부의 앰비언트 페이드를 무시(현재 `AnimatePanelOpen` 사용처가 `CharacterOutfitUI` 하나뿐이고 그건 UISurface 미사용이라 미발현) ③`HospitalUI:151`의 8px 액센트 바가 `ui-layout.md`의 "높이 ~8px 이하는 각진 채로" 규칙과 어긋남. 검증: error CS 0, ci_check 통과, PlayMode 198/198. 변경이 OnGUI 스타일 캐시라 `rules/testing.md`상 신규 테스트 대상 아님. +- 2026-08-03: RaidRoundResolver (score 0 표면 점검) — **clean P0/P1, P2:1 보고**. 순수 정적 리졸버라 OnGUI/Update·이벤트·싱글턴·AutoWire·세이브가 애초에 없고, 328줄이라 분해 대상도 아니다. 실질 점검은 **1v1(`InsectBattleController`)과의 스킬 효과 계약 대조**. **P2(미처리, 사용자 판단 대기)**: `BuffAttack`/`DebuffAttack`/`DefenseBuff`가 레이드에서 **영구 누적**된다. 1v1은 `AddEffect(…, skill.effectDurationTurns, …)`로 만료되는 효과 목록을 두고 `RecalculateBonuses()`가 매 턴 `AttackBonus = 효과합 + 의상 + 아이템`으로 **덮어쓰지만**, 레이드엔 그 목록도 재계산도 없어 `+=`한 값이 전투 끝까지 남고 쓸 때마다 쌓인다 — `effectDurationTurns`는 Battle 폴더 전체에서 레이드 경로가 단 한 번도 읽지 않는다(PoisonDot 총량 계산에만 쓰임). 영향이 큰 쪽은 디버프다: `break`(DebuffAttack, effectValue 0.3, 쿨다운 3)를 **세 번** 쓰면 `Mathf.Clamp(1f + AttackBonus, 0.3f, 3f)`의 하한에 닿아 보스 공격력이 남은 라운드 내내 30%에 고정된다. 구조적 원인은 `RaidBattleController:111-113`이 레이드 시작 시 같은 필드에 의상·아이템 보너스를 넣어둬서 **장비 기준값과 전투 버프가 한 필드에 섞여 있다**는 것 — 버프만 되돌릴 수 없다. 고치려면 지속턴 상태와 장비/버프 분리가 필요해 밸런스 결정이 따르므로(agent-coordination.md상 game-designer 영역) 자동 수정하지 않고 **divergence를 코드 주석으로 명시**만 했다. **거짓양성 4건**: ①`damageBySlot` stale 우려 — `RaidRoundResult` 생성자가 라운드마다 `new int[teamSize]`로 새로 잡아 무해 ②보스 시그니처가 비-Damage면 회복도 피해로 처리될 우려 — 생성부(`PlaySceneBootstrap:3340`)가 항상 `SkillEffectType.Damage`라 도달 불가 ③`case Stun: return result;`가 무동작 — 실제 기절은 `RaidBattleController:191-192`가 적용하므로 정상 ④PoisonDot·Stun이 레이드에서 명중 판정을 안 함 — 저명중 스킬은 `storm`(0.9)·`nova`(0.85) 둘뿐이고 둘 다 `Damage` 타입이라 현재 도달 불가. `SetBossDamage`(`RaidRoundModels`)는 호출부 0인 dead code로, 다음 라운드 대상 파일이라 그쪽에서 다룬다. 검증: error CS 0, ci_check 통과, PlayMode 198/198. +- 2026-08-03: RaidRoundModels (score 0 표면 점검) — **P1:1 처리**. 순수 데이터 모델이라 checklist 7항목은 해당 없음. 실질 점검은 **모델↔컨트롤러 집계 계약**. **P1**: `SetBossDamage(slot, damage)`가 `BossDamageBySlot[slot]` 기록과 `TotalDamageToTeam` 가산을 **한 번에** 하는데, 실제 경로는 `RaidRoundResolver.ResolveBossIntent`가 배열에 직접 쓰고 `RaidBattleController:271`이 배열을 훑어 따로 합산한다. 호출부가 0이라 지금은 무해하지만, 이름만 보면 이게 정석 API라 나중에 누가 쓰는 순간 팀 피해가 두 배로 잡힌다 — 삭제하고 `BossDamageBySlot`에 "이런 세터를 만들지 말 것"과 그 이유를 주석으로 남겼다(리졸버가 결과 모델을 모르게 두는 의도적 분리라 순수 배열로 테스트된다). **거짓양성 2건**: ①`AddTeamAction`의 `UniteSlotDamages[slot] = ` 대입이 누적(`+=`)이 아니라 덮어쓰기지만, `ResolveUniteCommand`가 슬롯당 1회만 호출하고 일반 라운드는 `isUnite:false`라 배열이 null이므로 이중 기여 경로가 없다 ②`TotalDamageToTeam`의 `internal set`을 컨트롤러가 `+=`로 쓰지만 `ResolveBossResponse`가 `IsAwaitingBossResponse` + roundStage로 재진입을 막아 이중 가산 불가. 검증: error CS 0, PlayMode 219/219. +- 2026-08-03: CaptureChanceCalculator (score 0 표면 점검) — **P1:1 처리**. 순수 정적 계산기라 checklist 7항목은 해당 없음. 실질 점검은 **같은 규칙을 구현한 두 계산기의 대조** — 미니게임 경로(`CaptureChanceCalculator`)와 전투 경로(`BattleCaptureChanceCalculator`). 기본값·floor·ceiling 차이는 의도된 설계지만(미니게임 0.60/등급별 floor, 전투 0.90/0.10~0.95), **레벨 차 상한만 전투 쪽에만 있었다**(`MaximumLevelDelta = 5`, 전투 테스트가 `GetLevelModifier(99,1)`/`(1,99)`로 고정까지 해둠). 미니게임 쪽은 무제한이라 `diff × 0.02`가 그대로 더해졌다. **실제로 도달하는 조합이다**: `InsectSpawner`가 메인 필드를 리전 요구레벨과 무관하게 **항상 Lv.1부터** 지수 분포로 스폰하므로, Lv.40 플레이어가 Lv.2 전설을 만나면 +38×0.02 = **+0.76**이 붙어 등급 페널티(0.32)와 난이도 페널티(0.40)를 통째로 상쇄하고 1.0에 닿는다 — 주 포획 경로에서 등급·난이도 설계가 고레벨 구간에 무력화됐다. 전투 경로와 같은 `MaximumLevelDelta = 5`로 clamp. **기존 테스트 무영향 확인**: 최대 델타를 쓰던 `Calculate_CoreBelowFloor_PositiveBonusesAreNotSwallowed`(1 vs 50)는 상한 적용 후에도 코어가 여전히 floor 아래라 결과가 동일하고, 나머지는 전부 ±5 안이다. 회귀 테스트 3건 추가(양방향 clamp / 두 경로 상한 일치 / Lv.40 vs Lv.2 전설이 1.0에 닿지 않음). `rules/balance.md`의 포획 기준점에 상한과 그 이유 기록. 검증: error CS 0, PlayMode 222/222. +- 2026-08-03: QuestRewardFormatter (score 0 표면 점검) — **P1:2 처리**. 순수 함수 모음이라 checklist 7항목은 해당 없음. 이 파일의 존재 이유가 "표시 조건 = 지급 조건"이므로 **`TutorialQuestManager.GrantRewards`와 1:1 대조**가 실질 점검이었다. 포함 조건 4개(candy>0 / exp>0 / itemId∧count>0 / insectId)는 **정확히 일치**했다. **P1-1**: 그런데 곤충 보상의 **레벨이 표시에서 통째로 빠졌다** — `GrantRewards`는 `Mathf.Max(1, quest.rewardInsectLevel)`로 실제 레벨을 적용하는데 Label엔 종 이름만 담겼다. 퀘스트 29개를 파싱해 확인하니 `q_approach`가 `rhinoceros_beetle`을 **Lv.6**으로 주는데 완료 배너엔 "장수풍뎅이"로만 떴다(옛 아이템 누락 버그와 같은 형태의 축소판). `Lv.N` 접미사를 붙이되 1 이하는 생략(지급도 1이라 동치). **P1-2**: `HasAny`가 4개 포함 조건을 **그대로 복사**해 갖고 있었다 — 조건을 한 곳에 모으려고 만든 파일 안에서 조건이 두 벌이던 셈이라, 한쪽만 고치면 "받았는데 안 보이거나 / 보이는데 안 주는" 어긋남이 그대로 재발한다. `HasCandy`/`HasExp`/`HasItem`/`HasInsect` 술어로 뽑아 `Collect`와 `HasAny`가 공유하게 했다(무캡처 static이라 할당 없음). **거짓양성/무해 확인**: `Format`의 문자열 결합은 호출부 2곳이 모두 1회성이라 프레임 할당이 아니다(`OnQuestCompleted`는 이벤트, `GetRewardText`는 questId 캐시 — 반복 서브퀘스트도 보상 정의는 불변이라 캐시 유효). `formatBuffer` 정적 재사용은 resolver가 재진입하지 않는 한 안전(현 resolver는 DB 조회만). 데이터 검사: 29개 퀘스트 전부 `rewardItemId`↔`rewardItemCount` 짝이 정합(한쪽만 채워 조용히 증발하는 건 없음). 회귀 테스트 3건 추가(레벨 표시 / 1 이하 생략 / HasAny↔Collect 일치). 검증: error CS 0, ci_check 통과, PlayMode 225/225. +- 2026-08-03: DexBrowseLayout (score 0 표면 점검) — **P1:1 처리 + P2:1 보고**. 순수 계산 헬퍼라 checklist 7항목은 해당 없음. 계산부 자체(`WrapIndex` 감김·무선택 진입, `GetGridColumns`의 `floor((w+gap)/(card+gap))`, 높이 2종)는 **전부 정확**했고 `DexScreenUI:591`의 `cardWidth = (contentWidth - (columns-1)*gap)/columns`가 그 부등식의 정확한 역이라 정합했다. **문제는 호출부였다** — `DexScreenUI`의 ↑↓ 키가 `SelectRelative(±1)`을 부른다. 도감이 **좌측 1열 리스트**였을 땐 ±1이 곧 한 행이었지만, 이번 세션에 전체 폭 그리드로 개편하면서 ±1이 '옆 칸'이 됐다. 6열 기준으로 ↓를 **여섯 번** 눌러야 한 줄 내려가고, 그동안 `ScrollToSelected`가 `selectedIndex / lastListColumns`로 행을 계산하므로 스크롤은 제자리였다 — 키보드 이동이 화면과 어긋난 상태. 행 단위 상수(`lastListColumns`, 이미 필드로 존재하고 `ScrollToSelected`가 쓰던 값)를 delta로 넘기도록 수정하고 ←→는 ±1 유지(그리드에서 좌우는 옆 칸이 맞다). 회귀 테스트 2건 추가(행 이동이 열을 보존하는지 / 마지막 행에서 감길 때 범위 이탈이 없는지). **거짓양성 확인**: `WrapIndex`가 도는 목록이 `database.insects` 전체인데 그리드도 필터 없이 같은 목록을 그리므로 불일치 없음(`DexSortMode`/`DexFilterMode` PlayerPrefs는 uGUI 경로인 `DexListUIController` 소유라 IMGUI `DexScreenUI`와 무관). 키 처리는 `currentTab == 0` 가드 안이라 보유·아이템 탭에서 도감 선택이 움직이지 않음. **P2 보고**: `FormatElementLabel`이 호출부 0(UI는 `DrawElementBadges`로 배지 2개를 따로 그린다) — `ShouldShowSecondary`를 공유해 동작은 정확하므로 함정은 아니고 단순 미사용. 또 보유 탭(`DexScreenUI:1116`)이 `GetGridColumns` 대신 `floor(panelW/410f)`로 열 수를 따로 구한다 — 자체 정합(넘침 없음)이지만 같은 질문에 대한 두 번째 답이다. 검증: error CS 0, ci_check 통과, PlayMode 254/254. +- 2026-08-03: OpeningReplayCoordinator (score 0 표면 점검) — **P0:1 + P1:2 처리**. 코디네이터 자신은 방어가 촘촘했다(timeScale 복원이 정상·실패·OnDisable·OnDestroy 4경로 전부 커버, `BeginCleanup(immediate)` 이중 가드, `ModalUIRegistry` 짝, `loadOperation.completed`가 static이라 누수 없음, `CanReplay`가 `DependenciesReady` 뒤에 역참조). **문제는 이 파일이 무엇을 촉발하는가였다** — `ApplyReplayBlock`의 `playUiRoot.SetActive(false)`가 UI 루트 아래 **41개** 컴포넌트의 OnDisable을 발화시킨다. **P0**: `BattleScreenUI.OnEnable`이 `// 구독은 AutoWire에서만 수행`이라는 주석만 든 **빈 메서드**인데 OnDisable은 `BattleUpdated`/`BattleEnded`/`PlayerFainted`를 해지한다. AutoWire는 Bootstrap 1회뿐이라, **오프닝을 한 번 다시보면 배틀 화면이 영구히 열리지 않는다**(`OnBattleUpdated`가 `Phase.Intro`를 세우는 유일한 지점). 설정에서 누구나 닿는 경로다. `RaidBattleUI`도 같은 형태(P1), `RegionMapUI`는 OnEnable 자체가 없어 `RaidBossSpawned` 마커 소실(P1). 셋 다 구독을 `Subscribe___()`로 빼 AutoWire와 OnEnable이 공유하게 했다(`-=` 뒤 `+=`라 중복 없음). **재발 방지**: 같은 계열이 `HospitalUI`(2026-07-19)까지 네 번째라 `subscription_lint.py`를 신설해 `ci_check`의 `REPO_CHECKS`에 등록했다 — Bootstrap의 `EnsureComponent("UI/...")`로 대상을 뽑고 OnDisable의 `-=`가 OnEnable(및 그 안에서 부르는 헬퍼 1겹)의 `+=`로 되살아나는지 대조한다. 주석을 제거하고 분석해 주석 처리된 구독을 오인하지 않는다. **동반 발견**: `ApplyReplayBlock`의 줄 순서가 load-bearing이었다 — `BattleScreenUI`/`RaidBattleUI`의 OnDisable에 `if (Time.timeScale < 0.99f) Time.timeScale = 1f` 슬로우모션 복구가 있어, `Time.timeScale = 0f`를 SetActive보다 **먼저** 두면 그 복구가 즉시 1로 되돌려 오프닝 뒤에서 월드가 계속 돌아간다. 현재 순서는 안전하지만 재배열 금지를 주석으로 못박았다. 검증: error CS 0, ci_check 6검사 전부 통과(신규 포함), PlayMode 254/254. +- 2026-08-03: UISafeLayout (score 0 표면 점검) — **clean P0/P1, P2:1 보고. 파일 수정 0건.** 순수 정적 계산부 + 파사드라 checklist 7항목은 해당 없음. 계산부(`Compute`/`Build`/`ClampSize`/`CenterStart`/`EndStart`)는 `UISafeLayoutTests` 10건이 마진 clamp 3경계·비대칭 인셋 중앙정렬·초과 패널·하단 앵커·옛 HospitalUI 관용구 동치까지 이미 고정하고 있다. 그래서 **실질 점검은 이 파일이 아니라 두 파사드의 사용 계약**이었다. ①**좌표계 계약 34개 파일 전수 대조** — `UISafeLayout.Px.*`를 쓰는 7개(PlaySceneBootstrap/SubAreaWorldBuilder/AccountLinkUI/CapturePopupUI/LoginUI/SaveConflictUI/WorldFieldMultiplayerUI)는 전부 `UIScale.Begin()`이 0회, 가상 파사드를 쓰는 26개는 전부 1회 이상이다. 뒤바뀌면 패널이 스케일 배수만큼 어긋난 자리에 그려지는데 그런 파일이 없다. ②**Begin/End 균형** — 텍스트 카운트로 6건이 불일치로 보였으나 전수 확인 결과 전부 정상: `UISurface`·`KeyGuideHUD`는 **주석 안의 `UIScale.Begin()` 문자열**이 잡힌 것이고(KeyGuideHUD는 오히려 "Begin 전에 return해야 균형이 유지된다"는 주석), `CashShopUI`·`PlayerStatusHUD`·`QuickAccessBarUI`·`WorldInteractionController` 4건은 조기 반환 경로마다 `End(); return;`을 붙인 형태라 모든 경로에서 짝이 맞는다. ③**가로축 하드코딩은 위반이 아니다** — `TutorialQuestUI`·`GuidedTutorialController` 등이 `VirtualScreenWidth - VirtualSafeLeft - VirtualSafeRight`로 직접 중앙정렬하지만 `ui-layout.md`가 "가로 마진은 24px 고정, 세로만 하네스로 옮겼다"고 명시하고 `ui_layout_lint`도 세로만 검사한다. **P2 보고(미처리)**: 공개 API 5멤버 `ContentLeft`/`ContentWidth`/`MarginY`/`ClampWidth`/`Overflows`가 가상·Px 양쪽 모두 **생산·테스트 호출부 0**이다(합 10개). `Overflows`와 `ContentWidth`는 `ui-layout.md`의 API 표에 실려 있어 "쓰라고 적어뒀는데 아무도 안 쓰는" 상태이고, 특히 `UISafeLayout.ContentWidth`(0회)와 `UIScale.ContentWidth(margin)`(3회)는 **같은 질문에 대한 두 번째 답**이다(둘 다 마진 24 기준이라 값은 일치). 함정은 아니라 자동 수정하지 않고 보고만 한다 — 지우려면 문서 표도 함께 고쳐야 한다. **관찰(수정 안 함)**: `Begin`/`End` 사이가 `try/finally`가 아니라, 그 구간에서 예외가 나면 `matrixStack`(static, 씬 로드로도 안 비워짐)에 항목이 남아 이후 모든 UI의 `End()`가 남의 행렬을 복원하게 된다. 현재 예외를 던지는 경로가 확인되지 않아 투기적 변경이라 손대지 않았다. +- 2026-08-03: UIDirectScroll (score 0 표면 점검) — **P1:1 처리**. 클래스 자체는 견고했다: 터치 경로가 `lastTouchFrame`으로 프레임당 1회만 처리하고, `suppressPointerFrame`으로 `Input.simulateMouseWithTouches`가 만드는 합성 마우스 이벤트를 같은 프레임에 차단하며, 손가락 유실·MouseUp 누락(Repaint에서 `Input.GetMouseButton(0)` 확인)까지 복구한다. 호출부 20곳의 `contentHeight`↔`BeginScrollView` viewRect도 전부 정합했다. **문제는 클래스에 z-order 개념이 없다는 것이었다** — IMGUI는 먼저 그린 쪽이 `Handle`도 먼저 부르는데, 이번 세션 도감 개편(좌측 1열 리스트 → 전체 폭 그리드 + 겹치는 중앙 모달)으로 `DrawPokedex`가 그리드를 그린 **뒤** 상세 모달을 겹치게 됐다. 그래서 배경 그리드가 모달 위에서 굴린 휠·드래그를 `Event.Use()`로 가로챈다. 1920×1080에서 실측하면 그리드 뷰포트 y 232~1026, 상세 뷰포트 y 164~988로 **상세 스크롤 영역의 92%가 그리드 안**이라 위쪽 68px 띠에서만 상세가 움직였다 — 상세 하단 콘텐츠(설명·스탯 행)에 사실상 도달 불가. 터치는 더 나쁘다: 터치 경로는 `Event`가 아니라 `Input.GetTouch`를 직접 읽어 **소비 개념이 없어서** 두 인스턴스가 같은 손가락을 동시에 붙잡고 배경과 모달이 함께 움직인다(포트레이트 1080×1920도 동일). `Handle`에 `interactive` 매개변수를 더해 겹친 배경이 입력을 포기하게 하고, `DexScreenUI`가 `!detailModalOpen`을 넘긴다. **clamp는 비활성 구간에서도 유지**했다 — 모달이 열린 사이 배경 내용이 짧아지면 범위 밖 스크롤이 남는다(회귀 테스트가 이 순서를 고정). **전수 확인**: 같은 겹침이 다른 화면엔 없다 — `TrainingUI`는 `Page` enum(전환 시 `Reset()`), `CollectionUI`는 목록/상세 배타 분기, `WorldLobbyUI`는 `LobbyPhase` switch, `CashShopUI`·`SocialPvpUI`는 탭당 인스턴스, `RegionMapUI`는 겹치는 도감 브라우저 아래에 스크롤 영역이 없다. `DexScreenUI`의 나머지 3개도 `currentTab` 배타 + 모달은 탭 0 전용. **검증 사고 1건(자체 발견)**: `-runTests`에 `-quit`를 같이 넘겨 Unity가 테스트 전에 종료했는데 exit 0 + "Exiting batchmode successfully"라 성공처럼 보였고, 갱신되지 않은 이전 `TestResults.xml`의 254/254를 이번 결과로 잘못 읽었다. `-quit` 제거 후 결과 파일을 치우고 재실행해 확정 — `rules/testing.md`에 이 함정과 "mtime도 함께 볼 것"을 기록. 검증: error CS 0, ci_check 6검사 통과, PlayMode **255/255**(신규 1건 포함). +- 2026-08-03: OpeningSequenceState (score 0 표면 점검) — **clean, P0/P1 0건. 파일 수정 없음.** 한 파일에 순수 클래스 3개(`OpeningPlaybackClock`/`OpeningSkipInputGate`/`OpeningSequenceState`) + enum 1개가 들어 있고 MonoBehaviour가 없어 checklist 7항목은 해당 없음. **핵심 점검은 타임라인 상수 ↔ 렌더러가 읽는 파생값의 정합**이었다: 8개 상수(2.4/3.0/5.2/5.8/6.2/8.0/9.2/10.0)에 대해 `Phase`·`CurrentImageIndex`·`NextImageIndex`·`ImageBlend`가 전 구간에서 어긋나지 않는다 — GlowCrossFade(2.4~3.0)에서 current 0·next 1, Horizon(3.0~5.2)에서 next -1, HorizonCrossFade(5.2~5.8)에서 current 1·next 2, Gathering 이후 current 2로 `openingImages[3]` 범위를 벗어나지 않는다. `Complete`는 `completed` 가드 + 핸들러 지역 캡처로 이중 발화·핸들러 재진입 모두 안전하고, `TrySkip`이 `skipFadeStartAlpha`에 현재 페이드를 담아 자연 페이드 중 스킵해도 화면이 도로 밝아지지 않는다. `OpeningPlaybackClock`은 `MaxFrameDelta` clamp(로딩 stall을 재생시간에서 제외)·시각 역행 방어·NaN/Infinity 차단이 완비돼 `Advance`에 NaN이 도달할 경로가 없다(도달하면 `Math.Min(Duration, NaN)`이 NaN이 돼 영구 미완료가 되지만 유일한 호출부가 이 시계다). `OpeningSkipInputGate`는 시작 시 누르고 있던 입력이 스킵으로 오인되지 않도록 중립 프레임을 요구하는 전이가 정확하다. **테스트가 상수를 심볼로 참조**해(`StateAt(OpeningSequenceState.GlowCrossFadeStart)`) 상수 순서를 뒤집으면 실제로 깨진다 — 하드코딩 수치였다면 못 잡았을 회귀를 이미 막고 있다. **관찰(수정 안 함)**: `OpeningVisualPhase`/`Phase`는 생산 코드 호출부가 0이고 테스트 단언 표면으로만 쓰인다 — 렌더러는 이미지 인덱스·알파를 직접 읽는다. 타임라인을 읽기 쉽게 단언하려는 정당한 용도라 dead code로 보지 않았다. 자체 발견 회귀 0건. +- 2026-08-03: UITween (score 0 표면 점검) — **P1:1 처리**. 순수 정적 트윈 유틸이라 checklist 7항목은 해당 없고, 이징 곡선 5종은 전부 0→0·1→1을 만족했다(`EaseOutBounce`의 마지막 구간 `7.5625*(1-2.625/2.75)^2 + 0.984375 = 1.0`까지 손계산 대조). **문제는 시간을 어디서 재는가였다** — `Evaluate`가 호출될 때마다 `h.elapsed += Time.unscaledDeltaTime`을 더하는데, 유일한 소비자가 `CharacterOutfitUI.OnGUI` → `UIHelper.AnimatePanelOpen` 경로다. **OnGUI는 프레임당 여러 번 돈다**(Layout 1 + Repaint 1 + 입력 이벤트마다 1). 그래서 `Create(..., 0.2f)`로 만든 패널 페이드가 최소 2배 빨랐고, 마우스를 움직이면 MouseMove 패스가 늘어 **더** 빨라졌다 — 프레임률·입력량에 따라 속도가 달라지는, 사실상 페이드가 없는 상태. `TweenHandle`에 `lastFrame`을 추가해 프레임당 한 번만 전진하게 했다. `Event.current.type == Repaint`로 거르는 IMGUI 관용구를 쓰지 않은 건, 이 유틸이 OnGUI 밖에서도 동작해야 하고 Repaint 횟수가 보장되지 않기 때문이다(`Create`/`Reset`도 `lastFrame`을 찍어 생성 프레임에 시간이 앞서가지 않게 한다). **방어적 보강**: `duration`이 0이면 `0/0 = NaN`이고 NaN은 `t >= 1f`가 거짓이라 `active`가 영영 안 풀린 채 NaN을 계속 뱉는다 — `Create`는 clamp하지만 `TweenHandle`이 public 필드를 가진 serializable struct라 직접 생성 경로가 열려 있어 `Evaluate`에도 clamp를 뒀다(현재 도달 경로는 없음). **테스트 신설**: `ApplyEase`를 `internal Ease`로 열어 이징 5종의 양 끝점 계약을 `[TestCase]`로 고정했다 — 깨지면 트윈이 시작·끝에서 튀는데 지금까지 어디에도 고정돼 있지 않았다. `EaseOutBack`이 1을 넘는 구간을 갖는다는 것도 함께 고정했다(그래서 `Evaluate`가 `Lerp`가 아니라 `LerpUnclamped`를 쓴다 — 클램프하면 이 곡선이 죽는다). 같은 프레임 반복 호출이 `elapsed`를 한 번만 늘리는지도 검증. **세이브 무관 확인**: `lastFrame` 필드가 늘었지만 `TweenHandle`은 `[SerializeField]`도 JSON 직렬화도 없다(`CharacterOutfitUI`의 private 필드 1개뿐). 검증: error CS 0, ci_check 6검사 통과, PlayMode 265/265(신규 11케이스 포함). +- 2026-08-03: InsectBattleStats (score 0 표면 점검) — **clean, P0/P1 0건. 파일 수정 없음.** 순수 계산 클래스라 checklist 7항목은 대부분 해당 없다. 실질 점검은 **두 보너스의 소비 지점 대조**였다: `DefenseBonus`는 `ApplyDamage` 안에서 `effDef = def * (1 + DefenseBonus)`로 **한 번만** 소비되고, `AttackBonus`는 호출부 3곳(`InsectBattleController:403`, `RaidRoundResolver:215/245/321`)에서 데미지를 만들 때만 곱해진 뒤 `ApplyDamageAndMeasure`가 다시 곱하지 않는다 — 겹치는 경로가 없다. 스택 카운터는 부호 있는 값이라 상한(±3)에 닿아도 **반대 방향 전환은 항상 통과**해 되돌릴 수 있다. `MaxHp`/`Attack`/`Defense`의 `protected set`은 `RaidBattleController:613`의 `RaidBossStats` 상속 때문이라 정당하고, 그 서브클래스가 기반 생성자 이후 스탯을 덮어쓰므로 `ResetHp()`를 부르는 순서도 맞다(기반 생성자가 옛 MaxHp로 CurrentHp를 잡기 때문). `effDef`가 음수가 될 여지는 `DefenseBuff`의 effectValue가 +0.35뿐이라 도달 불가이고, 그래도 `Mathf.Max(1f, effDef)` 가드가 있다. 기절(0 HP) 곤충의 전투 진입은 `InsectBattleController:74/100`이 양 경로 모두 막는다. +- 2026-08-03: InsectSizeCalculator (score 0 표면 점검) — **clean, P0/P1 0건. 파일 수정 없음.** 순수 정적이라 checklist 해당 없음. `RollFromInstanceId`의 FNV-1a가 `hash < 0 ? -(long)hash : hash`로 `Abs(int.MinValue)` 오버플로를 피하는 것, `EffectiveRoll`이 `-1`(구세이브 센티넬)을 instanceId 해시로 결정적 복원하는 것, `ScaleFor`가 범위 밖 롤을 `Clamp01`로 눕히는 것을 확인. 무게가 길이 배율의 **세제곱**이라 "조금 큰데 훨씬 묵직하다"가 성립한다. 라벨 포맷 경계(10g/1000g/100mm)도 대조. `ToString("0.0")`이 culture 의존이지만 표시 전용이고 저장·파싱 경로가 없어 무해(로케일에 맞는 소수점이 오히려 정상). +- 2026-08-03: WeeklyContestSchedule (score 0 표면 점검) — **clean, P0/P1 0건. 파일 수정 없음.** 주차 경계(`604800` 전후), 음수·0 시각의 눕힘, `IsWithinWeek`가 구세이브 `capturedUnix = 0`을 항상 false로 거르는 것, `WeekStartUnix`의 int×long 승격에 오버플로가 없는 것을 확인. `BuildPool`이 `rarity > Uncommon`을 걸러내고 `CompareOrdinal`로 정렬해 **기기·재설치와 무관하게 같은 주에 같은 종**이 나온다(서버 없이 동기를 맞추는 유일한 방법). `TargetFor`는 `Mathf.Abs(weekIndex) % Count`라 음수 인덱스가 불가능하고 빈 풀은 null 폴백. +- 2026-08-03: WeeklyContestManager (score 0 표면 점검) — **clean, P0/P1 0건. 파일 수정 없음.** 이벤트는 `InsectCaptured`(포획 전용)를 구독한다 — `InsectUpdated`였다면 치료·레벨업·진화에도 울려 기록 토스트가 오발화했을 자리이고, StoryDirector가 같은 이유로 이미 옮겨간 이벤트다. **World 루트 소속이라 UI 루트 SetActive 토글과 무관**하므로 `AutoWire`↔`OnDestroy` 페어링이 정확하다(`subscription_lint`의 검사 대상도 아니다). PlayerPrefs 값이 `"주차:등급"` 문자열이고 파싱이 `int.TryParse`뿐이라 **culture 의존이 없다**(소수점 포맷이 끼어들 여지가 없음). `ResolveTarget`이 주차 캐시로 128종 정렬을 조회마다 돌리지 않고, `TutorialQuestUI`가 OnGUI에서 `WeeklyContestTarget`을 읽어도 캐시 적중 경로만 탄다. **관찰(수정 안 함)**: 풀이 비면(`cachedTarget == null`) 매 조회마다 재정렬하는데, `InsectDatabase`에 Common이 반드시 있어 도달하지 않는다. +- 2026-08-03: NpcDuelController (score 1, 큐 1순위) — **P1:1 처리 + 방어 1건**. 채점 근거 "싱글턴 참조 1"은 거짓양성(`TutorialQuestManager.Instance?.NotifyNpcDuelWon()` — null 조건 연산자). **P1: 도주하면 대결 쿨다운이 통째로 우회된다.** `DuelEnded`는 `CheckEnd`의 승리(`InsectBattleController:718`)·전멸패배(`:751`) 두 경로에서만 발화하는데, `TryEscape`(`:229` 부근)는 `battleEnded = true; BattleEnded?.Invoke(false)`만 하고 끝난다. 그래서 도망치면 `OnDuelEnded`가 안 돌아 `kid.MarkDuelFinished`가 호출되지 않고, **같은 아이에게 즉시 재도전**할 수 있다 — `DuelCooldownSeconds` 주석이 "결과와 무관하게 … 연속 파밍 차단"이라고 설계 의도를 못박아 두었는데 정작 한 결과가 빠져 있었다. 도주 성공률이 레벨차에 따라 50~90%라 쉽게 닿는 경로다. `TryEscape`에 `if (duelMode) DuelEnded?.Invoke(false);` 추가(승리/패배와 같은 형태). 부수 효과로 `activeKid`가 도주 후 남아 있던 것도 함께 해소된다. **방어 보강**: `EnsureDuelInsect`가 `Mathf.Abs(StableHash(kid.NpcId))`로 시드를 만드는데 `Abs(int.MinValue)`는 그 자신(음수)이라 `pool[음수]`로 즉사한다 — 같은 저장소의 `InsectSizeCalculator.RollFromInstanceId`와 `NpcDialogueDatabase.Mod`가 이미 쓰는 방어인데 여기만 빠져 있었다(확률은 2^-31이지만 한 줄이고 하우스 패턴이 이미 존재). 순수 함수 `PoolIndexFor(npcId, poolLength, attempt)`로 분리해 long 양수화 + 빈 풀·null ID 폴백을 넣고 테스트 3건으로 고정. **거짓양성/무해 확인**: `AutoWire`의 `DuelEnded` 구독은 `battleController == null` 가드 안이라 이중 구독 불가, `activeRarity`는 `TryStartDuel` 시점 스냅샷이라 대결 중 아이 상태가 바뀌어도 보상 등급이 흔들리지 않는다, `FindPlayerLeader`가 팀·보유 양쪽에서 기절 곤충을 거른다. 검증: error CS 0, ci_check 6검사 통과, PlayMode 268/268(신규 3케이스 포함). +- 2026-08-03: InsectBattleController **재감사** (2026-05-20 최초 감사, 이후 StartDuel·버프 상한·DuelEnded 유입) — **P1:1 처리**. 이 라운드는 `audit_candidates.py`가 "후보 0건"을 낸 상태에서 시작했다 — 스크립트가 `if stem in reviewed: continue`로 **이름 기반 영구 제외**를 하기 때문이다("처리했는데 그 뒤로 바뀐 파일" 개념이 없다). 실측하니 감사 이후 수정된 파일이 **76개**였고, 그중 구조 변경이 가장 큰 이 파일을 1순위로 잡았다. **P1**: `RecalculateBonuses()`가 `AddEffect`(510)·`TickEffects`(558)·`SwapPlayerInsect`(853) 세 곳에서만 불리고 **전투 시작 경로에는 없었다**. `InsectBattleStats` 생성자가 `AttackBonus/DefenseBonus = 0`으로 두므로, `UseSkill`/`UseBasicAttack`의 순서(데미지 계산 → 적 반격 → `TickEffects`)상 **첫 턴에 의상·아이템 전투 보너스가 통째로 빠진다** — 플레이어의 첫 공격은 `GetDamage`의 `1 + AttackBonus`가 1.0이 되고, 적의 첫 반격은 `ApplyDamage`의 `def × (1 + DefenseBonus)`를 못 받는다. 야생 전투가 대개 2~4턴이라 소모품으로 산 효과의 큰 몫이 증발한다. **발현 조건이 결정적 증거였다**: `SeedPersistentState`가 **감염된 곤충일 때만** `AddEffect`를 부르고 그게 `RecalculateBonuses`를 태우므로, 독에 걸린 곤충만 첫 턴부터 보너스를 받았다(건강하면 못 받음). **교차 대조**도 같은 방향 — `SwapPlayerInsect`는 이미 `RecalculateBonuses()`를 부르고(교체 곤충은 정상), 레이드는 `RaidBattleController:111-113`이 시작 시 직접 대입한다(정상). 1v1 시작 경로만 빠져 있었다. `BeginBattleCommon` 끝에 호출 추가(`outfitBonus`/`itemEffects` null 가드가 메서드 안에 이미 있어 AutoWire 전 호출도 안전). **거짓양성/무해 확인**: `duelMode`는 `StartBattle`/`StartDuel` 양쪽이 명시 대입해 이전 대결 상태가 새지 않고, `BeginBattleCommon`이 `enemyEntity` 대입 **전에** 도는 것도 의도대로다(호출부가 뒤에 채운 뒤 이벤트를 울려야 `BattleScreenUI.OnBattleUpdated`의 `GetEnemyEntity()`가 유효하다). `CheckEnd`의 듀얼 분기는 포획 롤·야생 드랍을 정확히 건너뛴다. **테스트**: 호출 순서 문제라 씬 없이 검증 불가 — `rules/testing.md`의 MonoBehaviour 생명주기 제외 대상이고, 배율 공식 자체는 `BuffStackCapTests`가 이미 고정한다. 검증: error CS 0, ci_check 6검사 통과, PlayMode 268/268. +- 2026-08-03: PlaySceneBootstrap **재감사** (2026-05-27 감사 이후 2107줄 변경, 새 재감사 큐의 1순위) — **clean P0/P1, 파일 수정 0건.** 4913줄을 줄줄이 읽는 대신 **계약을 기계적으로 대조**했다(감사 이후 바뀐 부분이 부트스트랩의 배선 계약을 깼는지가 이 파일의 실질 위험이다). ①**EnsureComponent 87경로/102생성** — 서로 다른 타입이 같은 경로를 쓰는 건 `World/CharacterOutfit`(CharacterOutfitManager + OutfitBonusProvider) 하나뿐이고 매니저와 그 보너스 제공자를 한 GameObject에 얹는 정상 패턴이다. ②**AutoWire 오버로드 커버리지** — 생성 타입 88개 중 정의됐는데 그 인자수로 안 불리는 건 2건뿐이고 둘 다 무해: `CharacterOutfitUI.AutoWire(manager)`는 2인자 오버로드(`AutoWire(manager, bonus)`, :466)로 이미 배선되고, `ItemRarityTuningUIController.AutoWire(palette)`는 리플렉션 주입을 쓰는 개발용 잔재다. ③**리플렉션 주입 154건 전수 검증** — `GetType().GetField("x")?.SetValue()`는 필드명이 어긋나도 **컴파일·런타임 모두 조용하다**(PlayUIRefs 라운드가 잡았던 결함 계열). 대상 타입의 실제 필드 목록과 대조해 **미해석 0건** 확인. ④**Resources.Load** — `ItemDatabase`가 애셋 부재로 항상 null이지만 바로 다음 줄 `if (itemDatabase == null) itemDatabase = ItemDatabase.CreateRuntimeDefault();`가 받는다(런타임 팩토리가 실제 단일 출처). ⑤**부팅 canary** — 2026-07-19 P0(`MaxEquipSlots`를 `MaxLearnedSkills`로 오검사해 부팅 차단)가 재발하지 않았고, 단언하는 두 불변식(`MaxEquipSlots == 4`, Leaf→Water 상성 > 1)이 실제로 성립한다. 개별 곤충 데이터 결함은 throw가 아니라 로그+보정이라 한 종의 회귀가 게임 전체를 막지 않는다(그 P0의 교훈이 반영된 형태). ⑥**이번 세션 신규 등록 3종** — `WeeklyContestManager`(:485)·`NpcDuelController`(:565)의 AutoWire 인자가 전부 앞서 만들어지고, `ApplySizeProfile`이 `ScriptableObject.CreateInstance()` **두 경로 모두**(:2595→2610, :3008→3025)에 걸려 `baseSizeMm`을 못 받는 종이 없다(주간 대결 티어가 종 기준값에서 파생되므로 한 경로라도 빠지면 그 종의 임계가 틀어진다). ⑦**구독 시점 대 세이브 로드** — `weeklyContest`가 구독하는 `InsectCaptured`는 `AddInsectInternal`에서만 발화하고 `LoadAndIndex`(Awake)는 그 경로를 타지 않아, 세이브 로드가 보유 곤충 수만큼 포획 이벤트를 쏘는 오발화가 없다. **거짓양성 확인**: score 228의 `inst=158`(싱글턴 참조)은 전부 가드 안이다 — 유일한 무가드 후보였던 `AudioManager.Instance.PlayBGMForRegion`(:413)도 **앞줄**에 `if (region != null && AudioManager.Instance != null)`가 있다(한 줄 단위 스캔의 한계). **검사하지 않은 것**: 절차 생성 지오메트리·좌표값과 4913줄의 라인 단위 로직은 이 라운드 범위 밖이다 — 계약과 델타를 봤다. 코드 변경이 없어 테스트는 재실행하지 않았다(직전 검증 상태 유지: error CS 0, ci_check 통과, PlayMode 268/268). +- 2026-08-04: RegionMapUI **재감사** (2026-05-27 감사 이후 2048줄 변경, 새 재감사 큐 2순위) — **P1:1 처리**. 이 파일은 **score 41로 낮은데 변경량은 2위**였다 — 옛 점수 정렬이었다면 한참 아래였을 자리이고, 새 큐가 아니었다면 안 열렸을 라운드다. **P1**: `DrawDexItem`이 곤충 하나당 매 OnGUI 패스에 문자열을 새로 만든다 — 포획됨이면 `$"{data.rarity} | CP {CalculateBasePreview(...)}"`(보간 + enum 박싱 + **전투력 재계산**), 미포획이면 `new string('?', displayName.Length)`. 리전당 곤충이 20여 마리이고 OnGUI는 프레임당 Layout+Repaint+입력마다 돌며, IMGUI 스크롤뷰는 가상화가 없어 화면 밖 항목도 전부 실행된다. 둘 다 **종 데이터에서만 파생돼 세션 내내 불변**이므로 `insectId`(정보줄)와 이름 길이('???')로 캐시했다. **회귀 아님을 확인**: 처음엔 "2026-05-27 캐시 라운드(P1:28)가 7월 전면 재설계(70adc8e)로 되돌아갔다"고 의심했으나, 재설계 **직전** 버전(`70adc8e^`)을 꺼내 보니 같은 자리에 같은 코드가 있었다 — 그 라운드는 GUIStyle 28개를 다뤘고 문자열 할당은 원래 범위 밖이었다. 선재 결함이다. **거짓양성으로 판정(보고 안 함)**: `Label()` 헬퍼가 `wordWrap = false`를 전역으로 걸어(지도 핀 라벨은 1줄이 맞다) **지역 설명**까지 한 줄로 그린다. 방금 고친 글씨 잘림과 같은 계열로 보였으나 실측하면 설명 최대 38자 ≈ 912px에 박스가 972px(세로 모바일)이라 여유 7%로 들어간다 — 지금 잘리지 않으므로 보고하지 않는다. 다만 `UIHelper.LabelFit`은 `CalcHeight` 기반이라 **`wordWrap=false`의 가로 넘침을 못 잡는다**(폭 기준 축소는 `CalcSize().x`가 필요) — 설명이 한 줄만 길어지면 조용히 잘리고 헬퍼도 막지 못한다. 별건 판단 대상. **나머지 checklist**: `OnEnable`/`OnDisable`이 `RaidBossSpawned`를 해지-후-구독으로 짝 맞추고(이번 세션 P0 수정분), `EnsureAssets`가 `ready` 가드로 GUIStyle 26개+disc 텍스처를 1회만 만들며, `GetPlayer()`는 최초 1회만 Find한다. 렌더 경로에 남은 힙 할당은 패스당 1~2개(`$"도감 {caught} / {total}"` 등 항목별이 아닌 것)뿐이라 두었다. 검증: error CS 0, ci_check 6검사 통과, PlayMode 268/268. +- 2026-08-06: DexScreenUI **재감사** (2026-05-20 감사 이후 1797줄 변경, 재감사 큐 1순위) — **P0:1, P1:2 처리 / P2:3 보고.** 이 라운드의 결함은 전부 **3D 썸네일 도입(726795a)과 그리드 개편의 접점**에서 나왔다. 개별 코드는 각자 맞는데 **전제가 호출부에서 깨진** 형태다. **P0: 도감을 열어둔 내내 프레임마다 곤충 모델 1개를 통째로 만들었다 부순다.** `DrawDexGrid`(:623)가 스크롤 위치와 무관하게 128종을 전부 순회하고 타일마다 `InsectVisual.Draw` → `GetThumbnail`을 부르는데, 캐시 상한은 `ThumbCacheMax = 24`이고 **적중할 때마다 `Touch(key)`로 LRU를 갱신**한다. 128개가 매 패스 같은 순서로 LRU를 훑으므로 24칸은 절대 안정되지 않고, 미적중분이 큐에 쌓이면 `Update`가 프레임당 1개를 렌더한 뒤 `EvictOverflow()`가 **다음 프레임에 곧바로 다시 요청될 항목**을 버린다. 미적중 1건의 비용이 `RenderInsectTo`(`new GameObject` + `BuildForBattle` + `new RenderTexture(192,192,16,ARGB32){AA=2}` + 머티리얼 Destroy 루프 + `Destroy`)다. 부수효과로 대부분 타일이 2D 폴백과 3D를 오가며 깜빡인다. **결정적 근거는 렌더러 자신의 주석**이었다 — "프레임당 1개만 렌더한다 … 도감을 여는 순간 20종을 한 프레임에 처리하면 확실히 튄다". 상한 24는 **한 뷰포트 분량**(6열×~3.5행)에 맞춘 값인데 호출부가 컬링을 안 해 그 전제가 깨져 있었다. IMGUI 스크롤뷰엔 가상화가 없으므로 컬링은 호출부 책임이다. `DexBrowseLayout.GetVisibleRowRange`/`GetVisibleItemRange`(순수, overscan 1행)를 신설해 도감 그리드와 보유 탭 두 곳에 적용. **P1: `GetInsectData`가 `database.insects.Find(람다)`**(`PlayerInsectCollection.cs:445`) — `insectId`를 캡처하므로 **호출마다** display class + `Predicate`가 할당되고 128종 선형 탐색이다. 보유 탭이 카드마다 부르고 OnGUI는 패스가 여럿이라 60마리 보유 시 패스당 120개 할당 + 7,680회 문자열 비교. 바로 옆 `InsectModelPreviewRenderer.cs:110`이 같은 함정을 for 루프로 **일부러 피했다**고 주석까지 달아둔 자리인데 이 호출부만 놓쳤다. id→data 사전으로 교체(모든 호출부가 함께 이득). **P1: `OwnsShiny`**(:1509) 주석이 "선택 곤충 1종에 대해서만 호출(저빈도)"라고 단언하고 그 믿음 위에 `GetAllOwned()`(새 List) + 전수 스캔을 하는데, 호출부 `DrawDetail`은 상세 모달이 열려 있는 동안 **매 OnGUI 패스** 돈다. 결과는 라벨 한 줄에만 쓰인다 — 선택 변경 시에만 세도록 캐시(`SelectIndex`가 무효화). **P2 3건은 보고만**(사용자 결정 대기): ①`:683`/`:1178`의 `insectColor`가 계산만 되고 `InsectVisual.Draw`에 전달되지 않아(그 API엔 색 인자가 없고 `alpha`만 있다) **"발견만 됨" 타일이 포획 완료와 똑같은 풀컬러**로 그려진다 ②`:700`/`:1186`이 `displayName`을 `TextClipping.Clip` 스타일 고정 박스에 그려 6열(≈206px)에서 말줄임 없이 잘린다 — 같은 파일이 상세·아이템에선 `LabelFit`을 쓰는데 이 둘만 누락 ③`$"NO. {index+1:D3}"`(:668) 등이 타일마다·패스마다 문자열을 만든다. **Explore 판정 정확**(거짓양성 0건) — 체크 2·3·4는 실제로 clean이었다(이 파일엔 `+=` 구독이 없고, `UITheme.Instance`는 자기치유형, `TutorialQuestManager.Instance`는 가드됨, `InitDexStyles`의 `new GUIStyle` 31개는 `dexStylesInitialized` 가드 안). 검증: error CS 0, ci_check 통과, PlayMode **346/346**(컬링 순수부 신규 8케이스 포함). **P2 3건 후속 완료(사용자 승인)**: ①"발견만 됨" 타일을 알파 0.42 실루엣으로 구분 — **색이 아니라 알파를 쓴 이유**는 `InsectVisual`의 두 경로 중 알파만 양쪽이 지원하기 때문이다(2D 폴백 `CapturePopupUI.DrawTypedInsectPortrait`는 색을 id·등급에서 스스로 뽑고 alpha만 받는다. 색 인자를 새로 뚫었다면 3D 썸네일에만 먹고 폴백에선 무시돼 두 경로가 갈렸을 것). `DrawOwnedCard`에도 같은 계열의 죽은 지역 변수 `ic`가 있어 함께 제거 — 보유 곤충은 전부 포획 완료라 감쇠 자체가 없다 ②그리드 타일·보유 카드 이름을 `UIHelper.LabelFit`으로 전환 ③`NO. NNN`을 `DexBrowseLayout.NumberLabel`로 옮겨 미리 굽고 테스트 4건으로 고정(1-based 오프셋·캐시 재사용·128 초과 성장·음수 인덱스). 도감 순수 계산 헬퍼가 이미 그 자리라 사유 필드로 두지 않고 그쪽에 뒀다. +- 2026-08-06: RaidBattleUI **재감사** (2026-05-27 감사 이후 1609줄 변경, 재감사 큐 1순위) — **P1:3 처리 / P2:2 보고.** 세 결함이 전부 **한 커밋(5f0776f)의 라운드 파이프라인 교체**가 남긴 것이다 — `BossTelegraph`/`TryCompleteRoundPresentation` 도입, `UniteRushMinDuration` 신설, `BeginTurnAnnounce` 호출부 5곳 삭제가 모두 그 커밋이고(`git log -S`로 확인), 새 구조는 맞는데 **옛 구조가 들고 있던 전제들이 같이 안 옮겨졌다.** **P1: 합체공격 연출의 클라이맥스가 통째로 안 보인다.** 종료 조건이 `uniteAnimTimer > 2.5f`에서 `phaseTimer >= UniteRushMinDuration(1.15f)`로 옮겨졌는데 `DrawUniteAttackAnimation`의 타임라인은 2.5s 기준 그대로다. `animationReady`는 게이트가 못 된다 — 아레나 코루틴이 rush 0.42 + return 0.28 = **0.70s**에 끝나 `teamAnimationComplete`가 먼저 서고, 2D 폴백은 `teamAnimationComplete = arena == null || !arena.IsActive`로 **즉시 true**라 두 경로 모두 정확히 상한에서 이탈한다. 그래서 t가 1.15를 못 넘고, 5인 팀 기준 멤버별 착탄 시각이 0.75/0.95/**1.15/1.35/1.55**라 **3명분의 히트 플래시와 데미지 숫자**가 사라지며, 최종 폭발(t>1.5)·텍스트 쉐이크(1.5~2.0)·`TOTAL -N`(t>1.8)은 **어떤 경우에도 렌더되지 않는다**. 이 오버레이는 3D 아레나 위에도 그려지므로 두 모드 공통이다. 상한을 회귀 전 값 2.5f로 되돌리고, 두 숫자가 함께 움직여야 하는 이유를 상수 옆에 못박았다. **P1: 모바일 탭 래치.** `wantMouseClick`은 `OnGUI`의 MouseDown이 세우는데 소거가 `SelectInsect`/`SelectSkill` 분기 **안**에만 있었다. Intro·PlayerAttack·BossTelegraph·BossAttack·UniteAttack·Result에서 화면을 탭하면 true로 남았다가 다음 라운드가 SelectInsect로 들어오는 첫 프레임에 **지난 라운드의 `insectBtnRects`로** 소비돼, 선택 패널을 보기도 전에 곤충이 골라지고 SelectSkill로 건너뛴다. 결과 화면(5s) 탭은 `EndRaid`가 rect를 안 지워 **다음 레이드까지** 넘어갔다. 유일하게 중간에서 비워주던 자리가 하필 아래 P1로 죽은 `TurnAnnounce` 블록이었다 — 즉 이 결함은 그 삭제의 부산물이다. `Update` 말미 무조건 소거로 `Input.GetMouseButtonDown`과 같은 한 프레임 수명을 주고, `EndRaid`에도 버튼 캐시 4개 초기화를 추가(Explore P2 중 1건 동반 해소). **P1: 턴 배너 기계장치가 사장된 채 살아 있는 코드처럼 남았다.** 2654b8c(07-19)가 넣은 "팀의 턴 / 보스의 턴" 배너의 호출부 5곳을 5f0776f가 지웠는데 `Phase.TurnAnnounce`·announce* 5필드·`TurnAnnounceDuration`·`BeginTurnAnnounce`/`FinishTurnAnnounce`/`DrawTurnAnnounce`·OnGUI 디스패치·`TriggerBossAttackEffect`가 전부 남았다. **복원이 아니라 삭제로 판정**했다 — `DrawBossTelegraph`가 **같은 `introFightStyle`로 같은 중앙 위치**에 "⚠ 보스 공격 준비!"를 그리고 `DrawBossIntent`가 `CASTING` + 진행바까지 얹어 옛 배너보다 많이 알려주므로 의도된 대체이고, 되살리면 0.72s 텔레그래프 위에 0.9s가 더 붙는다. 1v1(`BattleScreenUI`)의 동명 코드는 호출부가 살아 있어 **건드리지 않았다**. 연쇄로 `BattleArenaController`의 `bossAttackTargetSlot` + `SetBossAttackTargetSlot`도 제거 — **쓰는 쪽 없이 읽히기만 하는 필드**라 초기값 때문에 컴파일 경고도 안 나던 자리다(호출부 0인 `RaidRoundModels.SetBossDamage`를 없앴던 것과 같은 함정). **Explore 판정 1건 과대평가**: `SetBossAttackTargetSlot` 미호출을 "보스 공격 대상 지정 경로가 죽었다"고 보고했으나 **기능 손실은 없다** — 새 경로 `OnRaidBossResponseResolved`가 `arena.PlayRaidBossAttack(..., lastHitSlot, ...)`로 피격 슬롯을 인자로 넘기고 `BattleArenaController:656`이 그 인자를 폴백보다 **먼저** 처리한다. 대상 지정이 필드에서 매개변수로 옮겨간 것뿐이라 결함이 아니라 정리 대상이었다. **통과 확인**: 구독은 `SubscribeRaidController()`가 해지→재구독을 모아 `OnEnable`·`AutoWire`가 공유하고 `OnDisable`의 5개 `-=`와 정확히 짝(2026-08-03 P0의 재발 없음), `OnDisable`의 `timeScale` 복구는 `OpeningReplayCoordinator.ApplyReplayBlock`이 "UI 먼저 끄고 timeScale 마지막" 순서를 지켜 성립하며 `CanReplay`가 `IsRaidActive`로 레이드 중 재생을 막는다, `new GUIStyle` 57개는 전부 `stylesInitialized` 가드 안, `Update`/`OnGUI`에 조회 없음. **P2 2건은 보고만**(사용자 결정 대기): ①3537줄 단일 클래스에서 `Draw*` 40여 개가 ~2600줄 — partial 분리 시 이번 같은 "페이즈 타이밍 변경 ↔ 드로잉 타임라인 불일치"를 리뷰에서 잡기 쉬워짐 ②`DrawBossIntent`의 `TeamStats[intent.TargetSlot].Data.displayName`이 `.Data` null 가드 없음(상위 조건은 `TeamStats[i] != null`까지만). **보고**: "팀의 턴" 신호는 텔레그래프가 대체하지 못하는 유일한 부분이라 대체 없이 사라졌다 — 선택 패널 자체가 신호 역할을 하지만 배너를 원하면 새 파이프라인의 `OnRaidRoundCompleted`에 다시 걸 수 있다. 검증: error CS 0, ci_check 8검사 통과, PlayMode **346/346**(신규 테스트 0 — 프레임 수명·페이즈 타이밍·사장 코드 제거는 `rules/testing.md`의 MonoBehaviour 생명주기 제외 대상). **P2 2건 + 배너 후속 완료(사용자 승인)**: ①`DrawBossIntent`에 `.Data != null` 가드 추가 ②**모놀리스 partial 분리** — `RaidBattleUI.Draw.cs` 신설로 3537줄이 본체 774줄 + 렌더 2748줄이 됐다(멤버 170개 중 렌더 102개 이동: GUIStyle 캐시 57 + `InitStyles` + `Draw*` 40여 + 순수 라벨·색 헬퍼 4. `EndRaid`·`Update`·`OnGUI`·`AutoWire`·컨트롤러 이벤트 핸들러는 본체 유지). 손으로 옮기지 않고 중괄호 깊이로 멤버 경계를 잡는 스크립트를 썼다 — 문자열 보간의 중괄호에 속지 않도록 리터럴과 줄 주석을 먼저 지우고 세며, 최종 깊이 0을 단언한다 ③**"팀의 턴" 배너 복원** — `Phase.TeamTurnAnnounce` + `OnRaidRoundCompleted` 진입, 0.9s·탭 스킵. **보스 쪽은 여전히 되살리지 않는다**(`DrawBossTelegraph`와 겹친다). Intro→SelectInsect와 SelectSkill의 Escape 복귀는 배너 없이 즉시. **부수 발견 1건**: `verify_coverage.py`의 경로 정규식이 스템 중간의 점을 허용하지 않아 **`RaidBattleUI.Draw.cs`를 agents/*.md에 제대로 적어도 조용히 무시**했다(미할당 0이 나온 건 coordination 표의 basename 경로가 우연히 받아준 덕이고, 공유 표기는 통째로 빠져 있었다). 점을 허용하도록 수정 — 안 고쳤으면 다음 partial 분할 파일이 소리 없이 미할당이 된다. 검증: error CS 0(신규 파일이라 2회 컴파일), ci_check 통과, verify_coverage 196/196 미할당 0, PlayMode **350/350**. +- 2026-08-06: SubAreaWorldBuilder **재감사** (2026-05-27 감사 이후 1352줄 변경, 재감사 큐 1순위) — **P1:1 처리.** 이번에도 Explore 위임 없이 직접 봤다. **P1: 서브에리어에 들어갈 때마다 런타임 머티리얼 41개가 샌다.** `Mat(Color)`가 `new Material(shader)`로 만들고 `Apply`가 `mr.material`에 대입하는데, 파기는 `Destroy(subAreaRoot)` **하나뿐**이라 GameObject 트리만 사라지고 머티리얼은 남았다 — `new Material`은 GameObject가 아니라 별개 오브젝트라 명시적으로 파괴해야 한다. 같은 저장소가 이미 그 이유로 `NpcVisualBuilder.CleanupMaterials`와 `PlayerVisualBuilder.SafeDestroyMat`를 갖고 있는데 여기만 빠져 있었다. **반복 경로라 누적된다** — 25m 이탈 자동 퇴장과 [E] 재진입으로 세션 내내 들락거린다. `runtimeMaterials` 리스트 + `DestroySubAreaWorld()`로 트리와 머티리얼을 함께 파기하고, 파기 지점 2곳(재빌드 전·퇴장)을 그 하나로 통일한 뒤 `OnDestroy`에도 걸었다(씬 전환 시 마지막 빌드분). **거짓양성으로 제외한 것 2건**: ①`OnDisable`이 `SubAreaChanged`를 해지하는데 `OnEnable`이 없다 — 형태는 BattleScreenUI P0와 같지만 이 컴포넌트는 `World/SubAreaWorld`라 `playUiRoot` 토글에 닿지 않고, 저장소 어디에도 World 루트를 SetActive하는 코드가 없다(그래서 `subscription_lint`가 `"UI/"` 루트만 검사하는 것도 설계상 맞다). ②`OnGUI`의 `$"{이름} 들어가기"` 문자열은 패스당 1개라 RegionMapUI 라운드가 세운 기준(항목별이 아닌 패스당 1~2개는 둔다)에 따라 보고하지 않았다. **통과 확인**: `notifyStyleCache`·`entryExitButtonStyleCache` 둘 다 null 가드로 1회 생성, `Update`의 플레이어 조회는 `cachedPlayerTransform` lazy 캐싱, `FindFirstObjectByType`도 1회 캐시, 25m 이탈이 `RequestExit` 단일 경로로 통일돼 상태 누수 없음, `AudioManager.Instance`는 전부 null 가드 안. 검증: error CS 0, ci_check 8검사 통과, PlayMode **384/384**(코드 변경이 순수 생명주기라 신규 테스트 0 — 머티리얼 파괴는 `rules/testing.md`의 MonoBehaviour 생명주기 제외 대상). +- 2026-08-06: CharacterModelPreviewRenderer (큐 1순위, score 16) — **P1:3 처리.** 이번 세션에 내가 쓴 파일이라 더 회의적으로 봤고, 셋 다 **호출부가 OnGUI 그리드 루프라는 사실**에서 나왔다. **P1: 썸네일 캐시 키가 카드마다·패스마다 새 문자열이었다.** `ThumbKey(slot, itemId, lookHash)`가 `(int)slot + "|" + itemId + "|" + lookHash`를 만드는데 호출부는 `CharacterOutfitUI:541`의 카드 루프다 — 보이는 카드 16장 × 패스 3회 × 60fps면 초당 수천 개다(도감·지역맵 라운드가 잡은 것과 같은 형태). `ThumbId` 구조체(`IEquatable`)로 바꿔 조회 할당을 0으로 만들고, `TouchKey`/`EvictKeys`를 제네릭화해 기존 LRU 테스트는 그대로 돌게 뒀다. **외형 해시는 키에서 뺐다** — 외형이 바뀌면 `EnsureMannequin`이 이미 `ReleaseThumbs`로 캐시를 통째로 비우므로 키가 그걸 또 표현할 이유가 없었고, 그 중복이 키를 문자열로 만든 원인이었다. **P1: 렌더하는 프레임마다 PlayerPrefs를 4번 읽었다.** `EnsureMannequin`이 무조건 `AppearanceSpec.FromPlayerPrefs()`를 부르는데, 큰 패널은 각도가 바뀔 때 렌더하므로 **드래그로 돌리는 동안 매 프레임**이고 `SaveScope.PrefsKey` 키 문자열 4개도 함께 났다(OpeningSceneController 라운드가 "매 프레임 PlayerPrefs 조회"를 P1으로 잡았던 그 형태). `appearanceDirty` 플래그로 바꿨다. **부수 발견**: 그 플래그를 세울 자리로 `InvalidatePreview()`가 딱 맞았는데 **호출부가 0이었다** — 이 저장소가 반복해 제거해 온 사장 API 계열(`RaidRoundModels.SetBossDamage`, `DexBrowseLayout.FormatElementLabel`)이다. 제거하는 대신 `CharacterOutfitUI.Toggle`에 배선해 실제 역할을 줬다(외형은 캐릭터 생성 화면에서만 바뀌므로 의상 모달 여닫는 지점이 유일하고 충분한 재확인 시점이다). **P1: 경계 계산이 렌더마다 60여 개짜리 배열을 만들었다.** `TryComputeBounds`가 `GetComponentsInChildren(false)` 배열 반환판을 쓰는데 마네킹 렌더러가 60개쯤이고 이 경로도 드래그 중 매 프레임이다. 리스트 채우기 오버로드 + 재사용 버퍼(`boundsBuffer`)로 교체(static → 인스턴스 메서드). **거짓양성으로 제외 1건**: 퇴출·해제가 `rt.Release()`만 하고 `Destroy(rt)`는 안 한다 — 형제 `InsectModelPreviewRenderer`도 정확히 같아서(:178, :285) 이 저장소의 하우스 패턴이고, GPU 메모리는 Release가 푼다. 바꾸려면 두 파일을 함께 봐야 하므로 이 라운드 범위 밖. **통과 확인**: `Camera.Render`는 `Update`에서만(OnGUI 중 호출 금지 계약 유지), 리그 레이어 29 · 원점 -5200으로 곤충 프리뷰(30 / -5000)와 분리 유지, `CharacterOutfitManager.Instance` null 가드 있음, 마네킹은 외형 바뀔 때만 재빌드(풀링 유지), `OnDestroy`가 RT 전량 해제. 검증: error CS 0, ci_check 8검사 통과, PlayMode **386/386**(ThumbId 신규 5 − 옛 ThumbKey 3). +- 2026-08-06: OutfitShapeLibrary (큐 1순위, **score 0**) — **P1:2 처리(그중 1건은 교차 수정).** score 0은 "OnGUI/Update 표면이 없다"는 뜻일 뿐이라 스킬 경고대로 clean으로 보지 않고 열었고, **둘 다 이 파일이 아니라 호출부의 빈도에서 나왔다.** **P1: `Apply`가 슬롯마다 `SpawnPrefix + slot`으로 문자열을 만든다.** enum을 문자열로 바꾸는 연결이라 할당이 나고, `Apply`는 슬롯 5개 × (컨테이너 조회 + 필요 시 생성) 두 자리에서 불린다. 슬롯별 이름을 정적 배열에 미리 구워 `SpawnContainerName(slot)`으로 꺼낸다(테스트 2건으로 리터럴 연결과의 일치·인스턴스 동일성 고정 — 새 슬롯이 늘었을 때 조용히 어긋나면 컨테이너를 못 찾아 파츠가 매번 새로 생긴다). **P1(교차): 프리뷰가 각도만 바뀐 프레임에도 옷을 통째로 다시 입혔다.** `CharacterModelPreviewRenderer.Update`의 조건이 `로드아웃 해시 != 렌더된 것 || 각도 != 렌더된 것`인데 그 안에서 `ApplyLoadout`을 무조건 불렀다 — **드래그로 돌리는 동안 각도가 매 프레임 바뀌므로 매 프레임** `ApplyToCharacter`(8슬롯 색 적용 + 5슬롯 레시피 재배치, 그때마다 `FindDeep`이 마네킹 계층을 훑는다)가 돌았다. 마네킹이 **지금 입고 있는** 조합을 `appliedLoadoutHash`로 따로 들고, 그것과 다를 때만 다시 입힌다 (RT에 찍힌 `shownLoadoutHash`와는 다른 개념이다 — 썸네일 경로가 중립 조합을 입히면 여기서 갈라지고 다음 큰 패널 프레임이 자연히 되돌린다). **직전 라운드에서 내가 놓친 것을 이번에 잡았다** — CharacterModelPreviewRenderer 라운드는 그 파일 안의 할당만 봤고 `ApplyLoadout`이 부르는 **바깥 비용**은 세지 않았다. **통과 확인**: `ApplySpawned`가 `index < container.childCount`로 파츠를 **재사용**해 드래그 중 생성/파괴 왕복이 없고, `TrimContainer`가 남는 파츠의 `sharedMaterial`까지 파괴하며, spawn 파츠 머티리얼이 자기 전용이라 `sharedMaterial` 직접 수정이 안전하다(주석이 그 이유를 못박고 있다). 레시피 테이블은 순수 정적 데이터라 구독·싱글턴·세이브 항목은 해당 없음. 검증: error CS 0, ci_check 8검사 통과, verify_coverage 197/197 미할당 0, PlayMode **388/388**(신규 2). +- 2026-08-06: RaidSupportPlanner (큐 1순위, score 0) — **P1:2 처리.** 한 시간 전에 내가 쓴 파일이고, **둘 다 같은 라운드 안에서 뒤에 온 단계가 앞 단계를 낡게 만든 자리**였다. **먼저 확인한 것(P0 후보 → 무해)**: Stage 1이 기절·독에도 `Lands()`(명중 굴림)를 붙였는데, `InsectSkill.accuracy`의 선언 기본값이 0이면 `RollHit`의 하한 clamp(0.3) 때문에 **게임의 모든 스킬이 30% 명중**이 될 수 있었다. 확인 결과 선언이 `accuracy = 1f`이고 Bootstrap의 `CreateSkill`이 건드리지 않아 그대로 1 — 무해. **P1: 버프가 팀 전체로 바뀐 걸 플래너가 모른다.** Stage 4가 `BuffAttack`/`DefenseBuff`를 시전자 전액·아군 절반으로 넓혔는데, 플래너는 여전히 `attacker.AttackStacks >= MaxBuffStacks`만 보고 0점을 냈다 — **시전자가 상한이면 아군 4명에게 여유가 있어도 스킬을 버렸고**, 반대로 값도 시전자 몫만 세어 과소평가했다. `BuffRoom(team, caster, defense)`으로 실제로 붙을 대상의 가중 합(시전자 1.0 + 여유 있는 아군 × 전달비율)을 세고, 0일 때만(=전원 상한) 거른다. 점수도 그 값에 비례시켰다. **P1: 보스 연속기절 면역을 플래너가 모른다.** Stage 1이 `bossStunImmuneRounds`를 넣어 2라운드 연속 기절을 막았는데, 플래너는 면역 라운드에도 기절을 최고점으로 매겨 **기절기를 든 팀원들이 나란히 저항당할 시도**를 했다 — 버프를 스택 상한에서 거르는 가드와 정확히 같은 계열인데 기절만 빠져 있었다. `bossStunImmune` 인자(기본 false)를 받아 0점 처리하고 컨트롤러가 `bossStunImmuneRounds > 0`을 넘긴다. **통과 확인**: 라운드당 4회 호출이라 할당은 애초에 쟁점이 아니고(호출 빈도가 초 단위), 난수 미사용·동점 최저 인덱스라 결정론 유지, `Heal`은 Stage 4에서 이미 리졸버와 같은 대상(`LowestHpAlly`)을 보도록 맞춰 뒀다, `DebuffAttack`은 보스 단일 유지라 팀 확장과 무관. 순수 정적이라 구독·싱글턴·세이브·Bootstrap 항목은 해당 없음. 검증: error CS 0, ci_check 8검사 통과, PlayMode **391/391**(신규 3 — 시전자만 상한일 때 여전히 쓰는지, 전원 상한이면 거르는지, 면역이면 기절 대신 다른 걸 고르는지). +- 2026-08-06: CharacterOutfitData (큐 1순위, score 0) — **P1:2 처리.** 데이터 파일 자체는 순수 모델이라 결함이 없었고, **둘 다 이 파일이 노출한 API를 UI가 어디서 부르느냐**에서 나왔다. **P1: `GetPrimaryBonusText()`가 카드마다·OnGUI 패스마다 문자열을 만든다.** `$"포획 +{x*100:0}%"` 형태라 float 포맷까지 도는데 호출부(`CharacterOutfitUI:607`)가 카드 루프 안이다. **바로 윗줄 주석이 "카드 12개 × 30FPS = 360회/초 new GUIStyle 회귀 차단"이라고 적혀 있다** — GUIStyle은 막아뒀으면서 같은 빈도의 문자열은 다음 줄에 그대로 남아 있었다. 카탈로그가 세션 내내 불변이라 itemId로 memoize(`BonusTextFor`)하면 무효화도 필요 없다. **P1: 세트 진행도 별 표시가 덧붙이기 루프였다.** `stars += ...`를 `total`번 돌려 세트마다·패스마다 문자열이 제곱으로 났다(`CharacterOutfitUI:421-423`). 조합 수가 적어(채운 수, 전체 수) 정적 캐시로 구웠다. **거짓양성 1건 — 내 예전 계획서의 오판을 정정한다**: 의상 계획서(S1~S4)가 "`bonusProvider.GetActiveSets()`가 카드 루프 안 — O(카드×세트) 할당/프레임"이라고 적어 두고 개선 후보로 남겼는데, 실제 구현은 `public ActiveSetInfo[] GetActiveSets() => cachedActiveSets;`로 **캐시 필드 반환이라 할당이 0**이다. 중첩 루프는 문자열 비교만 돈다. 계획서의 그 항목은 근거 없이 적힌 것이었다. **통과 확인**: `OutfitLoadout.SlotCount = 8`이 enum과 어긋나면 프리뷰·입어보기가 새 슬롯을 조용히 무시하는데, 주석이 주장하는 `OutfitLoadoutTests`가 실재하고(`CharacterPreviewTests:145`) `Enum.GetValues(...).Length`와 고정한다 — 주석과 현실이 일치. `Hash()`는 8슬롯 다항식이라 실사용 아이템 95개 규모에서 충돌이 사실상 불가능하고, null과 ""가 같은 해시로 접히지만 `GetThumbnail`이 빈 id를 먼저 거르고 `CopyFrom`은 null만 쓰므로 도달 경로가 없다. `OutfitStatBonus`는 순수 struct라 구독·싱글턴·세이브 항목 해당 없음(세이브는 PlayerPrefs 2키가 단일 출처이고 이 타입은 안 들어간다). 검증: error CS 0, ci_check 8검사 통과, PlayMode **391/391**(순수 캐시 교체라 신규 테스트 0 — 동작이 바뀌지 않았고 기존 테스트가 계약을 이미 덮는다). +- 2026-08-06: TutorialQuestUI **재감사** (2026-05-27 감사 이후 1224줄 변경, 재감사 큐 1순위) — **P1:1 처리.** 이 파일은 2026-05-27에 캐시 라운드를 두 번(P0:1 + P1:8) 받았는데, 그 뒤 Phase A/B(강제 가이드 인트로, 스토리/서브 분류, 반복 상승 서브 퀘스트)로 상세 패널이 새로 들어오면서 **새 코드에만** 같은 결함이 다시 났다. **P1: `DrawDetailPanel`이 패스마다 `List` 2개를 만들고 31개를 다시 가른다.** 스토리/서브 분리는 `TutorialQuestManager.Initialize()`가 코드로 만드는 고정 배열의 `category`에서만 파생돼 세션 내내 불변인데, 상세 패널이 열려 있는 동안 IMGUI 패스마다(프레임당 Layout·Repaint·입력) 재계산됐다. `EnsureQuestPartition`으로 캐시하고 **무효화 키는 원본 배열의 참조 자체**로 잡았다 — 매니저가 다시 초기화되면 배열 인스턴스가 바뀌므로 자동으로 다시 갈린다(`PlayerInsectCollection`의 `insectDataIndexSourceCount`와 같은 형태이되 참조 동일성이라 더 강하다). **거짓양성으로 제외 3건**: `:475`의 `ccur + "/" + ctgt`, `:588`의 같은 형태, `:459`의 `"★ " + act.title` — 전부 **활성 퀘스트 하나에 대한 패스당 1개**라 RegionMapUI 라운드가 세운 기준(항목별이 아닌 패스당 1~2개는 둔다)에 해당한다. **통과 확인**: `GetAllQuests()`는 배열을 그대로 반환해 할당이 없고(호출부가 매 패스 부르므로 중요하다), `InitQuestPanelStyles`가 `questPanelStylesReady` 가드로 GUIStyle 22개를 1회만 만들며, `DrawQuestRow`(행마다 호출)에는 할당이 하나도 없다 — 2026-05-27 캐시 라운드가 거기까지는 지켜냈다. `OnEnable`/`OnDisable`이 `QuestActivated`/`QuestProgressUpdated`/`QuestCompleted` 3쌍을 정확히 맞추고 `AutoWire`도 재구독 경로를 갖는다. `rewardChipBuffer`는 이미 재사용 버퍼다. 검증: error CS 0, ci_check 8검사 통과, PlayMode **396/396**(순수 캐시 교체라 동작 불변, 신규 테스트 0). +- 2026-08-06: TrainingUI **재감사** (2026-05-21 감사 이후 1007줄 변경, 재감사 큐 1순위) — **P0:1, P1:1 처리.** 2026-05-21에 캐시 라운드를 크게 받은 파일인데(P0:1 + P1:34), 그 뒤 **726795a가 3D 곤충 썸네일을 도입하면서** 도감과 똑같은 결함이 여기에도 생겼다. **P0: 보유 곤충 목록이 뷰포트 컬링 없이 전 개체를 돈다.** `DrawInsectSelect`가 `owned.Count` 전체를 순회하며 개체마다 `InsectVisual.Draw`(3D 썸네일 요청)를 부르는데, `InsectModelPreviewRenderer`의 캐시 상한은 **한 뷰포트 분량(24칸)**이고 적중할 때마다 LRU를 갱신한다. 60마리를 매 패스 같은 순서로 훑으면 24칸이 절대 안정되지 않아, 렌더러가 프레임마다 `InsectEntity`를 통째로 만들었다 부수고 RenderTexture를 create/Release 한다. **도감(DexScreenUI)에서 이번 세션 P0로 잡은 것과 완전히 같은 구조**인데, 그때 도감만 고치고 같은 썸네일을 쓰는 다른 목록은 보지 않았다 — 726795a가 건드린 화면이 9곳이므로 나머지도 같은 위험을 진다. 도감이 쓰는 순수 계산 `DexBrowseLayout.GetVisibleRowRange`(overscan 1행)를 그대로 공유했다 (UI→Dex 참조는 `InsectVisual`·`PlayerStatusHUD` 등 4파일이 이미 쓰는 방향이라 계층 위반이 아니다). **P1: 목록 한 줄의 정보 문구가 개체마다·패스마다 문자열 2개.** `" | " + SizeLabel(SizeMm(...))` 접미와 `$"Lv.n | 스킬 a/b | 장착 c/d{크기}"` 본문을 매번 만들고 크기 계산까지 돌았다(60마리면 패스당 120개). 값이 레벨·습득/장착 수·크기에서만 파생되고 그 변화가 전부 `InsectUpdated`로 오므로, **이미 있던 보유 목록 캐시와 같은 신호로** 비우는 `ownedInfoCache`를 붙였다(`HandleInsectUpdated`가 둘을 함께 무효화). **통과 확인**: GUIStyle 40여 개는 `trainDetailStylesReady`/`insectSelectStylesReady` 가드 안이라 1회 생성이고 (score 246의 대부분이 이 줄들이다 — 채점은 가드를 못 본다), `GetCachedOwned`가 `GetAllOwned` 매 프레임 호출을 이미 막고 있으며, `OnEnable`/`OnDisable`이 `InsectUpdated` 구독을 짝 맞춘다. **남긴 것**: 스킬 카드·장착 슬롯·교체 화면의 `"DMG {power}"` 류 문자열이 4곳에 같은 3분기로 중복돼 있고 항목마다 패스마다 생성되지만, 그 목록들은 최대 4~6개라 60마리 목록과 규모가 다르다 — 다음 라운드 후보로 남긴다. 검증: error CS 0, ci_check 8검사 통과, PlayMode **396/396**(컬링 순수부는 도감 라운드가 이미 8케이스로 고정, 문자열 캐시는 동작 불변이라 신규 테스트 0). +- 2026-08-06: BattleScreenUI **재감사** (2026-05-27 감사 이후 1586줄 변경, 재감사 큐 1순위) — **P1:2 처리 / P2:1 보고.** 이번 라운드는 Explore에 위임하지 않고 직접 봤다 — 직전 RaidBattleUI 라운드의 결함 3건이 전부 형제 파일과 같은 계열이라 그쪽부터 대조하는 게 빨랐고, 실제로 **첫 번째 P1이 같은 결함의 1v1판**이었다. **P1: 인트로 중 탭 한 번에 도주가 실행된다.** `wantMouseClick`은 OnGUI(MouseDown)가 세우는데 소거는 SwapSelect/PlayerTurn/TurnAnnounce 분기 **안**에서만 해서 Intro·PlayerAttack·EnemyAttack·Result에선 true로 남는다. 레이드보다 나쁜 건 두 가지 때문이다 — ①`Update`에서 **Intro 종료 전이와 PlayerTurn 처리가 같은 패스**에 있어 (`phase = Phase.PlayerTurn` 직후 30줄 아래에서 `phase == Phase.PlayerTurn` 블록이 돈다) 인트로 중 탭이 곧바로 그 프레임에 소비된다 ②`EndBattle`이 `skillBtnCount = 0`만 지우고 **`basicAtkRect`/`escapeRect`는 남긴다.** 히트 테스트가 `escapeRect.width > 0`라 지난 전투의 Rect가 그대로 유효하고, 도주 버튼은 화면상 같은 자리라 **2번째 전투부터 인트로 중 그 근처를 탭하면 턴을 보기도 전에 도주가 나간다**(스킬은 `skillBtnCount`가 0이라 무해). `Update` 말미 무조건 소거로 `Input.GetMouseButtonDown`과 같은 한 프레임 수명을 주고, `EndBattle`에 basicAtk/escape/swap Rect 초기화를 추가. **P1: 신규 스킬 4종이 1v1에서만 회색이다.** `GetSkillColor`가 `SkillEffectType` 7종 중 Damage/BuffAttack/DebuffAttack 3종만 처리하고 `default: Color.gray`로 떨어뜨린다 — d3d90cf가 Heal/PoisonDot/Stun/DefenseBuff를 추가하면서 **같은 파일의 라벨 switch 2개(`SkillTypeLabel`/`SkillPowerLabel`)는 7종을 전부 채웠는데 색 switch만 놓쳤다.** 레이드 쪽 `GetSkillColor`는 7종을 갖고 있어 같은 스킬이 화면마다 다른 색이었다 — 레이드 팔레트에 맞춰 4종 추가(enum↔switch 1:1 회복). **통과 확인**: 구독은 `SubscribeBattleController()`를 `AutoWire`·`OnEnable`이 공유하고 `OnDisable`의 3개 `-=`와 짝 (2026-08-03 P0의 재발 없음), `FindFirstObjectByType` 2곳 전부 캐시 뒤, `new GUIStyle`은 `stylesInitialized` 가드 안, `pendingSwap`/`pendingResult`는 전투 시작·Enter* 양쪽에서 소거돼 새지 않는다, `Time.timeScale`은 단일 아비터 + `EndBattle`의 try-finally 이중 복구. **연출 게이트는 이번엔 깨져 있지 않다** — 레이드의 P1(유나이트 상한 1.15s vs 2.5s 타임라인)과 대칭인 자리를 확인했는데, `PhaseAnimDone`의 2D 경로가 `phaseTimer > 0.8f`이고 `DrawAttackAnimation`도 `phaseTimer / 0.8f`로 정규화해 정확히 맞는다(3D 경로는 0.5~2s 사이 `IsPlayingSkill` 게이트, 2D 오버레이는 Clamp01로 유지). **거짓양성 제외**: `DrawBattleOverlay`의 `$"BATTLE - Turn {n}"`은 패스당 1개라 RegionMapUI 라운드가 세운 기준(항목별이 아닌 패스당 1~2개는 둔다)에 따라 보고하지 않았다. **P2 1건 보고**: `DrawSkillPanel`(:1921)이 매 패스 `battleController.GetPlayerCooldowns()`를 부르는데 그쪽이 **방어적 복사본**(`new int[len]` + `Array.Copy`)을 만든다. PlayerTurn은 플레이어가 생각하는 동안 수 초간 지속되는 페이즈라 그 내내 프레임당 여러 번 할당된다. 고치려면 `OnBattleUpdated`에서만 갱신하는 캐시가 필요한데, 쿨다운이 `BattleUpdated` 없이 감소하는 경로가 있으면 패널이 낡은 값을 보이므로 모놀리스 깊은 변경이다(`rules/audit-flow.md`: 표면 회귀만 자동 처리). 검증: error CS 0, ci_check 8검사 통과, PlayMode **350/350**(신규 테스트 0 — 프레임 수명·enum switch 보강은 `rules/testing.md`의 MonoBehaviour 생명주기 제외 대상). +- 2026-08-06: LoginUI **재감사** (2026-05-20 감사 이후 968줄 변경) — **clean P0/P1, 파일 수정 0건.** **score 183은 거짓양성**이었다 — `InitStyles` 안의 `new GUIStyle` 40여 줄인데 그 메서드는 `stylesInitialized` 가드로 1회만 돈다(채점기는 가드를 못 본다. TrainingUI의 score 246도 같은 형태였다). **P1 후보였다가 무해로 판정 2건**: ①`Update`가 매 프레임 `CheckCharacterCreation()`을 부르고 그 안에 `PlayerPrefs.GetInt` + **`System.IO.File.Exists`(디스크 I/O)**가 있다 — 매 프레임이면 심각하지만, 세 분기가 전부 `OnGameReady()`(→`phase = Done`) 또는 `phase = CharacterCreate`로 끝나고 호출 조건이 `phase == Login && IsLoggedIn`이라 **한 프레임만** 돈다. ②`"v" + Application.version + " · …"`이 패스마다 연결되지만 항목별이 아닌 패스당 1개라 RegionMapUI 기준에 해당한다. **통과 확인**: 텍스처 생성기 3종(`MakeGradientTex`/`MakeRadialTex`/`MakePanelTex`)이 **전부** `generatedTextures`에 등록하고 `OnDestroy`가 일괄 파기한다(2026-05-20 P1:2 수정이 유지되고 있다 — WorldLobbyUI의 MakeTex 누수 P0:7과 같은 계열인데 여기선 세 경로 모두 커버). 정리를 `OnDisable`이 아니라 `OnDestroy`에 건 것도 정확하다 — 텍스처는 1회 생성 후 재사용이고 `stylesInitialized`가 재생성을 막으므로, 비활성마다 지우면 다시 켤 때 빈 화면이 된다. `OnEnable`/`OnDisable`이 Auth 이벤트 3쌍을 정확히 맞추고, `AuthManager.Instance`는 참조 지점 전부에 null 가드, `TutorialQuestManager.Instance?.`는 null 조건 연산자. 코드 변경이 없어 테스트는 재실행하지 않았다(직전 검증 상태 유지: error CS 0, ci_check 통과, PlayMode 396/396). +- 2026-08-06: RaidBattleController **재감사** (오늘 레이드 5단계로 924줄 변경 — 큐 순서 대신 사용자 요청으로 앞당김) — **P1:1 처리 + 방어 1건.** 오늘 내가 가장 많이 고친 파일을 스스로 감사했고, **찾은 결함은 계획서에 적어 놓고 구현하지 않은 것**이었다. **P1: 회복 수치가 시전자 슬롯에 뜬다.** Stage 4가 회복 대상을 "생존 아군 중 최저 HP"로 바꿔 시전자와 대상이 갈렸는데, Stage 3이 만든 `BuildSlotContributions`는 여전히 `slotContribText[action.SourceSlot]`에만 적었다 — 즉 **멀쩡한 힐러 위에 `+40`이 뜨고, 정작 HP 바가 오른 곤충 위엔 아무것도 안 나온다.** 계획서 Stage 4에 "대상은 `RaidActionResult.TargetSlot`에 기록 → Stage 3 UI가 그 슬롯 위에 `+N`을 띄운다"라고 적어 두고 리졸버 쪽만 하고 UI 쪽을 빠뜨렸다(모델엔 `TargetSlot`이 제대로 들어가 있었고 UI가 안 읽었다 — `Healing`/`Capped`가 원래 그랬던 것과 똑같은 형태의 재발이다). 회복 전용 배열 `slotHealText`를 **받은 슬롯으로 색인**해 본문보다 한 단 위에 민트색으로 띄운다 — 배열을 나눈 이유는 회복받은 슬롯도 자기 행동(피해)이 있어 한 배열이면 서로 덮어쓰기 때문이다. **방어 보강**: `ResolveTeamCommand`의 서포트 루프가 `TeamSkills[i]`를 가드 없이 인덱싱했다. 바로 위 `CanUseSkill`은 `TeamSkills != null && i < Length`로 가드하는데 Stage 2에서 추가한 경로만 빠져 대칭이 깨져 있었다. **현재는 도달 불가**(유일한 호출부 `CaptureChoiceUI`가 항상 `MaxSlots` 길이를 채우고 `count < MaxSlots`면 시작을 거부한다)라 P1로 올리지 않고 한 줄 가드만 맞췄다 — `NpcDuelController` 라운드의 `Abs(int.MinValue)` 방어와 같은 판단. **통과 확인**: 기절 면역 타이밍을 라운드 단위로 되짚어 정확함을 확인(N라운드 적중 → 스킵 시 2 세팅 → `CompleteRoundPresentation`에서 1 → N+1 차단 → 0 → N+2 허용), `TeamCooldowns[i]`는 스킬이 없는 슬롯도 길이 0 배열로 항상 할당되고, 팀 전체 버프가 `StartRaid`의 의상·아이템 보너스와 같은 필드를 쓰지만 스택 카운터가 대상별이라 상한이 정상 동작하며, `BossEnraged`·`bossStunImmuneRounds`·`TeamStance` 전부 `StartRaid`에서 초기화된다. 검증: error CS 0, ci_check 8검사 통과, PlayMode **396/396**(회복 표시는 OnGUI라 `rules/testing.md` 제외 대상). +- 2026-08-06: BattleArenaController **재감사** (2026-05-27 감사 이후 933줄 변경, 재감사 큐 1순위) — **P1:1 처리.** **P1: 스킬 연출이 만든 런타임 머티리얼을 아무도 지우지 않는다.** `CreateSafeMaterial`(단일 생성 헬퍼, 호출 31곳)이 `new Material(shader)`로 만들고 각 이펙트 오브젝트의 `.material`에 대입하는데, 코루틴은 오브젝트만 `Destroy`하고 머티리얼은 남긴다. `CleanupArena`도 `arenaRoot`만 지운다. **스킬을 쓸 때마다** 링·스파크·구름·기둥이 새로 나므로 전투가 길수록 계속 쌓였다(SubAreaWorldBuilder 라운드와 같은 계열이되, 그쪽은 서브에리어 진입당이고 여기는 연출당이라 빈도가 훨씬 높다). 근거는 저장소 안에 있다 — `OutfitShapeLibrary.TrimContainer`가 GameObject보다 **머티리얼을 먼저** 파괴하고, `PlayerVisualBuilder.SafeDestroyMat`·`NpcVisualBuilder.CleanupMaterials`가 같은 이유로 존재한다. `CreateSafeMaterial`/`CreateTransparentMaterial`을 인스턴스 메서드로 바꿔 `runtimeMaterials`에 등록하고 `CleanupArena`에서 일괄 파기 — **호출 31곳을 건드리지 않고** 단일 생성 지점만 고쳐 누수를 전투 1회 수명으로 가뒀다. **거짓양성으로 제외 1건**: 페이드 코루틴들(`:1157`·`:1307`·`:1484`)이 매 프레임 `GetComponent().material` **게터**를 부른다. 게터는 인스턴스가 없을 때만 만들고 그 뒤로는 같은 것을 돌려주는데, 여기선 생성 시 이미 `.material = mat`으로 대입해 인스턴스가 있으므로 **프레임마다 새로 생기지 않는다**. 다만 `GetComponent` 자체는 매 프레임 반복이라 루프 밖 캐싱 여지가 있다 — 이펙트가 5~8개 규모라 이번엔 두었다. **통과 확인**: `CleanupArena`가 `StopAllCoroutines`를 **먼저** 불러 파괴된 GameObject 접근을 막고 `playingSkill`을 강제 리셋한다(2026-05-27 라운드가 잡은 고착 회귀의 방어가 살아 있다), `OnGUI`의 GUIStyle 2개는 null 가드로 1회 생성, `rendererCache`가 렌더러 조회를 캐싱한다. 검증: error CS 0, ci_check 8검사 통과, PlayMode **396/396**(머티리얼 파괴는 `rules/testing.md`의 MonoBehaviour 생명주기 제외 대상이라 신규 테스트 0). +- 2026-08-06: **3D 썸네일 목록 컬링 일괄 스윕** (큐 순서 대신 사용자 요청으로 횡단 점검) — **P0:4 처리.** 직전 TrainingUI 라운드에서 "726795a가 건드린 화면이 9곳인데 도감·훈련 2곳만 컬링됐다"는 걸 발견해, 큐 순서(LoginUI)를 미루고 `InsectVisual.Draw` 호출부를 **전수(11곳) 기계적으로** 훑었다 — 각 호출의 위 45줄에서 루프·BeginScrollView·컬링 흔적을 찾는 스크립트로 판정했다. **컬링 없이 전 항목을 돌던 4곳**: `BattleTeamUI:324`(곤충 선택기), `CollectionUI:381`(보유 목록), `HospitalUI:248`(치료 대상 목록), `RegionMapUI:668`(리전 도감). 전부 `DexBrowseLayout.GetVisibleRowRange`로 통일했다 (UI→Dex는 `InsectVisual`·`PlayerStatusHUD` 등이 이미 쓰는 방향). **해당 없음으로 판정한 5곳**(전부 근거 확인): `DexScreenUI:703`은 이미 컬링된 그리드 루프가 부르는 타일 내부, `BattleTeamUI:207`·`CaptureChoiceUI:504`는 고정 5슬롯, `CaptureChoiceUI:186`·`CollectionUI:468`은 단일 개체. **재발 방지**: 원인은 개별 화면의 부주의가 아니라 **썸네일 도입 커밋 하나가 6개 화면에 동시에** 같은 결함을 심은 것이므로, 단일 진입점 `InsectVisual`의 클래스 주석에 "목록에서 부를 때는 반드시 뷰포트 컬링을 먼저 하라"를 이유·계산 함수·이력과 함께 못박았다. 새 목록을 만드는 사람이 반드시 읽는 자리다. **이 스윕이 왜 필요했나**: 도감 라운드(P0)에서 결함을 정확히 진단하고도 **같은 썸네일을 쓰는 다른 화면을 보지 않아** 5개 화면이 그대로 남아 있었다. 라운드 단위 감사가 "한 커밋이 여러 화면에 심은 결함"에 약하다는 사례다. 검증: error CS 0, ci_check 8검사 통과, PlayMode **396/396**(컬링 순수부는 도감 라운드의 8케이스가 이미 고정). +- 2026-08-06: UIShapes (큐 1순위, score 0) — **P1:1 처리.** 147줄짜리 도형 헬퍼인데 **계산 방향이 뒤집힌 진짜 그림 버그**가 있었다. **P1: `Part()`의 중간 roundness 혼합이 반대로 동작한다.** disc 위에 덮는 안쪽 사각형의 인셋을 `(1f - r)`로 잡아서, r이 클수록(둥글어야 할수록) 덮는 사각형이 커져 **각지게** 보이고 r이 작을수록 조각으로 줄어 **둥글게** 보였다 — 정확히 반대다. 경계로 확인하면 명백하다: r=0.02면 disc가 거의 다 드러나 타원, r=0.98이면 사각형이 98%를 덮어 네모다. **사장 코드가 아니라 살아 있는 경로였다** — 호출 277개 중 275개는 기본값 1f(순수 타원 분기)라 무사했지만, 2D 의상 카드 폴백의 `CharacterPortraitRenderer.RecipeRoundness`가 **Cube에 0.12f, 세운 원통에 0.8f**를 실제로 넘긴다. 즉 큐브 파츠가 타원으로, 원통 파츠가 사각형으로 그려지고 있었다. 그 폴백은 3D 썸네일이 구워지기 전 몇 프레임 동안 보이는 그림이라 품질이 중요하다고 파일 주석이 못박은 자리이기도 하다. 인셋을 roundness에 **비례**시키고(`BlendInsetRatio`), 그리기와 분리된 순수 수식으로 떼어 테스트 5건으로 방향·연속성을 고정했다 (경계 0/1, 단조 증가, clamp, 양쪽 순수 분기 임계에서의 연속). **통과 확인**: `Disc` 텍스처는 정적 지연 생성 후 재사용이고 파괴돼도 Unity의 `!= null` 오버라이드가 받아 다시 굽는다, `Capsule`이 `GUI.matrix`를 저장·복원하며 회전, `new Rect`/`new Color`는 struct라 GC 없음. MonoBehaviour가 아니라 구독·싱글턴·Bootstrap·세이브 항목은 해당 없음. 검증: error CS 0, ci_check 8검사 통과, PlayMode **396/396**(신규 5). **큐 재생성**: Uncovered가 0이 돼 `audit_candidates.py`로 새로 뽑았다 — 이제 전부 **재감사 후보**이고, 이번 세션에 대폭 바뀐 `RaidBattleController`(레이드 5단계)가 큐에 들어왔다. +- 2026-08-07: PlayerVisualBuilder **재감사** (2026-05-20 감사 이후 1170줄 변경, 큐 1순위) — **P1:1 처리 + 트랩 1건 제거.** **P1: 얼굴·머리 장식 머티리얼이 정리 목록에 없다.** `OnDestroy`가 `SafeDestroyMat(ref hatMat)` 식으로 **슬롯 필드 11개를 손으로 나열**하는데, `BuildFace`(눈·동공·하이라이트·눈썹·코·입 + 여성 홍조·속눈썹)와 `BuildUpHair`(리본)가 만드는 6~9개는 **지역 변수**라 그 목록에 오를 수가 없었다. 실플레이어는 영구 객체라 영향이 실질적 0이지만(파일 주석이 그렇게 적어둔 근거가 여기까지만 맞았다), **마네킹은 파괴가 정상 수명**이다 — `CharacterModelPreviewRenderer.EnsureMannequin`이 외형 해시가 바뀔 때마다 `Destroy(mannequin)` 후 다시 짓는다. 즉 외형을 바꿀 때마다 6~9개씩 샜다. 하필 `SafeDestroyMat`의 `previewMode` 분기 주석이 "여기서 강제로 지우지 않으면 마네킹 하나당 머티리얼 11개가 영구히 샌다"라고 **정확히 이 문제를 알아채고도 11개(=필드 개수)만 세었다** — 실제 생성량은 17~20개다. 생성 지점이 `MakeMaterial` 하나뿐이므로 static을 인스턴스 메서드로 바꿔 `runtimeMaterials`에 등록하고 `OnDestroy`가 목록을 일괄 처리하게 했다 — **호출 19곳을 건드리지 않았다**(2026-08-06 `BattleArenaController` 라운드가 31곳에 쓴 것과 같은 형태). 부수 효과로 `GetComponentsInChildren` 조회가 필드마다 11번 돌던 것이 1번으로 줄었다. **트랩 제거**: `BuildFace`가 코에 쓰려고 **필드와 같은 이름의 지역 변수** `Material skinMat`을 같은 색으로 하나 더 만들고 있었다 — 피부색을 한 곳에서 바꾸려 하면 코만 옛 색으로 남는다. 필드를 쓰도록 접고 머티리얼 하나를 없앴다. **거짓양성으로 제외 1건**: 그 지역 `skinMat` 때문에 "코 색이 안 따라간다"를 P1로 올릴 뻔했으나, `CharacterOutfitManager.ApplyToCharacter`가 실제로 칠하는 노드는 Cap/CapBrim/Shirt/Body/Arm/Leg/Boot/Backpack/Net 14개뿐이고 **피부 노드는 아무도 recolor하지 않는다**(skinColor는 outer_none일 때 팔 색으로만 쓰인다). 현재는 시각 결함이 아니라 잠재 함정이다. **통과 확인**: `Awake`↔`BuildForPreview`의 `builtOnce` 상호 배제가 비활성 AddComponent 계약과 정확히 맞고(활성 상태로 붙이면 PlayerPrefs 외형으로 먼저 지어진다는 주석대로), `OnEnable`↔`OnDisable`의 `OutfitChanged` 짝이 `subscribedToOutfit` 가드로 중복 없이 성립하며(마네킹은 `previewMode`로 구독 자체를 건너뛴다 — 구독하면 핸들러가 `GameObject.Find("Player")`로 실플레이어까지 갱신하는 조용한 버그가 된다), `CharacterOutfitManager.Instance`는 참조 3곳 전부 null 가드, `MakeMaterial`의 셰이더 4단 폴백과 진단 로그 1회 래치도 정상. 검증: error CS 0, ci_check 8검사 통과, PlayMode **405/405**(머티리얼 파괴는 `rules/testing.md`의 MonoBehaviour 생명주기 제외 대상이라 신규 테스트 0). +- 2026-08-07: CharacterPortraitRenderer **재감사** (2026-05-27 감사 이후 1119줄 변경, 큐 1순위) — **P1:2 처리.** **P1-①: `DrawWithOutfit`가 주변 `GUI.color`를 오염시킨 채 반환한다.** 내부 `Draw`는 마지막 줄에서 `GUI.color = Color.white`로 스스로 되돌리는데, `DrawWithOutfit`은 그 **뒤에** `DrawArmsAsSkin`·`DrawBackpackWithSlot`·`DrawOutfitAccessories` 셋을 더 붙이고 그 셋은 전부 `DrawCol`(= `GUI.color = col` 후 그리기)로 끝난다. IMGUI의 `GUI.color`는 이후 모든 그리기에 곱해지므로, **호출부가 다음에 그리는 것이 마지막 파츠 색으로 물든다.** 실제 피해는 `CashShopUI:269-275`다 — `DrawWithOutfit` 바로 아래 "보유 재화"·"💎 {gems}"·"🪙 {coins}" 라벨 3개가 색을 따로 세팅하지 않아 그대로 곱해진다. 하필 `DrawOutfitAccessories`의 마지막 분기가 악세서리 색이고 **기본 악세서리 색이 `(0.1, 0.1, 0.1)`**이라, 그 계열을 장착하면 재화 라벨이 거의 검정으로 깔려 안 보이는 수준까지 간다. 도구만 낀 상태면 망 색(0.95 크림)이라 미묘하고, 장비 조합에 따라 증상이 오락가락해 재현이 어려운 형태다. 같은 파일의 `DrawItemPreview`는 `prevCol`을 저장·복원하고 `Draw`도 스스로 되돌린다 — **저자가 이 위험을 알고 두 곳엔 방어를 했는데 공개 진입점 하나만 빠졌다.** `CashShopUI:254`가 `DrawWithOutfit` **직전에** `GUI.color = Color.white`를 두고 있는 것도 같은 오해의 흔적이다(복원이 필요한 쪽은 호출 뒤다). 공개 진입점이 스스로 되돌리게 고쳐 호출부가 이 사정을 몰라도 되게 했다. **P1-②: `DrawOutfitAccessories`가 `OutfitCache`를 우회한다.** 이 파일의 캐시 블록은 주석에 *"매 OnGUI 호출 시 PlayerPrefs 5회 + GetEquipped 8회를 60FPS×13회/초=780회/초 차단"*이라고 목적을 못박아 뒀는데, 정작 `DrawOutfitAccessories:509`가 `PlayerPrefs.GetInt(SaveScope.PrefsKey("InsectGame.Character.Gender"))`를 **매 패스 직접** 읽고 있었다 — 값은 이미 `cache.gender`에 있다. `PrefsKey`→`AuthManager.ScopedKey`는 `baseKey + "." + uid` **문자열을 매번 새로 만들므로** 네이티브 조회에 더해 프레임 할당까지 붙는다(OnGUI는 Layout·Repaint·입력마다 패스가 돌아 초당 60회보다 많다). 유일한 호출부 `DrawWithOutfit`이 이미 `gender`를 지역에 들고 있어 매개변수로 넘겼다. 이제 이 파일의 `PlayerPrefs` 접근은 `RefreshCache` 5줄뿐이다. **거짓양성으로 제외 2건**: ①`SortRecipeByDepth`가 static `recipeOrder[32]`를 공유해 "동시 호출 시 뒤섞임"으로 보이지만 Unity는 단일 스레드고 정렬→그리기가 `DrawRecipePreview` 안에서 동기적으로 끝난다. 32칸 상한의 조용한 절단도 확인했으나 **실제 레시피 45개의 파츠 수는 최대 2개**라 도달 불가. ②`EnsureSubscribed`가 매니저 파괴 시 `-=`를 건너뛰는 것처럼 보이지만, 파괴된 MonoBehaviour는 Unity의 `!= null` 오버라이드가 false를 주고 그 이벤트도 함께 사라지므로 결과가 같다(새 매니저가 오면 재구독 + 캐시 무효화도 정상). 검증: error CS 0, ci_check 8검사 통과, PlayMode **405/405**(둘 다 OnGUI 경로라 `rules/testing.md`의 IMGUI 제외 대상 — 신규 테스트 0). +- 2026-08-07: RegionTerrainBuilder **재감사** (2026-05-21 감사 이후 1147줄 변경, 큐 마지막 항목) — **P1:1 처리 + 큐 재생성.** **P1: 월드 지형지물이 실행마다 다른 자리에 선다.** 이 빌더는 `Random.Range`를 **87번** 부르는데 프로젝트 어디에도 `Random.InitState`가 없다 — Unity 전역 난수는 실행마다 다른 상태에서 시작하므로 나무 25그루·통나무·바위·죽은나무·버섯·생울타리·아치·폐허 벽/기둥의 배치가 **매 실행 새로 뽑힌다.** 장식이면 넘어갈 일이지만, `Prim()` 55곳 중 **9곳이 collider를 남긴다**(나무 trunk·통나무·숲 바위·죽은나무·산 바위·생울타리·아치 기둥·폐허 벽·폐허 기둥). 그 9종은 `PlayerMovement.IsBlockedPosition`의 OverlapSphere가 막는 실제 장애물이라, **지나갈 수 있는 길 자체가 실행마다 바뀐다** — 어제 걷던 경로가 오늘 막히고, 그런 버그는 재시작하면 사라져 재현조차 되지 않는다(`CameraFollower.ResolveObstruction`의 차폐 판정에도 같은 콜라이더가 걸린다). 고정 시드(`TerrainLayoutSeed`)로 배치 구간만 가두되 **`Random.state`를 저장·복원**했다 — 전역 난수를 고정한 채 두면 스폰·IV·포획 판정까지 결정론이 되어 훨씬 나쁜 문제가 되고, 빌드 중 예외가 나도 반드시 되돌아가도록 `finally`에 뒀다. 세션 안의 다양성은 그대로고 세션 사이의 안정성만 생긴다. **거짓양성으로 제외 3건**: ①`Mat()`이 `new Material`을 64번 만들고 정리 코드가 없어 누수로 보이지만, 지형은 부팅 시 1회 짓고 씬 수명 내내 존재하는 영구 객체다(마네킹·연출처럼 반복 생성·파괴되는 경로가 아니다). ②그 64개가 루프 안에서 만들어지면 오브젝트마다 머티리얼이 하나씩 생겨 배칭이 깨지는데, **실측 결과 루프 안 호출은 0건**이고 `BuildFenceArc`처럼 루프 밖에서 한 번 만들어 60개 기둥이 공유한다 — 이미 올바른 형태다. ③`Prim()`이 `SetParent`를 하지 않아 씬 루트가 지저분해지지만 `BuildAllRegions`의 호출부는 `PlaySceneBootstrap` 1곳·1회뿐이라 중복 생성이나 정리 누락이 생기지 않는다. **통과 확인**: 랜덤 소품이 본 마을 부지를 피하는 `RandomSpotAvoiding`이 meadow의 랜덤 배치 2곳 모두에 걸려 있고(상수는 `VillageBuilder`가 단일 출처), 나무 배치 반경이 최대 0.65R이라 1.0R 근처의 fence gateway(통로)를 막지 않으며, `centerPosition.y`가 7리전 모두 0이라 과거의 `c.y` 이중 합산 계열 회귀는 재발 여지가 없다. MonoBehaviour이지만 생명주기 콜백·이벤트 구독·싱글턴 참조가 하나도 없어 검사 항목 1~4·7은 해당 없음. **큐 재생성**: 이 항목으로 Uncovered가 0이 돼 `audit_candidates.py`로 15건을 새로 뽑았다(1순위는 오늘 2961줄이 바뀐 `RaidBattleUI`). 검증: error CS 0, ci_check 8검사 통과, PlayMode **405/405**(지형 생성은 씬 의존이라 `rules/testing.md`의 MonoBehaviour 생명주기 제외 대상 — 신규 테스트 0). +- 2026-08-07: RaidBattleUI **재감사** (큐 1순위, score 33, "2026-08-06 감사 이후 2961줄 변경") — **clean P0/P1, 파일 수정 0건.** **후보 점수의 근거가 통째로 허수였다** — git numstat이 센 2961줄은 대부분 `RaidBattleUI.Draw.cs`로의 **이동**이고, 실제 추가는 116줄(그중 상당수가 주석, 5줄은 오늘 내가 넣은 카메라 호출)이다. 채점기는 "무엇이 왜 바뀌었나"를 모르고 줄 수만 세므로 **파일 분할이 곧 재감사 최우선순위로 둔갑한다** — 스킬 문서가 "점수는 열어볼 순서이지 무엇이 문제인지가 아니다"라고 적어둔 그대로다. **사장 코드 계열 기계 점검(0건)**: 이 파일의 과거 결함 3건이 전부 "5f0776f 파이프라인 교체가 남긴 죽은 배선"(`TriggerBossAttackEffect`·`FinishTurnAnnounce`·`SetBossDamage`)이었으므로, 두 파일의 주석을 걷어낸 뒤 private 필드 전량의 쓰기/읽기 횟수를 세어 불균형을 찾았다. 걸린 3건(`TeamRushMinDuration`·`UniteRushMinDuration`·`BossImpactMinDuration`)은 전부 `const`라 선언이 쓰기로 잡힌 것이고 각각 `Update`에서 정확히 한 번 읽힌다 — 진짜 사장 필드는 없다. **입력 표면 잔존 4종 전수 추적(전부 안전)**: `insectBtnRects`·`raidSkillRects`·`stanceRects`·`uniteBtnRect`는 OnGUI가 채우고 Update가 읽는데 Update가 프레임에서 **먼저** 돌아 한 프레임 낡은 값을 본다. ①슬롯 선택 → SelectSkill 전이는 `else if` 체인이라 같은 프레임에 스킬 분기가 이어 돌지 않고, ②`wantMouseClick`이 Update 말미에 매 프레임 소거돼(이전 라운드가 고친 자리) 배너를 넘긴 탭이 슬롯 선택으로 새지 않으며, ③`stanceRects`는 그리는 곳이 `DrawInsectSelector` 하나뿐이고 히트 테스트도 SelectInsect에서만 하며, ④`uniteBtnVisible`은 `DrawUniteGaugeBar`가 Intro/Result에서 **세팅 전에 early-return**해 낡은 값이 남을 수 있으나, `EndRaid`가 false로 비우고 클릭 경로가 `CanUniteAttack`(IsActive + roundStage Ready + 게이지 만충 + 생존 2 이상)을 함께 요구해 도달 불가다 — 잠재 형태이지 현재 결함이 아니라 P1로 올리지 않았다. **통과 확인**: `OnEnable`↔`OnDisable`이 컨트롤러 이벤트 5개를 정확히 짝짓고(`SubscribeRaidController`를 `AutoWire`와 공유, `-=` 뒤 `+=`라 중복 없음 — 오프닝 다시보기가 UI 루트를 토글해도 되살아난다), `AudioManager`·`TutorialQuestManager` 싱글턴은 참조 전부 null 가드, `cachedRegionMgr`가 `FindFirstObjectByType`을 레이드 승리당 1회로 묶고, `EndRaid`가 미초기화 상태로 남기는 필드들(`actionTimer`·`bossShake`·`resultShown`·`displayBossHp`)은 5초 결과 화면에서 자연히 소진되거나 `OnRaidUpdated`의 시작 블록이 다시 채운다. **문서가 못박은 교차 불변식도 확인**: 이 파일 `OnDisable`의 `timeScale` 복구가 오프닝 다시보기를 깨뜨리지 않으려면 `ApplyReplayBlock`이 `SetActive(false)`를 `Time.timeScale = 0f`보다 **먼저** 불러야 하는데(`rules/ui-layout.md`), 현재 코드가 그 순서이고 "반드시 마지막" 주석까지 살아 있다. 코드 변경이 없어 테스트는 재실행하지 않았다(직전 검증 상태 유지: error CS 0, ci_check 통과, PlayMode 405/405). +- 2026-08-07: CollectionUI **재감사** (2026-05-21 감사 이후 913줄 변경, 큐 1순위, score 226) — **P1:1 처리 + 사장 코드 2건 제거.** **P1: 보유 목록이 행마다·매 패스마다 문자열 4개를 새로 만든다.** `DrawInsectItem`이 ①`data.rarity.ToString()`(enum → **박싱** + 문자열) ②`" | " + SizeLabel(SizeMm(...))`(계산 + 할당 + 연결) ③`$"Lv.{level} | {rarity} | IV: {n}%{size}"` ④`$"HP:{n} ATK:{n} DEF:{n}"`를 매번 만든다. 네 값 모두 **(종·개체·레벨) 파생이라 불변**인데 OnGUI는 Layout·Repaint·입력마다 패스가 도므로 프레임당 여러 번 반복된다. 이 세션의 3D 썸네일 컬링 스윕이 같은 루프(`CollectionUI:381`)를 이미 손봤지만 **컬링은 "몇 행을 그리나"를 줄일 뿐 "행마다 무엇을 새로 만드나"는 그대로**라, 보이는 행 × 패스 수만큼 계속 나오고 있었다. 고치는 방법은 저장소 안에 이미 두 번 있다 — `RegionMapUI` 재감사(2026-08-04)가 "항목마다 매 패스 `$"등급 | CP n"` 보간+enum 박싱"을, `TrainingUI` 재감사(2026-08-06)가 "개체마다 정보 문자열 2개를 매 패스 생성"을 같은 방식으로 고쳤다. **CollectionUI만 남아 있었다.** 무효화 배선을 새로 만들 필요도 없었다 — `ownedCacheDirty`가 이미 `InsectUpdated` 구독으로 돌아가고, `TryLevelUpWithCandy`가 `data.level++` 직후 그 이벤트를 쏘므로(PlayerInsectCollection:389) 레벨이 바뀌면 문자열도 함께 다시 굽힌다. `cachedOwned`를 다시 만드는 바로 그 자리에 `BuildRowTextCache()`를 붙이고 행 그리기는 구운 문자열을 받기만 한다. **사장 코드 2건**: 빈 `Update()`(본문이 없어도 Unity가 매 프레임 managed→native 호출을 한다)와 빈 `DrawToggleButton()`(`OnGUI` 첫 줄에서 `isOpen` 검사보다 **앞에** 불려 화면이 닫혀 있을 때도 매 패스 돌았다) 제거 — 퀵바 진입점은 `QuickAccessBarUI`가 맡는다. **거짓양성으로 제외 1건**: score 226의 실체는 `InitDetailStyles`의 GUIStyle 30여 개 + `InitItemStyles` 5개인데 둘 다 `detailStylesReady`/`itemStylesReady` 가드로 1회만 돈다(LoginUI 183·TrainingUI 246과 같은 형태 — 채점기는 가드를 못 본다). **통과 확인**: `OnEnable`↔`OnDisable`의 `InsectUpdated` 짝이 성립하고(`-=` 뒤 `+=`), `ModalUIRegistry` 등록/해제가 `Toggle`·`CloseModal`·`OnDisable` 세 경로 모두에 있으며, `TutorialQuestManager.Instance`는 null 가드, 상세 패널의 스크롤 뷰포트가 `Mathf.Max(1f, ...)`로 음수를 막고, 설명 라벨이 `UIHelper.LabelFit`을 써 고정 84px 상자에서 잘리지 않는다. 상세 패널·학습기술 목록도 매 패스 문자열을 만들지만 **한 번에 개체 하나 · 5행 안팎**이라 목록과 규모가 다르고, 이번 라운드는 목록만 다뤘다. 검증: error CS 0, ci_check 8검사 통과, PlayMode **405/405**(OnGUI 경로라 `rules/testing.md`의 IMGUI 제외 대상 — 신규 테스트 0). +- 2026-08-07: WorldChannelManager **재감사** (2026-05-27 감사 이후 893줄 변경, 큐 1순위, score 29) — **P1:1 처리.** **P1: 온라인 필드의 내 레벨이 항상 Lv.1이다.** `joinWorld`·`syncWorld` 페이로드가 `level = PlayerPrefs.GetInt("player_level", 1)`로 채워지는데, 그 키는 **옛 저장소**다 — 전체 저장소를 뒤져보니 쓰는 곳이 `CloudSaveManager.ApplySaveData:467` **단 하나**(클라우드 복원 시 1회)이고, 실제 레벨은 `PlayerProgressController` → `player_progress.json`에 산다(`save-system.md`). 레벨업은 그 JSON에만 남고 이 키를 갱신하지 않으므로 **신규 설치·로컬 플레이는 영원히 1**, 클라우드에서 복원한 계정은 **복원 시점 값으로 굳는다.** 이 값은 서버로 올라가 다른 사람 화면에 그대로 뜬다 — `WorldFieldMultiplayerUI`의 근처 탐험가 배너(:163)·플레이어 라벨(:328)·친구 목록(:510)과 `WorldLobbyUI`의 로비 목록(:439), **4곳**이다. **이 결함은 이미 한 번 진단된 것이다** — `CloudSaveManager` 상단 주석이 *"옛은 PlayerPrefs(player_level/player_candies/player_coins)를 읽어 JSON 파일에 저장하는 실제 시스템과 어긋나 레벨/XP/캔디/코인이 클라우드에 전혀 동기화되지 않았음(항상 기본값 0/1 업로드)"*라고 적어두고 그 파일은 `progressController != null ? progressController.Level : PlayerPrefs...` 형태로 고쳤는데, **`WorldChannelManager`만 그 정정에서 빠졌다.** 같은 폴백 식을 쓰는 `LocalPlayerLevel` 프로퍼티로 교체했다(진행도 컨트롤러 우선, 없으면 옛 키). 조회는 `CacheLocalPlayer`와 같은 지연 캐시라 비용 프로필이 같고, 호출 지점도 Update가 아니라 join/sync 코루틴(최대 1초에 1회)이다. **거짓양성으로 제외 2건**: ①`AuthManager.Instance.IdToken`·`.DisplayName`을 null 가드 없이 역참조하지만 모든 진입점이 `CanStartRequest()`/`Update()`에서 `IsFirebaseReady()`(=`Instance != null && IsLoggedIn && IdToken 비어있지 않음`)를 먼저 통과한다. ②`CacheLocalPlayer`가 `PlayerMovement`를 못 찾으면 매 sync마다 `FindFirstObjectByType`을 다시 부르지만 1초에 1회이고 파괴된 참조를 되찾는 데 필요한 형태다(Unity의 fake-null). **통과 확인**: `SendRequest`에 `timeout = 12`가 있어 half-open 연결이 `IsBusy`/`syncInFlight`를 영구 true로 고정하는 소프트락을 막고, 코루틴 5종이 전부 `try/finally`로 플래그를 복구하며(이벤트 핸들러 예외에도), sync 응답이 돌아왔을 때 `CurrentWorld == null`이면 stale 응답으로 월드를 되살리지 않고(`나가기 눌렀는데 재입장` 차단), `RespondInviteRoutine`이 `JoinWorldRoutine` 호출 **전에** 자기 `IsBusy`를 먼저 푸는 순서도 정확하다. 401 재시도가 `allowRetry=false`로 한 번만 돌아 무한 재귀가 없다. 검증: error CS 0, ci_check 8검사 통과, PlayMode **405/405**(네트워크 경로라 `rules/testing.md`의 외부 서비스 제외 대상 — 신규 테스트 0). +- 2026-08-07: CapturePopupUI **재감사** (2026-05-27 감사 이후 657줄 변경, 큐 1순위, score 76) — **P1:1을 5개 파일에서 처리.** `"beetle"` ⊃ `"bee"`인데 bee 분기가 stag/rhinoceros/hercules/longhorn보다 앞이라 **딱정벌레 31종이 전 화면에서 벌로 그려졌다**(포획 팝업·도감·1v1·레이드 2곳). `InsectEntity.BuildModel`만 가드를 갖고 있었다 — **같은 규칙이 5곳에 복제된 구조 자체가 결함의 원인**이라, 고치는 김에 `InsectPortraitRoutingTests`로 소스에서 조건식을 읽어 가드 누락을 고정했다. 거짓양성 1건 제외(`ResolveWings`의 bee 분기는 모델이 아니라 날갯짓 파라미터라 무해). 검증: error CS 0, ci_check 8검사 통과. +- 2026-08-07: CloudSaveManager **재감사** (2026-05-20 감사 이후 669줄 변경, 큐 1순위, score 35) — **P1:1 수정.** 이번 라운드의 결함은 **같은 세션에서 방금 만든 것**이다: 2막 Phase 5의 간부 보스전이 격파 기록을 새 PlayerPrefs 키(`DefeatedLedgerBosses`)에 쓰면서 클라우드 5지점을 배선하지 않아, 기기를 바꾸면 같은 간부와 다시 싸우고 승리 보상도 다시 받았다. `defeatedGuardians`와 동형으로 붙이고 `NpcDuelController`를 `ICloudReloadable`로 만들었다(PlayerPrefs만 갈아끼우면 인메모리 캐시가 낡는다). **audit이 자기 세션의 산출물을 잡은 첫 사례** — 큐가 마침 CloudSaveManager를 가리킨 건 우연이지만, 신규 세이브 키를 만들 때 이 파일을 함께 열어보라는 규칙이 `save-system.md`에 이미 있었다. 검증: error CS 0, ci_check 8검사 통과. +- 2026-08-07: CharacterOutfitManager **재감사** (2026-05-20 감사 이후 671줄 변경, 큐 1순위, score 9) — P1:1 수정, P2:1 보고. **점수 9로 큐 최하위권인데 진짜 P1이 나왔다** — 스킬 문서의 "점수는 열어볼 순서이지 무엇이 문제인지가 아니다"가 그대로 맞았다. 수정: `GetItemsForSlot`의 OnGUI 매 패스 LINQ 할당(95벌 순회 + 배열 신규) → `Initialize`에서 슬롯별 1회 캐시. 보고만: `unlockCondition` 미평가로 조건부 의상 4벌 영구 잠금(해금 시점 결정이 게임 디자인 영역). 거짓양성 2건 제외(`Instance` 가드는 같은 식 앞 절에 있음 / `OnDestroy`에서 `Instance`를 안 비워도 Unity fake-null이 스스로 낫는다). +- 2026-08-07: TutorialQuestManager **재감사** (2026-05-27 감사 이후 681줄 변경, 큐 1순위, score 38) — **clean P0/P1, 파일 수정 0건.** 문서가 실패 양식을 이미 아는 드문 경우라(`rules/quest-system.md`의 q_team 영구정지 사고 + `quest_lint.py` 6검사) **린터가 보는 것과 보지 않는 것을 나눠** 접근했다. 린터 쪽은 7 PASS / 0 FAIL. 린터가 안 보는 3가지를 직접 대조해 전부 성립: ①퀘스트 PlayerPrefs 5키가 클라우드 4지점 + DTO에 모두 있고 `QuestUnseen`은 문서대로 의도된 로컬 전용 ②`Subscribe`/`Unsubscribe`의 `+=`/`-=`가 6/6으로 맞고 **q_team을 정지시켰던 `TeamChanged += OnTeamChanged`가 살아 있다**(그때 넣은 방어가 681줄 변경을 견뎠다) ③`ResetForNewAccount`가 `TutorialHidden`을 스코프·비스코프 양쪽 삭제. 거짓양성 2건 제외(`Instance` 가드는 같은 if의 앞 절에 있음 / `Update`는 3중 조기이탈 + 캐시된 Transform). 코드 변경 0건이라 테스트 미재실행. +- 2026-08-07: ProceduralAudioGenerator **재감사** (2026-05-21 감사 이후 696줄 변경, 큐 1순위, score 0) — **clean P0/P1, 파일 수정 0건.** static 생성기라 검사 1~7이 대부분 해당 없어 **8번(데이터 매칭)에 라운드를 걸었고**, 요청(`AudioManager`)↔생성(`ProceduralAudioGenerator`) 문자열을 양방향 전수 대조했다 — 요청 37개 전부 처리(누락 0), 생성기 전용 24개도 계산 문자열 경로 2종(`"skill_" + ElementToString`, `PlayAmbient(environmentType)`)으로 도달한다. **거짓양성 3건 제외, 그중 둘은 내 검사 도구의 오경보라 확인 후 철회했다** — 환경음 대조 스크립트가 메서드 범위 추출에 실패해 케이스 0개를 읽고 "11개 리전 전부 무음"이라는 오경보를 냈다(파싱 실패를 결과 0으로 읽으면 안 된다는 `verify_coverage`의 교훈과 같은 형태). 보고만 한 관찰 1건: 정적 캐시에 상한이 없으나 클래스의 명시된 설계이고 실측 없이 수치를 단정할 수 없어 P1로 올리지 않았다. 코드 변경 0건이라 테스트 미재실행. +- 2026-08-07: CashShopUI **재감사** (2026-05-21 감사 이후 766줄 변경, 큐 1순위, score 203) — **clean P0/P1. P2:1 보고 후 사용자 승인으로 처리.** score 203은 거짓양성(`new GUIStyle` 29개가 전부 `stylesReady` 가드 뒤). 실결제 경로를 먼저 봤고 전부 정상 — 탭 3곳이 `CashShopManager.Instance == null` 가드로 시작하고, `PurchaseWithGems`가 사전검증→잔액확인→차감→실패시환불 순으로 원자적이다. **P2(승인 후 처리): 카드 라벨 리치텍스트를 OnGUI 패스마다 재생성**(아이템 탭 기준 패스당 36개). `CardText` 구조체 + `cardTextCache`로 품목당 1회만 굽되, **실결제 가격은 캐시에서 뺐다** — IAP 준비 후 스토어 현지화가로 바뀌므로 굳히면 틀린 가격이 남는다. 레이아웃 무효화는 `EnsureCardTextLayout(mobile)`로 회전 시 `` 태그를 갱신. 검증: error CS 0, ci_check 8검사 통과, PlayMode 405/405. +- 2026-08-07: InsectEntity **재감사** (2026-05-21 감사 이후 883줄 변경, 큐 1순위, score 8) — **P1:2 처리.** **P1-①: `AnimateWings`가 매 프레임 `transform.Find`를 두 번 한다.** `Update`가 무조건 부르는데 본문 첫 줄이 `transform.Find("WingL")` + `transform.Find("WingR")`이고, 이어서 종별 날갯짓 파라미터를 정하려고 `insectId.Contains`를 최대 10회 다시 돌린다. 둘 다 **빌드당 한 번이면 끝나는 값**이다. 가장 나쁜 건 **날개가 없는 종**이다 — 실패하는 Find가 자식 전체를 훑어 제일 비싸다. `ResolveWings()`로 노드와 파라미터를 함께 확정하고 리셋 블록 두 곳에 배선했다(못 찾아도 `wingsResolved`를 맨 먼저 세워 재시도하지 않는다). **P1-②: `Update`의 NameLabel 조회도 같은 형태** — 배틀 모델엔 그 노드가 아예 없어 전투 내내 실패하는 Find가 돌았다. `nameLabelResolved` 센티넬로 1회만. **거짓양성 2건 제외**: `AnimateShinySparkle`은 없으면 만들어 캐시하므로 2프레임째부터 조회 없음, `ClearChildren`의 배열 할당은 스폰/디스폰 1회 경로. 검증: error CS 0, ci_check 8검사 통과, PlayMode 405/405. +- 2026-08-07: AuthManager **재감사** (2026-05-20 감사 이후 630줄 변경, 큐 1순위, score 3) — **인증 코어 clean, 드리프트 1건 처리.** 토큰 로깅 0건 / `ClearAuth` 완전 / `Logout`의 플러시-후-무효화 순서 / `DeleteAccount`의 PII 부활 차단 — 급소는 전부 정상이었다. 처리한 건 `ApplyMasterPrivileges`의 **하드코딩 리전 목록**으로, 2막 6지역을 얹으며 해금 6·격파 7건이 누락돼 마스터 계정이 2막에서만 진행을 못 건너뛰었다. `RegionDefinitions`에서 파생하도록 바꾸고 테스트로 고정. **증상은 P2급인데 처리한 이유는 고치는 방법이 단일 출처 파생이라 위험이 0이고, 두면 리전 추가마다 같은 자리가 또 낡기 때문이다.** 검증: error CS 0, ci_check 8검사 통과. +- 2026-08-07: CharacterOutfitUI **재감사** (2026-05-27 감사 이후 595줄 변경, 큐 1순위, score 155) — **P1:1 처리.** 잠긴 의상 카드에 `unlockCondition` 원문 토큰이 그대로 그려져 한국어 화면에 `region_garden`·`level_15`가 노출됐다(조건부 4벌 전부). `DescribeUnlockCondition`으로 문장화하되 지역명은 `RegionDefinitions`에서 파생하고, 그리는 쪽은 `LabelFit`으로 바꿨다(고정 26px + wordWrap이라 한국어로 길어지면 잘리는 자리였다). `OutfitUnlockConditionTextTests` 5검사로 고정. **직전 CharacterOutfitManager 라운드의 P2와 겹치지만 다른 것을 고쳤다** — 그쪽은 해금 **판정**이 없다는 것이고 게임 디자인 결정이 섞여 그대로 남았다, 여기서 고친 건 **표시**뿐이다. 거짓양성 2건 제외(score 155의 GUIStyle 12개는 `stylesInitialized` 가드 뒤 — 이번 큐에서만 다섯 번째 같은 형태 / 호버 툴팁 문자열은 한 번에 카드 하나라 CollectionUI 라운드가 이미 제외한 규모). 검증: error CS 0, ci_check 8검사 통과, PlayMode 443/443. +- 2026-08-07: InsectSpawner **재감사** (2026-05-22 감사 이후 518줄 변경, 큐 1순위, score 26) — **P1:1 처리. 스포너 본체는 clean이었고 결함은 그것을 먹이는 부트스트랩에 있었다.** `GetRegionLevelRange`가 ver1 7지역만 아는 switch + `default: 5`라, 2막 6지역의 필드 스폰 상한이 47/51/55/59/63/67에 묶여 있었다(의도 48/52/56/60/64/70). 6 case를 바이블 대역대로 넣고 **default에 LogWarning**을 붙여 다음 사람이 알아채게 했다. **거짓양성 3건 제외 — 전부 내가 세운 가설이었고 코드를 읽어 철회했다**(먼 리전 낭비 스폰은 55m 거리 게이트가 이미 막고, 스폰포인트 밀도는 반경 비례라 리전 수와 무관하며, 리전 필터 할당은 5초 스로틀 뒤에만 돈다). 이 라운드의 교훈은 **"의심스러운 구조를 발견해도 방어 코드를 먼저 찾아라"**다 — 세 번 다 이미 방어가 있었고 주석이 이유까지 적어 뒀다. 검증: error CS 0, ci_check 8검사 통과, PlayMode 444/444. +- 2026-08-07: PlayerMovement **재감사** (2026-05-27 감사 이후 515줄 변경, 큐 1순위, score 23) — **P1:1 처리. 걷는 내내 매 프레임 `Collider[]`를 2개씩 할당했다.** `IsBlockedPosition`이 할당형 `Physics.OverlapSphere`를 쓰는데 Update가 이동 중 두 번 부른다(다음 위치 차단 + 끼임 감지) — 초당 120개 배열이 GC로 간다. `OverlapSphereNonAlloc` + 고정 버퍼(16)로 바꾸고 같은 형태의 `IsClearAt`도 같은 버퍼를 공유하게 했다(둘은 호출이 겹치지 않는다 — 끼임 판정 후에야 복구가 돈다). 버퍼가 차도 오판정 없음: 가득 찼다 = 이미 막을 것을 찾았다는 뜻. **거짓양성 2건 제외**: 월드 경계를 320→520으로 넓힌 게 이동 제한과 어긋날까 봤으나 이 파일엔 좌표 클램프가 없고 물리 벽이 담당한다 / 리전 순회가 7→13이 됐지만 `ContainsPoint`는 곱셈 두 번이라 무시할 수준. 검증: error CS 0, ci_check 8검사 통과, PlayMode 444/444. +- 2026-08-07: BattleTeamUI **재감사** (2026-05-21 감사 이후 487줄 변경, 큐 1순위, score 102) — **P1:2 처리. 이 라운드로 큐가 0이 되어 재생성했다**(상위 15, 신규 파일 3개 포함). ①화면 전체가 한국어인데 이 화면만 영어였다(`"BATTLE TEAM"`·`"Select Insect for Slot {n}"`·`"< Back"`) — 퀵바와 HUD가 이 화면을 "배틀팀"이라 부르므로 같은 말로 맞췄다. 화살표는 ASCII `<` 유지(전각으로 바꿨다가 폰트 아틀라스 확실성 때문에 되돌렸다). ②빈 `Update() { }` 제거 — 본문이 없어도 매 프레임 managed→native 호출이 든다(같은 날 CollectionUI 라운드가 지운 것과 동일). **거짓양성 3건 제외**: score 102의 GUIStyle 11개는 `teamStylesInit` 가드 뒤로 **이번 큐에서만 여섯 번째 같은 형태** / 피커 목록은 이미 `GetVisibleRowRange`로 컬링됨 / 슬롯 문자열은 5개 고정이라 기존 라운드가 명시적으로 제외한 규모. 검증: error CS 0, ci_check 8검사 통과, PlayMode 444/444. +- 2026-08-08: StoryJournalUI **신규 감사** (큐 1순위, score 41, 한 번도 감사된 적 없는 2막 신규 파일) — **P1:1 처리. 탭 한 줄이 프레임마다 스토리 전체를 훑었다.** `DrawChapterTabs`가 챕터 15개마다 그 챕터 비트를 세는데 `HasSeen`이 `List.Contains`라, 다 본 세이브에서 **패스당 5천 회가 넘는 문자열 비교** + 보간 문자열 15개가 들었다(OnGUI는 프레임당 2패스 이상). 열람 상태는 저널을 열어 둔 동안 불변이므로(다시 읽기가 seen을 마킹하지 않는 게 이 화면의 설계다) `SeenCount`를 키로 라벨과 `done==0`을 캐시했다. **자체 회귀 1건 즉시 되돌림** — 배경색 판정을 캐시된 라벨의 문자열 파싱으로 되읽게 썼다가 매 패스 문자열 연산을 도로 넣는 꼴이라 `bool[]` 동반 캐시로 고쳤다. **거짓양성 3건 제외**: score 41의 실체는 struct(`new Rect`/`new Color`)와 `stylesReady` 가드 뒤 GUIStyle 7개로 **이번 큐에서만 일곱 번째 같은 형태** / `UITheme.Instance`는 정적 캐시라 첫 호출만 Resources.Load / 비트 목록의 컬링 부재는 도감 P0와 형태가 같아 보이나 3D 썸네일 요청이 없어 LRU 스래싱이 없고 최대 8행이라 뷰포트에 거의 다 들어온다. 검증: error CS 0, ci_check 8검사 통과, PlayMode 459/459. +- 2026-08-08: InsectExpansion2Definitions **신규 감사** (큐 1순위, score 0, 2막 신규 파일) — **데이터는 clean, 결함은 "짝"을 아무도 검사하지 않는다는 것.** 54종의 ID 유일성·1막 비충돌·레어도 밴드·등급분포·풀배정·BuildModel 라우팅 전수 정상(파일 수정 0건). 시드 주석이 habitat ↔ InferPrimaryType zone 폴백을 **짝**이라 못박았는데 기존 테스트는 시드 쪽만 자기 하드코딩 목록과 봤다 — 부트스트랩에서 분기가 사라지면 통과하고 그 리전 종의 속성이 전부 조용히 Bug가 된다. Epic/Legendary 12종의 전용기 switch도 같은 형태로 무검사였다. 둘 다 소스 대조 테스트로 고정하고 **오타·삭제를 주입해 검출력을 실측**했다. **거짓양성 2건 철회 — 둘 다 내 가설이었다**: zone 폴백 누락(grep -A 45 범위가 짧았다, 실제로는 6개 전부 존재) / IsAntInsectId가 mantis를 잡을 것(StartsWith("ant_")||"_ant_"||"antlion"이라 안 걸림). 이번 큐에서 세 번째 같은 교훈 — **의심스러운 구조를 보면 방어 코드를 먼저 찾을 것.** 검증: error CS 0, ci_check 8검사 통과, PlayMode 461/461. +- 2026-08-08: NpcBossDuels **신규 감사** (큐 1순위, score 0, 2막 신규 파일) — **표는 clean, 결함은 주석이 약속한 검증이 실제로는 없다는 것.** "아이템 ID는 ItemDatabase에 실재해야 하며 NpcBossDuelTests가 정합을 고정한다"고 적혀 있으나 그 테스트는 **비어 있지 않은지만** 본다(곤충은 실재성을 본다). 오타를 물면 조용히 실패해 **간부를 이겼는데 보상만 안 들어온다**. 아이템 ID가 네 소스의 합집합이라 그 레지스트리를 이미 모으는 quest_lint.py에 검사 4b를 붙이고, 추출 0건이면 ExtractorBroken을 던지게 해 파서가 낡았을 때 조용히 죽지 않게 했다. **오타 주입으로 검출력 실측**(net_gold → net_goldd 검출, 현재 3건은 전부 실재). 주석도 "고정하는 곳이 둘로 나뉜다"로 정정했다 — 약속과 실제가 어긋난 게 본질이라 문서를 두면 다음 사람이 또 믿는다. 통과 확인: 상대 곤충 3종 실재, storyNpcId 3개가 story.json NpcTalk 트리거와 일치, ledger_ink 제외(아군화), 호출부 3곳 배선. 검증: error CS 0, ci_check 8검사 통과, PlayMode 461/461. +- 2026-08-08: OutfitShapeLibrary **재감사** (2026-08-06 감사 이후 1053줄 변경, 큐 1순위, score 0) — **P1:1 처리. 직전 PlayerVisualBuilder 라운드가 고친 머티리얼 누수의 "나머지 절반"이었다.** 그 라운드는 `MakeMaterial` 생성 지점에 등록을 걸어 17~20개를 덮었는데, spawn 의상 파츠는 생성 지점이 `OutfitShapeLibrary.CreatePartMaterial`로 따로 있어 그 목록에 들어가지 않는다. 이쪽 정리는 `TrimContainer`뿐이고 그건 갈아입을 때 **남는** 파츠만 지우므로, **마지막까지 입고 있던 파츠**가 루트 파괴 시 샌다 — 마네킹은 파괴가 정상 수명이라(외형 해시가 바뀔 때마다 재생성) 커스터마이즈 토글 한 번마다 **최대 22개**(hat 7 / acc 6 / outer 5 / bag 4)가 누수됐다. `DestroySpawnedMaterials`를 신설해 `OnDestroy`가 자기 목록을 비운 직후 부르게 하고, bind 노드 이중 파기(캐릭터가 분홍색이 된다)를 막는 `OP_` 접두 계약을 `BindNames_NeverStartWithSpawnPrefix`로 고정했다. **거짓양성 2건 철회 — 둘 다 내 가설이었고 코드를 읽어 접었다**: hideNodes 복원 부재(→ 복원은 `ApplyPartColor`가 하고 `ApplyToCharacter`가 ApplyPartColor → ApplyShapeRecipe 순서로 부른다, 주석이 그 계약을 명시해 뒀다) / `TryGet`의 부분문자열 오매칭(→ Tool만 Contains 사슬이고 나머지 슬롯은 `ExactRecipes.TryGetValue` 정확 매칭). 검증: error CS 0, ci_check 8검사 통과, PlayMode 462/462. +- 2026-08-08: CharacterModelPreviewRenderer **재감사** (2026-08-06 감사 이후 476줄 변경, 큐 1순위, score 19) — **P1:2 처리.** ①`RenderTexture.Release()`는 GPU 리소스만 놓고 **객체는 남긴다**. 세 정리 지점(24장 LRU 축출 · 외형 변경 시 전체 무효화 · `OnDestroy`)이 전부 `Release()`만 해서 참조 끊긴 껍데기가 쌓였다 — 외형 토글 한 번마다 최대 24장. `DisposeRT`로 `Release()` + `Destroy()`를 묶었고, **도감 3D 썸네일 `InsectModelPreviewRenderer`의 세 지점도 같은 형태라 함께 고쳤다**(이번 세션 네 번째 "형제 파일 복제" 결함). ②마네킹이 씬 루트에 있어 컴포넌트가 죽어도 고아로 남았다 — `Destroy(mannequin)`이 외형 변경 경로에만 있고 수명 종료 경로엔 없었다. `OnDestroy`에 추가하니 직전 라운드가 붙인 `DestroySpawnedMaterials`까지 연쇄로 도달한다. 검증: error CS 0, ci_check 8검사 통과, PlayMode 462/462. +- 2026-08-08: PlayerStatusHUD **재감사** (2026-05-21 감사 이후 451줄 변경, 큐 1순위, score 98) — **P0:1, P1:2 처리.** ①**"보유" 숫자가 세션 내내 고정.** `AutoWire`가 `insectCollection`을 대입만 하고 구독을 안 걸었다 — Bootstrap이 `EnsureComponent` → `AutoWire` 순서라 `OnEnable`은 참조가 null인 상태로 지나가고, 그러면 `ownedCountCacheDirty`를 되살릴 경로가 사라진다. 바로 아래 `regionManager`가 같은 이유로 이미 지연 구독을 하고 있는데 여기만 빠졌다. `subscription_lint`는 해지↔재구독 짝만 봐서 "애초에 구독된 적 없음"은 못 잡는다. ②**펼침 패널 아래 깔린 버튼이 여전히 클릭을 받았다** — 패널(ContentTop~+540)이 미니맵·퀘스트 칩·목표 행을 통째로 덮는데 IMGUI는 겹침으로 입력을 막지 않는다. 오늘 Phase A가 그 자리에 목표 행 버튼을 놓아 실입력 결함이 됐고, `expanded` 기본값이 true라 데스크톱은 첫 프레임부터 해당된다. `IsExpanded` + `MinimapUI.LeftStackOccluded`로 좌측 스택이 스스로 빠지게 했다. ③**서브에리어 배너만 y가 70/74/110으로 박혀** 노치 기기에서 상태바 뒤로 숨었다 — 하네스를 도입한 바로 그 플랫폼에서 실패하는 자리. `ContentTop + 70`으로 옮겨 상단 중앙 관례(ContentTop → +30 → **+70** → +150)를 지켰다. **거짓양성 제외**: struct 할당, `stylesInitialized` 가드 뒤 GUIStyle(이번 큐 여덟 번째 같은 형태), `subscribedSubArea` 플래그가 이중구독·누수를 이미 방어. 검증: error CS 0, ci_check 8검사 통과, PlayMode 505/505. +- 2026-08-08: GachaBoxManager **재감사** (2026-05-20 감사 이후 291줄 변경, 큐 1순위, score 8) — **P1:1 + P2:1 처리.** 확률 공시의 구멍은 표기가 아니라 **풀 배치**였다. `data_lint`가 전용 풀·임계 분포·UI 파생은 지키는데 **`normalPool` 티어 배치는 무검사**였다 — 이 파일 주석이 "티어 ID는 DB 실제 rarity와 일치해야 한다"고 못박아 뒀는데도. `GetDbRarity`는 표시만 보정하고 풀 선택은 안 바꾸므로 어긋나면 브론즈가 55%로 상위 곤충을 흘린다(공시 ≠ 실제 분포). 검사 10번을 붙였고 **현재 64종 전부 정합**이라 회귀 방지가 목적이다. **검출력은 등급 변조·DB 누락을 각각 주입해 실측**했다. P2로 도감 조회 lazy 캐시를 맞췄다(`database`만 캐시가 있었다). **P2 보고**: 전용 곤충 확률 50/30/20%가 공시 문자열에 없다 — 법적 판단이 섞여 결정 위임. 검증: error CS 0, ci_check 8검사 통과, PlayMode 526/526. +- 2026-08-08: CameraFollower **재감사** (2026-05-21 감사 이후 287줄 변경, 큐 1순위, score 0) — **P0:1, P1:2 처리. P0는 같은 세션에서 두 시간 전에 내가 만든 것이었다.** Phase D의 `CutsceneDirector.Play`에 재진입 가드가 없어, 재생 중 재호출 시 "되돌릴 상태"가 **컷신 1번이 스스로 만든 값**(battleMode/frozen=true)을 읽고 `Stop()`이 복구를 둘 다 건너뛴다 — 조작이 영영 안 돌아오고 카메라도 배틀 모드에 갇힌다. 클래스 주석이 스스로 경고해 둔 바로 그 상태다. `if (playing) Stop();` 한 줄로 막았다. 도달 경로는 지금 데이터로는 안 닿지만(컷신 2비트가 둘 다 SubAreaEnter) **위험이 비대칭**이라 처리했다 — 방어는 한 줄, 증상은 영구 먹통. **P1-1**: `restoreBattleMode`일 때 `battlePos`가 컷신 마지막 컷 값으로 남아 전투 카메라가 그 구도로 굳었다 → `GetBattleFraming` 신설로 원복. **P1-2(컷신과 무관한 기존 결함)**: 쉐이크가 역대 최댓값을 붙들고 매 호출마다 되감아, 강한 흔들림 뒤의 약한 호출이 **강한 것을 처음부터 재생**했다. 레이드처럼 타격이 연달아 오면 영영 잦아들지 않는다. 비교 대상을 "지금 남아 있는 세기"로 바꾸고 순수 함수 `ResolveShake`로 분리해 7건으로 고정. **거짓양성 1건 — 내가 위임 전에 세운 가설이었다**: 매 프레임 `EnterBattleModeFramed`가 복귀 지점(`normalPos`/`normalRot`)을 덮을 줄 알았으나 그 둘은 **저장소 어디서도 읽히지 않는 죽은 필드**다(복귀는 `battleTransition` 감쇠로 산 위치를 향한다). 검증: error CS 0, ci_check 8검사 통과, PlayMode 533/533. +- 2026-08-08: WorldLobbyUI **재감사** (2026-05-27은 텍스처 누수만 처리, 그 뒤 267줄 변경, 큐 1순위, score 94) — **P0:1, P1:2 처리. P0: 월드 선택 화면 뒤에서 캐릭터가 걸어 다녔다.** `IModalUI` 미구현 + `ShowLobby()` 정상 경로에 `SetFrozen(true)` 누락이라 WASD(frozen 게이트)와 클릭 이동·포획(`IsAnyOpen()` 게이트)이 전부 살아 있었다. **계약 불일치가 증거** — 같은 phase로 들어가는 `OnWorldLeft`는 "로비로 돌아오면 이동 잠금" 주석과 함께 이미 잠그고 있었고 최초 진입만 빠졌다. Bootstrap 주석이 그 자리를 뺀 이유(옛 영구 frozen 버그)를 적어 뒀기에 **되돌리기 전에 퇴출 경로를 전수 확인**했다: WorldSelect를 빠져나가는 길 셋이 전부 해제하고, 유일한 예외 `HideLobby()`는 호출부 0인 죽은 메서드라 거기에도 해제를 넣어 재발을 막았다. phase 대입 10곳을 `SetPhase()` 단일 통로로 모아 등록/해제 누락을 구조적으로 없앴다. `CloseModal`은 InWorld 오버레이만 닫는다 — 월드 선택은 필수 단계라 ESC 하나로 온라인을 포기시키지 않는다(Explore 권고와 다르게 판단). **P1-1**: `CanStartRequest`가 `IsBusy`면 **이벤트조차 안 쏘고** 반환하는데 호출부가 phase를 먼저 Joining으로 바꿔, 5초 주기 목록 새로고침과 겹친 클릭이 증발하고 화면만 "접속 중"에 갇혔다 → `BeginJoinRequest()`로 `IsBusy` 선확인 + 15초 타임아웃 폴백. **P1-2**: 에러 표시가 월드 선택 패널에만 있어 나가기 실패 시 화면에 아무 변화가 없었다("눌러도 아무 일 없는 버튼") → InWorld에도 에러 라벨 + 요청 중 비활성화. 거짓양성: `stylesInitialized` 가드 뒤 GUIStyle(이번 큐 아홉 번째 같은 형태). 검증: error CS 0, ci_check 8검사 통과, PlayMode 533/533. +- 2026-08-08: AudioManager **재감사** (2026-05-21 이후 263줄 + 오늘 변경분, 큐 1순위, score 13) — **P1:2 처리. 둘 다 "오늘 내가 넣은 기능"과 부트스트랩 사이에서 났다.** ①**게임 시작 시 리전 곡이 안 나왔다** — `DelayedAudioStart`가 2프레임 뒤 무조건 `PlayBGM(Explore)`를 부르는데 그 사이 frame 0에 `RegionChanged(meadow)`가 이미 리전 곡을 걸어 놨다. 조기 반환 가드는 "같은 곡이면 무시"라 다른 곡인 이 호출이 통과해 **리전 곡 13개가 시작할 때마다 범용 곡에 덮였다**(리전을 옮기거나 첫 전투가 끝나야 제 곡이 나오는 역전). 오늘 넣은 `RestoreExploreBGM()`이 부트스트랩보다 정확해 그걸로 교체. ②**보스 BGM에 긴장 램프가 안 걸렸다** — Update 피치 조건이 `Battle || RaidBattle`을 직접 적어 둔 형태라 보스 2종이 else로 떨어져 피치가 1.0으로 되돌려졌다. **enum 주석이 등록을 "3지점"이라 못박아 둔 것이 화근** — 이 분기가 목록에 없는 4번째 지점이었다. `IsCombatBgm`으로 모으고 주석을 4지점으로 정정, 테스트 추가 후 **누락을 주입해 검출력 실측**. **내가 의심으로 지목했던 2건은 Explore가 clean 확인**: 보스전 복귀는 승/패/도주/fallback이 전부 `BattleEnded`로 수렴 / 서브에리어는 BGM을 안 건드리고 `subAreaSticky`가 위치 판정을 스킵해 `lastExploreBgm`이 리전 기준으로 정확. **P2 보고**: `SettingsPanel`이 어느 씬에도 없어 인게임에서 볼륨 조절 수단이 없다(선재 문제, UI 신설 필요). 검증: error CS 0, ci_check 8검사 통과, PlayMode 534/534. +- 2026-08-08: PlayerInsectCollection **재감사** (2026-05-21 이후 256줄 변경, 큐 1순위, score 24) — **P1:1, P2:2 처리. 세이브의 심장부라 되돌리기가 가장 어려운 영역.** ①`GetAllOwned()`를 캐시 없이 매 OnGUI 패스에서 부르는 호출부 2곳이 남아 있었다(나머지 5곳은 이미 캐시, 같은 파일 `OwnsShiny`도 같은 함정을 고쳐 놓은 상태였다). **공유 캐시 대신 재사용 버퍼 `OwnedView`를 따로 뒀다** — 캐시면 무효화 지점 하나만 빠뜨려도 "안 보이는 곤충"이 되고, 기존 호출부 다섯은 반환값을 보관하므로 공유 버퍼를 주면 그쪽이 깨진다. ②**P2: 문서가 보증한다고 적은 `EnsureHp` 보정이 부트에서 한 번도 실행되지 않았다** — `AddComponent`가 부르는 `Awake`→`LoadAndIndex`가 `AutoWire`보다 먼저 돌아 `database`가 null이고, "다음 로드에 미룸" 분기가 전부 스킵되는데 그 다음 로드도 순서가 같아 영영 오지 않는다. 증상이 없는 이유까지 확인했다(음수를 풀피로 보는 `GetEffectiveHp` + `IsFainted`가 `==0`)지만, 가드 없는 소비자가 하나만 생기면 터지고 고치는 비용이 기존 루프에 세 줄이라 처리했다. ③**P2: `ReloadFromDisk`가 이벤트를 안 쏘면서 근거 주석이 낡았다** — UI 넷이 이제 목록을 캐시하고 `InsectUpdated`로만 무효화하는데, 클라우드 복원이 `saveData`를 통째로 바꾸면 열려 있던 UI가 **고아 데이터**를 들고 남아 거기서 레벨업·치료를 하면 **재화만 빠지고 변경이 사라진다**. 한 줄 발화 + 주석 갱신. **테스트는 붙이지 않았다** — `Awake`가 실제 세이브를 읽고 조건부로 덮어쓰므로 하네스가 사용자 세이브를 건드릴 위험이 이득보다 크다. 검증: error CS 0, ci_check 8검사 통과, PlayMode 534/534. +- 2026-08-08: RaidSupportPlanner **재감사** (2026-08-06 감사 다음 날 252줄 변경 — 파일 크기와 맞먹는 양, 큐 1순위, score 0) — **P1:2 처리. 직전 감사가 고친 2건은 재발하지 않았고, 문제는 이 파일이 순차 턴 커밋에서 한 줄도 안 바뀌었다는 것이었다** (컨트롤러 391줄·리졸버 11줄이 바뀌는 동안 플래너만 옛 전제로 남았다). ①**이번 라운드에 이미 명중한 기절을 모른다** — 기절은 팀 전체에서 한 번만 판정되는데 플래너는 "지난 라운드 면역"만 받는다. 순차 턴에서 슬롯 0이 맞히면 나머지 4명의 기절은 확정 무의미인데 Guard 가중치가 1.4로 최고라 그대로 골라져 **라운드당 최대 4턴이 날아갔다**. 호출부 한 줄(`|| roundStunLanded`). ②**전체공격이 꺼지면서 방어 버프가 5명 중 4명에게 영구히 죽었다** — `RaidBossUsesAreaAttack = false`라 `incomingArea`가 상시 false이고 게이트가 "지목된 한 슬롯"으로 축소됐다. 레이드 버프는 만료가 없어 지목과 무관하게 값이 있는데 옆의 `BuffAttack`만 `×2f` 다라운드 가치를 받는 비대칭이었다. 게이트를 없애되 밸런스 변화를 최소화(미지목 `×0.5`). **테스트가 초록인 채 통과하던 이유도 고쳤다** — 두 테스트가 AOE 예고로만 검증해 프로덕션이 안 타는 분기를 고정하고 있었다. 단일 대상으로 바꾸는 과정에서 **내 기대값이 틀린 것도 하나 잡았다**(단일 대상 기절 가치는 0.4배라 Assault로는 데미지가 이기는 게 정상). **검출력 실측**: 옛 게이트 주입 시 스탠스 테스트가 정확히 실패. 검증: error CS 0, ci_check 8검사 통과, PlayMode 534/534. +- 2026-08-08: CashShopManager **재감사** (2026-05-22 감사 이후 239줄 변경, 큐 1순위, score 14) — **P1:1 처리. 돈이 오간 직후 클라우드에 확정하지 않았다.** 릴리스의 유일한 지급 경로인 `ApplyVerifiedGemBalance`에 `SaveToCloud()`가 없고, 정작 **에디터 전용** `GrantGemPackage`에만 있었다 — 결제 직후 종료하면 120초 자동저장을 못 만나고 다음 실행의 클라우드 복원이 결제 전 잔액을 덮는다("결제했는데 보석이 없다"). 두 경로를 대칭으로 맞췄다. 결제 코어 자체는 정상이었다: 클라이언트가 rewardCount를 더하지 않아 중복 지급이 없고, 프로덕션 모듈 부재 시 무료 지급 대신 거부하며, 보석 결제가 사전검증→차감→실패 시 환불로 원자적이다. **P2 보고 2건**(보석 *소비*의 즉시 저장 범위는 아키텍처 결정 / 사전검증·지급의 FindFirstObjectByType 중복 조회). **거짓양성 1건 철회**(가챠 `isOpening` 가드는 동기 실행이라 도달 불가). 검증: error CS 0, ci_check 통과, PlayMode 534/534. +- 2026-08-08: CashShopManager **재감사** (2026-05-22 이후 239줄 변경, 큐 1순위, score 14) — **P1:2 처리 + P1:1 보고만. 재화를 다루는 파일이라 결함이 곧 손실이다.** ①**젬 소비가 클라우드에 확정되지 않아 복제가 가능했다** — 서버 IAP 지급이 Firestore `gems`(클라이언트 PATCH로만 갱신)를 읽어 더하는데, `PurchaseWithGems`가 `SaveToCloud()`를 안 불러서 자동저장 전에 결제하면 **차감 전 잔액**에 보너스가 붙는다. 지급 경로 둘은 이미 저장하고 **소비 경로만 빠진 비대칭**이었다. ②**재검증이 로컬 잔액을 뒤집었다** — 이미 청구된 토큰은 서버가 `newlyGranted: false` + 마지막 PATCH 시점 잔액을 주는데 그걸 절대 세팅해서, 그 사이 번 젬은 증발하고 쓴 젬은 되살아났다. `newlyGranted`는 **DTO에 이미 있었고 호출부가 안 넘기고 있었다**. 같은 메서드의 `Max(0, verifiedBalance)`도 함께 고쳤다 — 신규 문서(gems 필드 부재)면 **결제 직후 잔액이 통째로 0**이 됐다(CloudSaveManager가 같은 필드에 `-1` 센티넬을 두는 것과 정반대). ③**보고만**: 레거시 젬 마이그레이션이 로그인 전 전역 지갑에 병합한 뒤 PlayerPrefs 키를 지운다. 키 삭제만 막으면 다음 AutoWire에서 또 병합돼 **젬 복제**가 되고, 시점을 옮기려면 AutoWire가 부트 1회인 구조를 바꿔야 한다 — 재화가 걸려 자동 처리하지 않았다. **결함 아님**: 클라우드 5지점 전부 배선 / 구매 원자성(사전 검증 + 차분 환불, 이중 환불 없음) / 중복 구매 가드 2종. 검증: error CS 0, ci_check 8검사 통과, PlayMode 534/534. +- 2026-08-08: RaidRoundResolver **재감사** (2026-08-03 이후 234줄 변경, 큐 1순위, score 0) — **P1:1, P2:1 처리. 리졸버 자신은 순차 턴 개편과 정합했고 결함은 그것을 부르는 컨트롤러의 합체 경로에 있었다** (직전 라운드가 "주변이 바뀌었는데 이 파일만 안 바뀜"이었다면 이번은 그 반대다). ①**합체를 라운드 중간에 쓰면 이미 행동한 슬롯이 한 라운드에 두 번 때린다** — 개편 전엔 합체가 팀 턴을 대체했지만 지금은 게이지가 슬롯마다 차올라 팀 턴 도중에 100을 넘고 `CanUniteAttack`은 `ActiveSlot`을 안 본다. **컨트롤러 주석이 반대쪽 절반만 막고 있었다**("아직 차례가 안 온 팀원"은 처리, 이미 행동한 쪽은 미처리). `HasActedThisRound(i)` 한 줄 + 합체 이전 피해 이월(새 결과 객체로 갈아끼우며 버려져 라운드 집계·UI 기여도가 과소 표시됐다). ②**P2: `allowAreaAttack` 기본값이 `true`** — 인자를 빠뜨린 새 호출부가 조용히 AOE를 부활시키고 그 경로는 rageMultiplier를 무시해 곧 전멸이다. 필수 인자로 전환(컴파일 오류 0 = 모든 호출부가 이미 넘기고 있었다). **검출력 실측**: 두 수정을 되돌리니 신규 테스트 2건이 각자의 결함을 정확히 검출. **정합 확인**: 리졸버는 라운드 상태를 안 들고 전부 호출 시점 라이브 읽기 / 기절은 컨트롤러가 라운드당 1회 소비 / `LowestHpAlly`가 죽은 슬롯을 거르고 플래너와 같은 함수를 쓴다 / 버프 스택은 대상별 `TryStack*`으로 상한에서 정확히 멈춘다. 검증: error CS 0, ci_check 8검사 통과, PlayMode 536/536. +- 2026-08-08: RegionManager **재감사** (2026-05-20 감사 이후 207줄 변경, **큐 2순위 — 1순위 `RaidRoundResolver`는 다른 세션이 미커밋으로 편집 중이라 건너뛰었다**) — **P1:1 처리. 진행 사슬이 세 곳에 적혀 있는데 둘만 대조하고 있었다.** 런타임 switch(③)가 빠져도 데이터(①)↔테스트 사본(②)만 맞으면 전부 통과했다 — Next 누락은 **진행 영구 차단**, Prev 누락은 수문장이 맵 한복판에 스폰. 소스에서 메서드 본문을 잘라 양방향 대조하는 테스트 3건을 추가했다. **자체 발견 1건**: 파일 전체 검색으로 짰다가 두 switch가 서로의 역방향이라 종착지 검사가 거짓 실패 — 중괄호 균형으로 본문만 자르도록 고쳤다. **검출력은 간선 삭제·주입 3종으로 실측**. 거짓양성 1건 철회(PlayerPrefs 키는 이미 `SaveScope.PrefsKey` 스코프). 검증: error CS 0, PlayMode 539/539. +- 2026-08-08: 컷신 3파일 **신규 감사** (CutsceneData·CutsceneLibrary + 인접 CutsceneDirector, 큐 1·2순위) — **clean P0/P1, 파일 수정 0건.** 이 세션이 다른 영역에서 반복 지적한 "짝 규칙 무검사"가 여기엔 이미 적용돼 있다: `story_lint` 검사 9번이 JSON↔Library cutsceneId를 **양방향**(실재성 + switch 미배선)으로 보고, 테스트 15건이 타임라인 경계를 `EveryInstantIsCoveredExactlyOnce`까지 덮으며, 디렉터는 재진입 가드 + 모든 종료 경로를 `Stop` 하나로 모으고 `OnDisable`도 그걸 부른다(배틀 카메라는 **구도까지** 기억해 복원). **거짓양성 2건 철회**: TotalDuration↔TryGetShot의 0 보정 불일치(실제 duration이 전부 양수고 종료 판정은 후자만 쓴다) / UI 루트 토글로 영구 frozen(World 루트라 대상 아님 + OnDisable이 복구). 검증: 코드 변경이 없어 기존 PlayMode 539/539 유효, ci_check 통과. +- 2026-08-08: RegionManager 재감사 **후속** (다른 세션이 984e3cb로 같은 라운드를 먼저 커밋 — 겹치지 않고 짝을 이룬다) — **P0:1, P1:1 처리.** 그쪽은 진행 사슬의 세 번째 사본을 테스트로 묶었고, 이쪽은 **좌표의 네 번째 사본**을 찾았다. ①**실물 수문장이 지도 마커와 다른 자리에 서 있었다** — `PlaySceneBootstrap.GetGuardianWorldPosition`이 `RegionManager.GetGuardianPosition`의 낡은 사본으로, 같은 공식을 쓰면서 이전 리전 switch만 ver1 5개에서 멈춰 있었다. ruins와 2막 6리전은 원점 기준이 되어 최대 105m 어긋났다(dunes는 연못 안, canopy는 유적 안). ruins는 2막의 유일한 문이라 **지도가 가리키는 곳에 아무것도 없으면 조용히 진행이 막힌다**. 사본 삭제 + RegionManager 직접 조회로 단일 출처화. ②**2막 서브에리어 4종에 조명 프로필이 없었다 — 내가 Phase C에서 만든 누락이다.** 지오메트리만 넣고 `SubAreaEnvironment.GetProfileForType`을 빠뜨려 전부 야외 주광으로 떨어졌다(밀폐 공간에 햇빛). **검출력 실측**: 4종 제거 시 정확히 그 넷을 지목. 좌표 사본 재발도 소스 검사로 고정. 검증: error CS 0, ci_check 8검사 통과, PlayMode 541/541. +- 2026-08-08: RaidBattleController **재감사** (순차 행동 전환 후속, 590줄 변경) — P1:2 + 문서결함:1 처리. 두 P1 모두 **"짝 규칙을 한쪽 입구만 지킨다"** 형태다: ①`CanUniteAttack`이 `AliveCount()`를 봐서 실제 참가자가 1마리인데도 열려 게이지 100을 1마리분에 태웠다(→`RemainingActors`), ②`CanUseSkill`에 `HasActedThisRound` 검사가 없어 `RaidMemberActionResolved` 창에서 한 곤충이 두 번 행동할 수 있었다(형제 입구는 막고 있었다). ③`RaidBossHpMultiplier=8.5`는 산출 근거가 무효화됐으나 **값은 유지** — 사용자가 요청한 난이도 하향을 되돌리지 않기 위해. 주석에 현재 화력(3.4→5.0유닛)과 판단 근거 기록. P2 5건은 사용자 결정 위임(합체 시 이전 액션 소실·연출 하이라이트 1슬롯 밀림·keyLabels 프레임 할당·미호출 연출 헬퍼 2개). 검증: 컴파일 0 error, ci_check 통과, PlayMode 555/555(+3 신규). +- 2026-08-08: VillageBuilder **재감사** (2026-07-17 이후 479줄 증분) — P1:2 처리 + 회귀 방지 테스트 1건 신설. 둘 다 **"주석이 약속한 것을 아무도 검산하지 않는다"**: ①병원 주석이 "빈 각도 315°"라 적었으나 집5가 320°에 있어 중심 거리 1.48m로 **집이 병원 안에 박혀 있었다**(둘 다 콜라이더 유지 → 통행 차단, 주민이 벽에 붙어 스폰). 352°(이등분선)로 이동. ②`BuildIceShelter` 얼음 블록 9개만 접선 정렬 관례(`-(a+90)`)에서 +90이 빠져 바람막이 호가 **방사형 스파이크**가 됐다 — 같은 파일의 울타리·육각벽 2곳은 지키고 있었다. `VillageBuildings_DoNotOverlapEachOther`가 실제 벽 크기로 쌍마다 겹침을 검산한다(각도를 눈으로 세는 방식이 이미 틀렸다). **거짓양성 1건 철회**: EditMode `Destroy` 로그로 테스트가 무력화된다는 지적 — 이 저장소는 PlayMode 러너를 쓰고 해당 픽스처 11건이 통과 중이다. 검증: 컴파일 0 error, ci_check 통과, PlayMode 561/561(+6 신규). +- 2026-08-08: BattleArenaController **재감사** (아레나 가시성 56줄 증분) — P1:2 처리 + UI 파일 1개 신설. 증분은 깨끗했고 결함은 **3D 컴포넌트가 화면 문구까지 직접 그리던 `OnGUI`**였다: ①`UIScale` 밖 픽셀 좌표라 스케일이 1이 아닌 기기(1.333)에서 전투 문구만 25% 작게 찍혔다 — 1920×1080에서만 우연히 맞아 Game View로는 안 보인다. `BattleEffectTextOverlay`(UI)로 옮겼다. 아레나가 `UIScale`을 직접 부르면 UI→Battle과 맞물려 순환이라 방향을 지켰고, 호출부 0이던 `GetActiveEffectTexts()`가 원래 그 이음매였다. ②고정 600×50 상자에 40px 글자 + 데이터 문자열 (`{displayName}!`)이라 세로·가로 양쪽으로 잘렸다 → 58×760 + `LabelFit`. `text_fit_lint`는 Rect 높이가 리터럴이 아니라 못 잡는 자리다. P2 3건 보고(1v1 카메라의 벽 안쪽 여부를 아무도 검산 안 함 — 레이드만 테스트로 고정돼 있다 / `new MeshRenderer[0]` 반복 할당 / 레이드 카메라 주석의 "6도"는 실제 11도). 검증: 컴파일 0 error, PlayMode 572/572. (`story_lint`는 다른 세션이 편집 중이라 KeyError로 실패 — 무관) +- 2026-08-08: **12개 영역 일괄 감사** (사용자 요청) — Explore 12개 병렬 + 전건 자체 검증. **P0:1, P1:10 처리**, 거짓양성 1건 철회, P2 다수 위임. P0은 **전멸 패배에서 `DuelEnded`가 영영 발화하지 않던 것** — `PlayerFainted`가 구독자 유무만 보고 종료를 UI에 넘기는데 UI는 벤치가 비면 컨트롤러에 되돌려 알리지 않았다. NPC 대결 쿨다운이 통째로 우회되고 `activeBossId`가 남아 **아이 대결 승리가 간부 격파로 영구 오기록**됐다(클라우드 동기라 복구 불가). 나머지 P1: 서브에리어 신규 4방 경계벽 전무(낙하) + 얼음기둥이 입구 차단 + 접선 정렬 위반, SocialPvpUI `isOpen` 누출(ESC 무효·핫키 오작동), 훈련이 `InsectUpdated` 미발화, 병원 null 가드 비대칭, 레이드 버프 오라가 엉뚱한 곤충 위에 + 단일 공격에 "TEAM RUSH ×3", 회복 버튼 라벨 잘림, `Label` default 흡수. **이 라운드의 반복 주제도 "형제 입구는 안 지킨다"였다** — 병원 4경로 중 1개만 가드, `CashShopUI`만 `isOpen` 정리, 레벨업만 `InsectUpdated` 발화, 도주만 `DuelEnded` 보강. 거짓양성 철회: 포획 UGUI 패널은 "첫 포획 뒤 사라지는" 게 아니라 **처음부터 한 번도 안 뜨는 죽은 패널**이라 켜는 것이 수정이 아니다. 검증: 컴파일 0 error, ci_check 7/7, PlayMode 576/576(+1 신규). +- 2026-08-17: StoryBeat (Story/StoryBeat.cs, 큐 1순위, score 0 표면 점검) — **P1:1 처리, P2:1 위임.** 데이터 모델 자체는 깨끗했다(JsonUtility 관례 준수, 신규 필드 전부 null 기본값, 소비자 4곳 모두 null 가드). 결함은 **소비자 쪽 enum↔switch 드리프트**였다: `StoryJournalUI.HintFor`가 트리거 타입을 **문자열 리터럴 사본**으로 switch해서, 뒤늦게 추가된 `GuardianDefeat`·`DexProgress`가 `default`로 흘러 **82비트 중 9건(11%)이 저널에서 "조건 미상"만 표시**했다 — 미열람 비트의 힌트가 저널의 유일한 존재 이유인데 수문장 6건·도감 3건이 통째로 무용했다. case 라벨을 `StoryDirector.Trigger*` const로 바꿔 사본을 없애고(같은 이유로 `StoryObjectiveResolver.KindOf`가 이미 상수를 쓴다 — 그 파일 주석이 이 함정을 정확히 예고하고 있었다) 두 케이스를 채웠다. **P2 위임**: `StoryBeat.oneShot`이 완전한 사문 — 선언(`= true`)만 있고 읽는 코드가 0이라, `oneShot: false`로 저작해도 `seenBeatIds`가 무조건 1회로 막는다(데이터가 동작을 거짓말한다). **P2도 같은 라운드에서 처리**(사용자 승인): `oneShot` 필드와 JSON 키 82개를 지우고, "모든 비트는 seenBeatIds가 전역으로 1회 보장"을 클래스 주석에 못박았다. 반복 구현은 배제했다 — 앰비언트 비트(talk_elder·talk_rival·talk_scholar)조차 캔디 5개를 주므로 그대로 반복시키면 **말 걸기 무한 파밍**이 된다(정리가 아니라 보상 재설계가 먼저다). 필드만 지우면 JsonUtility가 모르는 키를 계속 조용히 무시하므로 **검사 14(Story.json 키 ↔ StoryBeat 필드 실재성)**를 신설했다 — 되살아난 `oneShot`·대소문자 오타(`stageEnterID`)·중첩 객체 오타(`lines[].speeker`)·`trigger` 내부 오타를 주입해 정확히 그 4건만 지목하는 것을 실측했다. 검증: 컴파일 error CS 0, ci_check 8검사 통과, PlayMode 614/614. +- 2026-08-17: BattleScreenUI **재감사** (큐 1순위, 3769줄 score 372, 미커밋 증분 158줄) — **P1:1 처리.** 증분(명암비 재조정·LabelFit·`GUI.color` 곱셈 누락 수정)과 신규 `BattleEffectTextOverlay`는 clean이었고, 결함은 **직전 라운드 P0 수정의 부작용**이었다: `EnterResult` 첫 줄에 들어간 `ConcludeDefeatWithoutSwap`이 `BattleEnded`를 발화 → `OnBattleEnded`가 (phase가 아직 Result가 아니라) `EnterResult`를 **재진입** → 결과 진입이 같은 프레임에 2회 돌고 **패배 SFX·BGM이 겹쳐 재생**됐다. `ConcludeDefeatWithoutSwap`이 멱등해 무한 재귀는 없었던 탓에 조용했다. `phase = Phase.Result`를 Conclude **앞**으로 옮기고 조기 반환을 더했다 — 순서가 가드의 전부다(상태가 뒤에 있으면 재진입이 가드를 통과한다). **거짓양성 2건 철회**: 크리티컬 그림자의 `GUI.color` 검정 곱셈은 의도된 연출(다음 줄에서 복구), score 372의 프레임 할당은 `InitStyles` 단일 가드 + 콤보 스타일 2건 null 가드로 전부 방어됨. 도주 경로는 `battleEnded`를 먼저 세워 오기록 없음을 확인. 검증: 컴파일 error CS 0, ci_check 8검사 통과, PlayMode 614/614. +- 2026-08-17: StoryDirector **재감사** (큐 1순위, 536줄 score 2, 2026-07-19 이후 커밋 5건) — **P1:1 처리.** `EvaluateTriggers`가 `StoryService.AllBeats()`를 훑어 **첫 일치**를 발화했는데 그건 `Dictionary.Values`라 순서가 비결정적이다. 같은 트리거에 동시에 자격을 갖는 비트를 데이터에서 세어 보니 **5쌍**이었고, 가장 아픈 것이 마을 어르신 대화다 — 1막 개막 `ch1_intro`(프롤로그 컷신 + 은빛 그물)와 앰비언트 `talk_elder`(캔디 5)가 함께 걸려, **처음 하는 사람이 개막 대신 잡담을 볼 수 있었고 어느 쪽이 뜰지는 실행마다 달랐다.** 더 나쁜 건 HUD 목표(`StoryObjectiveResolver`)는 *결정적으로* 골라 놓고 실제 발화만 임의였다는 점 — 안내를 따라갔는데 다른 대사가 나온다. 순위 규칙을 `CompareBeatPriority`(스파인→챕터→order→beatId) 하나로 모아 두 경로가 같은 답을 내게 했고, `SelectObjectiveBeat`의 4단 비교 사본도 그 함수로 대체했다. 수정 후 5쌍 전부 상식적 승자(개막·낮은 order)로 고정됨을 데이터로 확인. 회귀 테스트 6건 신설(스파인 우선·ch10>ch2 숫자 비교·본편>곁이야기·beatId 타이브레이크·null·개막 vs 잡담 + 입력 순서 무관). 검증: 컴파일 error CS 0, ci_check 8검사 통과, PlayMode 620/620(+6 신규). +- 2026-08-17: **애니메이션 흐름 전수 점검**(사용자 요청) + OpeningSceneController 재감사 — **P1:2 처리, 관측 1건 위임.** 표시되는 움직이는 주체 12종의 구동 경로를 하나씩 따라가 **`PlayerMovement`에서 2건**을 찾았다: ①**플레이어에게 idle 애니메이션이 아예 없었다** — `AnimateWalk(false)`가 swing·bob·headTilt를 전부 0으로 떨어뜨려 멈추면 완전 정지. NPC는 조각상 방지용 호흡(`NpcWalkAnimator`)을 갖는데 플레이어만 없어, 숨 쉬는 주민 옆에 마네킹처럼 섰다(대화 중 카메라가 둘을 나란히 잡아 특히 티가 난다). ②**frozen이면 애니메이션이 통째로 멈췄다** — `Update`가 `if (frozen) return`이라 `AnimateWalk`가 안 불렸고, 대화·컷신·연출이 전부 frozen이므로 **매 스토리 비트마다 걷던 자세로 굳었다**. `catchSwingTimer`도 안 줄어 스윙 중 모달이 열리면 팔이 든 채 남았다. 같은 파형·진폭(1.5rad/s, 팔 ±1.2°, 몸통 ±1.8cm, 고개 ±4.5°)을 NPC에서 이식하고 frozen 분기에서도 애니를 돌린다. **도구 각도를 팔과 분리(`toolDeg`)한 것이 급소** — 도구 base 재캐싱이 idle 프레임마다 도는데 idle 회전을 섞으면 base로 누적돼 도구가 드리프트한다(잡기 스윙 중 재캐싱을 막아 둔 것과 같은 함정). 나머지 10종(필드/배틀/레이드 곤충·주민·잡기 아이·컷신 카메라·연출·썸네일·2D 초상·오프닝)은 구동 정상 확인. **위임**: 패널 페이드 트윈(`UIHelper.AnimatePanelOpen`+`UITween`)이 테스트까지 있으면서 모달 23개 중 1개만 쓴다 — 22개 확산은 IMGUI 알파가 `GUI.color` 곱셈이라 글자까지 물들이는 부류(직전 라운드 BattleScreenUI 결함과 동형)라 눈으로 못 보고 하기엔 위험해 보고만 한다. OpeningSceneController 재감사는 clean(P0/P1 0건). 검증: 컴파일 error CS 0, ci_check 8검사 통과, PlayMode 620/620. +- 2026-08-17: 패널 페이드 확산(사용자 승인, 추천안대로 1화면 한정) + UIHelper **재감사** — **P1:2 처리, 자체 발견 1건.** 대화창에 페이드를 넣으려다 **공유 렌더러가 페이드를 깨고 있는 것**을 먼저 찾았다: `UISurface`는 주변 `GUI.color`를 저장→곱셈→복구하는데 `CharacterPortraitRenderer`는 파츠 색을 **절대값으로 덮고** 끝에 `Color.white`로 되돌렸다 — 그래서 유일하게 페이드를 쓰던 `CharacterOutfitUI`조차 **초상 이후 그려지는 것 전부가 알파 1로 되돌아가** 페이드에서 빠져 있었다(주석에 '주변 색 복원' 교훈이 이미 있었지만 복원 대상이 흰색이라 절반만 맞았다). 공개 진입점 4개가 진입 시 `GUI.color`를 스냅샷하고 `DrawCol`이 거기에 곱하도록 고쳤다 — `LoginUI`·`CashShopUI`의 초상도 함께 정상화된다. 그 위에 `NpcDialogueUI` 페이드인을 넣었다(페이드아웃은 배제 — `CloseModal`이 `lines`/`currentBeat`을 그 자리에서 비우고 `CompleteBeat`으로 보상까지 지급해, 내용을 살리려면 비트 완료를 미뤄야 하는데 그건 영구 정지를 겪은 자리다). **구현 중 자체 발견**: `AnimatePanelOpen`이 조기 반환 뒤에 있어 닫힘 전이가 전달되지 않고 `wasOpen`이 true로 굳어 **두 번째 열림부터 페이드가 사라졌다** → `CloseModal`에서 리셋. UIHelper 재감사 **P1:1** — `FitKey`가 스타일을 키에 안 담아 서로 다른 스타일이 축소 결과를 공유했다(wordWrap이 다르면 가로 검사 여부까지 갈린다). 참조 동일성 추가 + 테스트 5건. 텍스처 캐시·DrawTinted·DrawBorder 등 나머지는 전부 주변색 곱셈/복구 정상. 검증: 컴파일 error CS 0, ci_check 8검사 통과, PlayMode 625/625(+5 신규). +- 2026-08-17: 라이브 캡처 하네스 구축(사용자 승인) + CollectionUI **재감사** — **도구 1건 신설, 감사 clean.** 배치모드에서 실제 게임 화면을 렌더해 PNG로 남기는 `Assets/Editor/LiveSceneCapture.cs`를 만들었다 — 3D 변경을 코드만 보고 판단하던 것을 눈으로 확인할 수 있게 됐다. 실측으로 확인한 함정 셋: ①`[UnityTest]`는 asmdef 부재로 `UnityEngine.TestTools` 참조가 없어 못 쓴다 ②`ScreenCapture.CaptureScreenshot`은 배치모드에 게임뷰가 없어 **조용히 실패**한다(카메라→RenderTexture→ReadPixels로 우회, 디바이스는 Direct3D11로 존재) ③플레이모드 진입의 도메인 리로드는 `SessionState`+`[InitializeOnLoadMethod]`로 넘긴다. **게임 카메라를 뺏지 않는다** — 전용 카메라를 `CopyFrom`으로 만들어 쓰고 파괴한다(Camera.main을 옮기면 CameraFollower와 다툰다). 첫 성과로 **플레이어 idle 호흡이 실제로 도는 것을 픽셀 차분으로 확인**했다(2초 간격 3장, 차이가 플레이어 영역에만 몰림 — 코드로는 증명 불가하던 항목). **한계**: IMGUI는 카메라를 거치지 않아 안 잡힌다(상점·대화창·배틀 UI). 에디터를 열어두면 락파일로 도구가 못 돈다. `rules/testing.md`에 사용법·한계 절 신설, visual-dev에 등록. CollectionUI 재감사는 **clean** — 정렬 증분의 불변식(정렬 순서↔행 문자열 캐시 인덱스 결합)이 무효화 2지점에서 모두 지켜지고 TeamChanged도 구독돼 있다. **거짓양성 2건 철회**: `Compare`의 null 미가드는 `Sort`가 상류에서 거르므로 안전(오히려 하류 null 가드가 죽은 분기), `GUI.backgroundColor` 5건은 버튼 배경 틴트라 글자에 곱해지지 않는다. 검증: 컴파일 error CS 0, ci_check 8검사 통과, PlayMode 625/625, 캡처 도구 3회 실행 exit 0. +- 2026-08-17: 어르신 조우 접근 **라이브 검증**(사용자 요청) + PlayerMovement **재감사** — **P1:1 처리, P1:1·P2:1 위임.** 새 캡처 하네스로 접근 연출을 실제로 찍었다: 11m에서 시작해 어르신이 걸어와 **2.10m에서 멈추고 손을 흔든 뒤 더 쫓아오지 않는다**(설계값 `ArriveRadiusFor(3m)=2.1`과 소수점까지 일치). 첫 실행은 접근이 아예 안 떠서 버그를 의심했으나 **오진이었다** — 계정 세이브에 `ch1_intro`·`talk_elder`가 이미 열람돼 있어 목표가 정확히 그 둘을 건너뛴 것이었다(정상 동작). 진행 파일을 백업·비움·복원해 재현했고 복원은 내용까지 대조 확인했다. **관측**: 플레이어 자동 주행과 NPC 접근이 서로 상대의 옛 위치를 향해 움직여 t=2s에 거리가 5.35→7.26m로 벌어졌다가 4s에 수렴한다(스쳐 지나갔다 되돌아옴 — 처리 방향 3안 위임). PlayerMovement 재감사 **P1:1 처리** — frozen 안내가 대화·컷신 중에도 떠서 "ESC로 이동 잠금 해제"라는 틀린 안내를 스토리 내내 노출했다(ESC는 그때 모달을 닫는 키다) → 모달 가드 추가. **위임 2건**: ①그 문구와 리전 차단 문구가 `UIScale` 밖 픽셀 좌표(BattleArenaController가 직전에 같은 이유로 UI로 옮겨졌다) — 제대로 고치려면 UI 계층 이전이 필요하고 Core→UI는 금지라 단독 처리 보류 ②`hasReceivedInput`이 제거된 배너의 잔재로 CS0414 경고를 매 빌드 발생시킨다. 부수 확인: `ui_layout_lint`가 Rect 인라인 `Screen.height - N`을 못 잡는다(전 저장소 실제 3건, 나머지 7건은 전체 화면 채우기라 정당 — 좁히지 않고 패턴만 넣으면 거짓양성이 된다). 검증: 컴파일 error CS 0, ci_check 8검사 통과, PlayMode 625/625. +- 2026-08-17: DexScreenUI **재감사** — **P1:5 처리 + 저장소 전수 스윕 신설.** 증분(23줄, 잘림 2곳 수정)은 clean이었는데 그 커밋이 남긴 한 줄이 단서였다: "둘 다 리터럴 문구라 text_fit_lint의 대상이 아니었다". 그 린터는 **길이를 데이터가 정하는 자리만** 보므로 `GUI.Label(rect, "고정 문구", style)`은 아무도 안 본다. 스타일 변수의 fontSize와 Rect 높이를 소스에서 뽑아 대조하는 스캐너를 만들어 세어 보니 **82건**(fontSize×1.35 기준 미달), 그중 **36건이 10% 이상 부족**하고 13개 파일에 걸쳐 있었다 — 최악은 `28pt를 28px 상자에`(35% 부족, 글립 상자는 언제나 폰트 크기보다 크므로 논란의 여지가 없다). DexScreenUI에서 **이웃 레이아웃을 검산할 수 있는 5곳**만 `LineH()`로 고쳤다: 보유 아이템 헤더(headerH 74 안이라 자유), 곤충 친구 헤더(여백이 8px뿐이라 리스트 뷰포트 y·높이도 함께 밀었다), 타일 3줄(타일 182 안 여유 확인). → 77건(31건)으로 감소. **나머지는 손대지 않았다** — 상자를 키우면 아래가 밀리므로 자리마다 검산이 필요하고 IMGUI는 배치모드 캡처가 안 돼 눈으로 확인할 수 없다. 스캐너를 `.claude/scripts/literal_fit_scan.py`로 남기되 **ci_check에는 아직 넣지 않았다**(켜면 즉시 실패). 전용 정리 라운드로 줄인 뒤 올린다. 검증: 컴파일 error CS 0, ci_check 8검사 통과, PlayMode 628/628. +- 2026-08-17: 잘린 리터럴 문구 **일괄 정리**(사용자 요청) + CharacterOutfitUI **재감사** — **31건 처리, 검사기 1개 신설, 감사 clean.** 직전 라운드가 센 심각 31건(10%↑ 부족, 10파일)을 전부 `UIHelper.LabelFit`으로 바꿔 0건으로 만들었다. **상자를 키우지 않은 것이 요점** — `rules/ui-layout.md`가 "상자는 그대로 두고 글자를 줄인다"를 기본으로 정해 뒀고, IMGUI는 배치모드 캡처가 안 돼 결과를 눈으로 확인할 수 없으니 레이아웃을 안 건드리는 쪽이 맞다. `literal_fit_lint.py`를 만들어 `ci_check`에 등록(FAIL 기준 10%↑ — 전부를 기준 삼으면 text_fit_lint가 247→31로 좁혀졌던 실수를 되풀이한다). **검출력 실측에서 검사기 자체의 결함을 잡았다**: 정규식이 `new GUIStyle(...)`만 보고 `new GUIStyle { ... }`(괄호 없는 초기화)를 놓쳤다 — 그대로 올렸으면 아무것도 못 잡는 검사기가 됐다. 보강 후 두 형태 모두 검출(주입 시 exit 1, 복구 시 0). 경미 46건은 남겼다(폰트 패딩에 묻힐 수 있고 LabelFit은 보간 문자열마다 캐시 항목을 만든다). CharacterOutfitUI 재감사는 **clean** — 좌우 배치 증분은 `Mathf.Max` 가드·고정 가상 캔버스로 안전하고, 구독이 0개라 OnEnable이 불필요하며, `new GUIStyle` 9건은 전부 `CachedStyle` 팩토리다(거짓양성). 이번 세션의 `CharacterPortraitRenderer` 주변색 수정이 이 화면에서 온전히 도는 것도 확인했다. 검증: 컴파일 error CS 0 / warning CS 0, ci_check **9검사** 통과, PlayMode 628/628. +- 2026-08-17: CashShopUI **재감사 2회차** (큐 1순위, 953→1000줄 score 206) — **P1:2 처리, Explore 부분 오류 1건 정정.** 이 파일은 이미 세 번 감사됐어서(가챠 P0:11 / 보석·아이템 상점 P0:16 / 08-07 재감사) 이번엔 **08-08 이후 증분**, 즉 이번 세션에 내가 넣은 상자 아이콘·가챠 초상에 초점을 뒀다. 그 초상이 결함이었다 — `CapturePopupUI.DrawTypedInsectPortrait`를 **직접** 불렀는데 그건 파사드가 아니라 **파사드 아래의 2D 폴백 계층**이다. 진짜 이음매는 `InsectVisual.Draw`(3D 썸네일 있으면 그것, 없으면 안에서 같은 2D로 내려감)이고 도감·보유곤충·팀편성·병원·훈련·포획선택·지역맵 9곳이 그걸 쓴다. 결과적으로 **돈을 쓴 직후 화면만 게임 전체에서 홀로 2D**였다. DB 조회를 `cachedWallet`과 같은 lazy 캐시로 두고 파사드 경유로 바꿨다 — 단, 조회가 null이면 파사드는 **아무것도 안 그리므로**(빈 사각형 회귀가 바로 이번 세션에 고친 결함이다) 그때만 2D를 직접 부르는 분기를 남겼다. 둘째로 Phase 2 플래시가 `new Rect(0,0,850,620)` 하드코딩인데 감싸는 Area는 1200x820(모바일 1200x1560)이라, 1.5~2.0초 구간에 좌상단만 덮는 **각진 흰 판**이 떴다(모바일에선 높이의 40%) → Area 크기를 인자로 받게 했다. Explore는 이걸 "기존 코드"라며 P2로 냈지만 **나이가 심각도를 정하지 않는다** — 눈에 보이는 렌더링 결함이라 P1로 올려 함께 고쳤다. 부수 1건: 썸네일은 첫 요청이 null이라 결과가 열리는 프레임에 2D→3D로 튀고 하필 그 프레임에 `InsectEntity` 생성/파괴까지 겹친다 — 연출의 절정에서 한 번 끊기므로 상자 흔들림 1.5초 구간으로 예열을 옮겼다. **정정**: Explore의 "shiny 플래그도 빠진다"는 틀렸다 — `GachaResult`에 shiny 필드 자체가 없고 지급 시점 `AddCapturedInsect`가 굴린다(늘 false가 맞다). **정상 확인**: `DrawChest` 계수는 전부 w/h의 양수 배수 + 진입 가드 이중, roundness는 0/1만 써 중간값 뭉갬을 피했고, `BeginArea`는 여전히 OnGUI 1회(08-08의 3탭 공백 회귀 미재발), 구독 짝 대칭, 신규 코드에 프레임 할당 없음. 검증: 컴파일 error CS 0 / warning CS 0, ci_check **9검사** 통과, PlayMode 628/628. +- 2026-08-17: **큐 잔여 5건 일괄**(사용자 요청) — PlayerItemInventory / CaptureController / AccountSettingsUI / DexController / DexBrowseLayout — **P0:1, P1:3 처리, 검사기 1개 신설, 밸런스 1건 현행 유지 결정.** 대상이 안 겹쳐 탐색 5건을 병렬로 돌리고 수정은 순차 처리했다. **가장 큰 건은 DexController 라운드의 P0**인데, 정작 그 파일은 clean이었다 — 마지막 증분이 `CapturedSpeciesCount`를 **스토리 진행의 판정값으로 승격**시키면서, 원래 있던 도감 등록 누락이 "표시가 좀 빈다"에서 "전 플레이어의 진행이 한 종 늦게 열린다"로 격상된 것이다. 튜토리얼 보상 곤충(첫 파트너)이 소유·출전까지 하는데 도감엔 영원히 미발견이라 100% 완주도 불가능했고, `dexController`가 선언·대입만 되고 **한 번도 읽히지 않는 죽은 필드**인 게 결정적 증거였다. `StoryDirector`도 같은 형태(현재는 전 비트 `rewardInsectId:""`라 휴면이지만, 곤충 보상 비트를 저작하는 순간 자기가 준 보상이 자기 트리거를 못 밀어올린다). **같은 누락이 두 곳에서 동시에 나왔으므로 `dex_grant_lint.py`를 신설**했다 — 지급 6경로가 각자 도감 등록을 따로 불러야 하는데 빠져도 예외·경고가 없고 곤충은 멀쩡히 손에 들어와서, 도감을 세어보기 전엔 아무도 모른다. 검출력 실측: 두 수정을 되돌리자 exit 1 + 두 곳 정확 지목, 복구 후 exit 0. CaptureController에서 **P1:2** — `CaptureResolved`가 `AddCapturedInsect`보다 먼저 발화해 팝업이 **같은 종의 직전 개체**를 표시했고(첫 포획이면 조회가 비어 이전 팝업 값이 잔류, 초기화 코드가 없었다) → 지급 뒤로 이동 + 방어적 초기화. `NotifyCapture`가 연출 컴포넌트에만 있어 앞선 구독자 예외 하나로 포획 퀘스트가 영구 유실될 수 있었다 → 컨트롤러 성공 분기로 이동. **P1:1은 고치지 않고 사용자 판단을 받았다** — 07-17에 `CaptureController`+`GameplayTuningProfile` **두 파일을 같은 값으로** 내린 포획 너프(base .6→.35 / 난이도 .4→.45 / 퍼펙트 .15→.1)를 08-03 리팩터가 통째로 원복했고 같은 커밋이 그 잘못된 쌍을 고정하는 테스트까지 심었다. 원복이 사고인 건 분명하지만(08-03 커밋 메시지는 레벨 차 상한만 언급한다) **그냥 되돌리면 더 나빠진다** — 같은 08-03 커밋이 도입한 레어도 floor(.30/.22/.14/.08/.04)가 base 0.60 전제로 잡혀 있어서, 0.35로 내리면 난이도 0.5 동레벨 기준 **전 등급이 floor에 닿아**(Common `.35-.135=.215 < .30`) 난이도·레벨·base 항이 전부 죽고 포획률이 등급별 상수가 된다. 너프의 원래 의도(더 어렵게)조차 이루지 못한다. → **현행 유지로 결정**하고 경위를 4곳(`CaptureChanceTuning` 상단 주석 / `GetRarityFloor` 요약 / `rules/balance.md` 신설 절 / 해당 테스트 주석)에 남겼다. 특히 그 테스트는 **일치만 검사할 뿐 값이 옳은지는 보지 않는다**는 점을 적어 뒀다 — 그게 원복을 가린 장치였다. 나머지 3라운드는 clean. 검증: 컴파일 error CS 0 / warning CS 0, ci_check **10검사** 통과, PlayMode 628/628. +- 2026-08-17: **큐 15건 전수**(사용자 요청) — **P0:2, P1:9 처리, P2·설계 5건 위임.** 탐색 12건을 병렬로 돌렸다(작은 순수부 4개는 묶음). 큐 앞 6건이 **이번 세션에 내가 새로 쓴 코드의 첫 감사**였고 거기서 절반이 나왔다 — 자기가 쓴 걸 자기가 다시 보는 것보다 독립적으로 보게 하는 편이 낫다는 증거다. **P0 둘**: ①`QuickAccessBarUI`에 `FieldHudInput.RegisterBlockingRect`가 **한 줄도 없었다** — 탭한 프레임엔 모달이 아직 안 열려 `IsAnyOpen()`이 false, IMGUI라 `pointerOverUI`도 false여서 버튼 아래 월드 지점이 클릭-이동 목표로 잡혔다. **메뉴를 열 때마다 매번**, 모달을 닫는 순간 캐릭터가 걸어간다(모바일은 키 입력 자동 해제도 없어 더 나쁘다). ②`VillagerNpc.WarpTo`가 레이 첫 히트를 무조건 지면으로 채택했다 — 자기 주석이 "`SampleGround`는 지붕 오인을 막으려 ±0.75m를 거부한다"고 적어 두고 그 가드를 대체 없이 버린 것이다. 건물 몸통은 콜라이더를 남기므로 상판 y=2.8~3.8m가 잡히고, 이후 클램프가 **지상 복귀를 영구 거부**해 자가 회복이 불가능하다. 라온의 등장 워프 좌표가 실제로 그 입력을 만든다. **P1 아홉** 중 무게가 큰 것: `PlayerMovement`의 frozen 분기가 `return`해 `blockedMsgTimer`가 안 줄어 차단 문구가 **모달 위에 영구 고착**(바로 옆 `catchSwingTimer`는 이미 같은 이유로 줄이고 있었다), `StoryStageDirector`의 복귀 래치가 8초 타임아웃과 만나 **스토리 NPC를 들판에 영구 유기**(진입 데드존을 `engaged`로 고쳤던 것의 거울상) + `SnapToFinalPose`가 `stepIndex`부터 훑어 **정상 완주 시 한 번도 안 돌아** 걷다 만 귀가가 보정되지 않음, 레벨업 UI가 **"사탕 0"을 표시하면서 실제로는 차감**(`InsectLevelCurve` 에셋이 프로젝트에 0개라 표시 경로만 늘 null), `WeeklyContestClaimed` 클라우드 미동기(기기 교체 후 **주간 보상 재수령**)와 `SaveScope` 5키 누락(계정 삭제 후 같은 uid로 재로그인하면 진행 부활). **콜백 계약은 clean**임을 11경로로 확인했다 — 캠페인 영구 정지가 이 하네스의 최대 위험이었는데 `onPreludeDone`이 모든 경로에서 정확히 1회다. **위임**: `NpcManager`의 AI 예산이 인구 49명과 앨리어싱해 주민 갱신 주기가 배증(프레임 예산 튜닝), `OutfitSetData`의 세트 보너스 표시가 **최소 수치를 광고**하고 0개 착용 세트는 목록에서 빠짐(젬 전용 세트가 구매 전엔 존재조차 안 보임), 퇴장 연출의 9초 조작 잠금(안 기다리게 하면 워프로 바뀌어 연출이 사라진다 — 잠금과 진행의 분리가 필요한 설계 판단). 검증: 컴파일 error CS 0 / warning CS 0, ci_check 10검사 통과, PlayMode 628/628. +- 2026-08-17: **큐 12건 전수 — 큐 소진**(사용자 요청) — **P0:2, P1:8 처리, 위임 7건, clean 6영역.** 탐색 12건 병렬. 이 라운드로 `audit_candidates.py`가 후보 0건을 반환했다 — 저장소의 모든 `.cs`가 최소 1회 감사를 거쳤다. **P0 둘**: ①`WorldFieldMultiplayerUI`에 `RegisterBlockingRect`가 0건이라 비모달 버튼 6개 위의 탭이 월드 클릭-이동으로 샜다("3:3 대전"을 누르면 도전과 동시에 캐릭터가 상대 뒤로 걸어간다). **직전 라운드의 `QuickAccessBarUI`와 같은 결함이 다른 파일에서 또 나왔다** — 3파일 중 2곳이 빠져 있었으므로 `rules/ui-layout.md`에 절을 신설했다(검사기는 두지 않았다: "필드 위 비모달 버튼"을 정적으로 판정하려면 모달 여부·그리기 조건을 따라가야 해 오탐이 크고, 전체화면 모달은 등록하면 **안 되는데** 그 구분이 소스 패턴에 안 드러난다). ②`PlayerItemInventoryGridUIController.BuildGrid`가 `contentRoot`의 모든 자식을 파괴하는데 부트스트랩이 템플릿을 바로 그 자식으로 만든다 → 첫 갱신에 템플릿이 죽고 두 번째부터 null 가드로 조기 반환, **인벤토리 그리드가 옛 수량으로 영구 동결**(아이템을 써도 개수가 그대로). 덤으로 셀이 템플릿의 `SetActive(false)`를 상속해 보이지도 않았다. **P1 여덟** 중 무게가 큰 것: `NpcDialogueUI` 프렐류드 게이트 2건 — 예외 미봉쇄와 **인터페이스 타입 `!= null`이 Unity 파괴 검사를 하지 않는 것**(제공자는 `World/`, 대화창은 `UI/`라 한쪽만 죽을 수 있고 그때 `SetFrozen(true)`만 남고 타임아웃이 영영 안 돈다) — 둘 다 캠페인 영구 정지 계열이다. 젬 싱크 4곳 중 3곳이 클라우드 확정을 빠뜨려 **결제 시 쓴 젬이 되돌아오는 복제**가 가능했고(`SpendGems` 초크포인트 한 곳으로 덮음), `TrainingUI`의 새 캐시가 **이 화면 자신의 장착·해제로는 안 비워졌으며**(`ForceSave`는 이벤트를 안 쏜다), 「지워진 개체」 연출이 3D 모델 밖으로 전파되지 않아 포획 선택창이 감춘 이름을 바로 알려주고 있었다. **위임 7건**: `BuildForBattle`의 `erasedOverride`가 **호출부 0개**(전투 진입 시 원색 복귀, 다지점 플러밍) / 초대 팝업과 친구 패널이 겹쳐 IMGUI 선그리기 우선순위 탓에 "거절"이 "초대"로 눌림 / 대상지정 아이템의 `hospital` 미배선 무음 소멸 / 서브에리어 `erasedChance` 0% / 오프닝 최단 내레이션 창 알파 미고정(테스트 보강) 등. 검증: 컴파일 error CS 0 / warning CS 0, ci_check 10검사 통과, PlayMode 628/628. +- 2026-08-23: **스토리 진행 경로 심층 감사**(사용자 요청 "문제점 심층 분석 및 개선") — **P0:1, P1:9, P2:2 처리, 검사기 5개 신설.** 큐가 비어 있어 재생성했고, 후보 6건 중 스토리 경로에 걸린 것부터 파고들었다. **결함이 전부 같은 뿌리에서 나왔다 — "그 순간에 비트가 안 떴다"를 "그 단계가 지났다"로 읽는 것.** ①여운 비트의 prereq가 체인 순서만 담아 진행 단계를 담지 못했다(초원에서 라온 3회 → 12장 복귀 대사, 숲에서 세라 4회 → **엔딩 에필로그 전문**과 보상). ②보스 도전이 `!storyFired`만 보고 열려 집게·저울·관장을 **소개 전에** 싸웠다(최종 보스 포함). ③전투 종료가 모달의 프리즈를 풀어 **대사 중 캐릭터가 걸어다녔다**(`BattleWin` 비트 12개 전부). 셋 다 "없음"과 "아직"의 혼동이다. **P0은 튜토리얼 첫 1분**: 개막 `ch1_intro`는 `q_move`로 잠겨 있는데 앰비언트 `talk_*`는 무게이트라 HUD 목표가 잡담을 가리키고 첫 목표 자동 주행까지 태웠다 — 도착해 말을 걸면 개막이 아니라 잡담이 소비된다. 안내 쪽도 같이 갈았다: 82비트 중 34개(41%)가 위치 없는 목표라 "모험을 이어가세요"만 떴고 하필 **1막 전체**가 그 구간인데, 28개는 `requiredRegionId`를 이미 갖고 있었다 → `ActInRegion` 신설로 그 문구를 **0건**으로. **내가 이번 세션에 넣은 코드에서도 3건이 나왔다** — 지연 복귀가 새 나가는 경로 둘, 범례 7행이 세로 패널을 뚫는 것 하나. 자기가 쓴 걸 곧바로 다시 보는 게 값이 있다는 증거다. **검사기 5개**(15 requiredBeatId 무결성 / 16 게이트 대상 재발화성 / 17 챕터 도달 순서 / 18 캠페인 시작 동시 자격 / 19 보스 소개 비트) — 특히 17은 수정 전 데이터에 넣으면 **정확히 그 7건만** 잡고, 19는 이번 수정이 새로 만든 잠금 위험(소개 없는 보스 = 영구 도전 불가)을 되받는다. 검증: 컴파일 error CS 0 / warning CS 0, ci_check 통과, story_lint **19검사** 0 FAIL, PlayMode 651/651. +- 2026-08-23: **audit 큐 4건 전수 — 큐 소진**(사용자 요청 "남은 큐 다 진행하고 커밋 및 푸시까지") — **P1:7, P2:2 처리.** 네 영역이 서로 다른 시스템인데 **결함 셋이 같은 모양이었다: "한 번 만들고 끝"이 상태 변화를 못 따라간다.** 수문장 봉인은 부팅 때 한 번 지어져 인게임 격파에도 클라우드 로드에도 반응하지 않았고(붉은 구체가 다음 부팅까지 남고, 기기 교체 첫 접속에서 깬 수문장이 다시 섰다), 레이드 마커는 스폰 좌표에 굳어 배회한 보스를 못 따라갔으며, 컷신 테스트 목록은 손 관리라 6번째 컷신을 조용히 안 볼 참이었다. 나머지 넷은 **겹침**이다 — 도감 브라우저 제목이 뒤로 버튼 밑에 깔리고(리전 이름이 통째로 사라진다), 세로 정보 패널에서 진행바가 버튼을 파고들고(**같은 패널의 범례 분기만 지난 라운드에 고치고 이 분기를 빠뜨렸다**), 전투 결과 화면 위로 스토리 대사가 열려 보상 패널을 덮었다. 마지막 하나는 저작이다: `cs_story_prologue`가 **재생 시점을 거꾸로 알고** 쓰여 있어, 게임 첫 1분에 어르신 대사를 되풀이하고 방금 만나고 나온 사람을 찾아가라고 안내했다. 검증: 컴파일 error CS 0 / warning CS 0, ci_check 통과, PlayMode **669/669**(+11). +- 2026-08-23: StoryDirector 재감사 (audit 자동 플로우) — **P1:2 처리, 검사기 1건 보강.** 지난 라운드가 BattleWin을 결과 화면 뒤로 미뤘는데 **GuardianDefeat를 빠뜨렸다** — 그쪽이 오히려 더 이르다(`CheckGuardianDefeat`는 결과 화면이 뜨기도 전에 돈다). 그리고 유적 이후 6개 리전은 수문장을 잡으면 `gd_X`와 `chN_clash`가 함께 자격을 얻는데, `FireBeat`가 **읽고 있는 비트를 밀어내면 그 비트가 읽히지 않은 채 보상까지 받고 열람 처리된다**(`ShowStory` → `CloseModal` → `CompleteBeat`). 둘을 트리거 큐로 묶어 순서대로 흘리고, 큐는 대사·컷신이 떠 있는 동안 기다린다. 검사 6에 지연 발화를 가르치면서 **또 껍데기를 만들 뻔했다** — 배출부 존재 판정이 `EvaluateTriggers` 선언부의 `string`에 매칭돼, 배출부를 통째로 지워도 PASS가 나왔다. 지난 라운드 quest_lint 검사 9와 같은 실수라, 이번에도 **주입 두 종(배출부 제거 / GuardianDefeat 배선 제거)으로 실측**해 각각 정확히 지목되는 것을 확인했다. 검증: 컴파일 error CS 0 / warning CS 0, ci_check 통과, story_lint 19검사 0 FAIL, PlayMode 669/669. +- 2026-08-23: **audit 큐 4건 전수 — 큐 소진**(사용자 요청 "다음 큐 다 개선해") — **P0:1, P1:2, P2:1 처리, 검사기 2건 보강, clean 1영역.** **P0은 세 번째 재발이다** — 필드 위에 겹쳐 그리는 버튼이 `FieldHudInput.RegisterBlockingRect`를 안 불러 탭이 클릭-이동으로 새는 결함이 `QuickAccessBarUI`·`WorldFieldMultiplayerUI`에 이어 `TutorialQuestUI`(칩·✕·목표 행)에서 또 나왔다. 목표 행은 이번 세션에 내가 넣은 것이라, 규칙 문서에 적혀 있는데도 새 코드가 같은 함정을 그대로 밟았다. 나머지 셋은 **낡은 숫자·좁은 범위**다: 섹션 헤더가 패널 헤더 폰트를 상수 31로 되돌려(헤더는 37로 커진 뒤였다) 제목이 영구히 작아졌고, 진행 카운트 상자가 칩 쪽만 넓혀져 팝업은 62px로 남았고, 지난 라운드의 팀 자동 편성이 **앞으로의 획득만** 덮어 이미 막힌 세이브를 구제하지 못했다. `NpcDialogueDatabase`는 데이터가 clean이었지만 **아무도 검사하지 않고 있어서** 검사 20을 신설했는데, 만드는 과정에서 `story_npc_ids()`가 **VillageBuilder 주석의 예시 문자열을 실제 NPC로 세고 있던 것**이 드러났다(파이썬 `\w`가 한글을 먹는다). 검출력은 주입으로 실측했다 — 키 오타 하나에 양방향 2건을 정확히 지목. 검증: 컴파일 error CS 0 / warning CS 0, ci_check 통과, story_lint **20검사** 0 FAIL, quest_lint 10검사 0 FAIL, PlayMode 669/669. +- 2026-08-23: NpcDialogueUI 재감사 (audit 자동 플로우) — **P1:2, P2:2 처리.** 이번에도 **낡은 숫자**가 나왔다: 진행 표시가 이름과 스타일을 공유하는데 스토리 모드에서 폰트만 26→38로 커지고 상자는 30px에 남아 세로로 깎였다(이름 상자는 52로 함께 키웠다). 오늘 하루에 같은 형태를 세 번째 본다 — `TutorialQuestUI`의 헤더 복원 상수 31, `RegionMapUI`의 진행 카운트 62px, 그리고 이것. **그리고 어제 막은 손실의 남은 문**: 저널 다시보기는 `StoryDirector.FireBeat`의 겹쳐쓰기 가드를 거치지 않으므로, 대사를 읽는 중에 저널에서 다른 비트를 열면 읽던 비트가 보상까지 받고 열람 처리됐다 — 발화 경로만 막아서는 부족했다. 곁들여 최종 보스를 포함한 2막 간부 4인이 **얼굴 없이** 말하던 것(1막 하수에게는 초상이 있다)과, 1×1 흰 텍스처를 직접 만들어 들고 있던 것을 정리했다. 검증: 컴파일 error CS 0 / warning CS 0, ci_check 통과, PlayMode 669/669. +- 2026-08-23: PlayerStatusHUD 재감사 (audit 자동 플로우) — **P0:1, P1:1 처리.** **직전 라운드에 "전수 점검했다"고 적은 그 점검이 이 파일을 놓쳤다** — 버튼 위젯 문자열로 훑었는데 이 화면은 `Event.current`로 직접 히트테스트한다. 그래서 화면에서 가장 큰 HUD(480×540 패널 + 닫힘 탭)가 등록 없이 남아 있었다. 전수의 결론이 아니라 **전수의 방법**이 틀렸던 것이라, 규칙 문서의 명령을 `EventType.MouseDown`까지 세도록 고치고 다시 돌려 남은 미등록이 없음을 확인했다(나머지는 전부 IModalUI·프리즈·월드 없음). 곁들여 이 HUD만 모달 검사가 없어 도감·상점 위에 얹히고 **모달 좌상단의 탭을 먼저 먹고 있었다** — `evt.Use()`가 방어가 아닌 이유(PlayerMovement는 IMGUI 밖에서 폴링한다)를 문서에 함께 적었다. 검증: 컴파일 error CS 0 / warning CS 0, ci_check 통과, PlayMode 669/669. +- 2026-08-23: **audit 큐 3건 전수 — 큐 소진**(사용자 요청 "남은 큐 3건도 다 개선해") — **P0:1, P2:2 처리.** 전수 점검이 값을 했다: 버튼을 그리는 25개 파일을 표로 뽑으니 **가장 큰 필드 버튼이 등록에서 빠져 있었다** — `SubAreaWorldBuilder`의 620×100 진입/퇴장 버튼. 누르면 서브에리어로 들어가면서 동시에 옛 월드 좌표를 향한 클릭-이동이 걸린다. **이 결함의 네 번째 재발**이라 규칙 문서에 이력과 전수 방법(표를 뽑는 셸 한 줄)을 남겼고, 검사기를 왜 안 두는지도 **실측으로** 적었다(네 건 중 하나만 잡고 셋이 거짓양성). 나머지 둘은 "모른다를 아니다로 읽은" 것들이다: 리전 사이 빈 땅에서 `CurrentRegion`이 null인데 그걸 "다른 리전"으로 읽어 바로 앞 목표를 눌러도 지도가 열렸고, 미니맵은 불투명 패널인데 버튼이 아니라는 이유로 탭이 뒤 월드로 샜다. 검증: 컴파일 error CS 0 / warning CS 0, ci_check 통과, PlayMode 669/669. +- 2026-08-23: StoryStageLibrary 저작 재감사 — **P0:1, P1:2, P2:1 처리 + story_lint 검사 21.** 컷신에서 통했던 구분("재생기는 clean인데 저작이 틀렸다")이 여기서도 값을 했다. 좌표를 **방 기하와 대조**하니 서브에리어 대치 6개 중 셋이 벽 밖이었다 — 그중 `ch12`는 **최종장 관장의 첫 등장**인데 남벽 6m 바깥·바닥 밖에 떨어져 8초를 벽에 밀린 뒤 화자 없이 대사만 떴다. 원인은 1막의 "뒤쪽 9m" 관용구를 벽 있는 방에 그대로 옮긴 것. 곁들여 `ch5`는 목적지가 플레이어 너머일 수 있어 8초 제자리걸음이 났고(새 `ApproachPlayer` 액션으로 교체), 테스트의 스테이지 목록이 손 유지였다(리플렉션으로 교체). 검사 21은 벽 크기와 입구 좌표를 코드에서 읽으므로 사본이 없다 — 옛 좌표 주입 → 3 FAIL → 복원 → PASS로 검출력을 실측했다. 검증: 컴파일 error CS 0 / warning CS 0, ci_check 통과, PlayMode 672/672. +- 2026-08-23: BlightVfx (오염 거점 라운드 직후 신규 파일) — **P1:1, P2:5 처리 + 자체 발견 2건.** P1은 런타임 머티리얼 미파기다 — `Mat()`/`Transparent()`가 호출마다 `new Material`을 만드는데 정리 코드가 GameObject만 지웠다(거점당 20~23개, 정화당 11개). **이 저장소가 이미 한 번 고친 결함**이고(`SubAreaWorldBuilder.DestroySubAreaWorld` 주석: "예전엔 Destroy(subAreaRoot)만 해서 머티리얼은 남았다"), 같은 `runtimeMaterials` 형태로 맞췄다. 색이 안 변하는 파츠는 머티리얼을 공유해 거점당 23→11개로 줄었다. **자체 발견이 더 조용했다**: ①서브에리어 안에서 거점이 지어지면 `HideMainWorld`가 `Region_*` 지면을 꺼 둔 상태라 `GameObject.Find`가 못 찾아 **그 세션 내내 그 리전만 탈색이 빠진다**(sites에 등록돼 재시도도 없다) — 서브에리어 가드 추가. ②그 실패가 로그도 안 남겼다 — 경고 추가. P2 중 실질은 붕괴 상태 누수였다: `sites.Remove`가 코루틴 **시작 전**이라 코루틴이 완주 못 하면 구조물이 선 채 지면이 탈색된 채 남고 `IsBlighted`가 이미 false라 되돌릴 경로가 없다 — 제거를 완주 뒤(`FinishCollapse`)로 옮기고 비활성일 때는 연출을 포기하고 상태만 정리한다. 나머지: 루트 이름을 `Scenery_` 프리픽스로 바꿔 기존 숨김/복원 경로에 얹고, 안개 코루틴에 리전 게이트를 걸고, 클라우드로 정화가 뒤집히는 경로를 `Update`가 따라잡게 했다(문서 주석이 그렇게 주장만 하고 있었다). 검증: 컴파일 error CS 0 / warning CS 0, ci_check 11종 통과, PlayMode 704/704. +- 2026-08-23: RegionBlightManager + 오염 거점 라운드 회귀 — **P1:2, P2:4 처리 + 자체 발견 1건.** 매니저 자체는 clean이었다(상태 정합·이벤트 4구독·세이브 8지점·idempotent 가드 전부 확인, XML cref 오타 하나뿐). **결함은 전부 그 주변에 있었다.** 자체 발견이 가장 컸다: `q_blight_both`가 **영영 완료되지 않는다** — `NotifyAction`은 활성 퀘스트 하나만 올리는데(`:699`) 거점 2곳에 목표 1+2를 두어, 첫 정화를 앞 퀘스트가 소비하고 남은 정화는 하나뿐이었다. 목표를 1로 내리고 **`blight_lint` 검사 18**(정화 퀘스트 목표 합계 ≤ 거점 수)로 고정했다 — quest_lint는 ID·배선·순서만 보고 달성 가능성은 안 본다. P1 둘: ①첫 승리가 정화도 겸하면 **보상 획득 문구가 통째로 사라졌다**(AddItem은 토스트를 안 띄운다). 파일럿 2곳은 첫 만남이 이미 오염 상태라 이게 예외가 아니라 **기본 경로**였다 — 전원이 아이템을 모르고 받았다. 그 문구를 합치자 800×44 고정 상자의 raw `GUI.Label`이 드러나 `LabelFit`으로 바꾸고 `text_fit_lint`의 텍스트 출처에 `LastResultText`를 좁게 올렸다(검출력 실측: 되돌리면 FAIL). ②정화 재배치 코루틴이 대기 후 서브에리어를 재확인하지 않고, 탈출 조건이 리전 마릿수인데 `GetAvailableSpawnPoint`는 **아무 리전으로 폴백**한다 — 정화한 리전은 안 차고 이웃에 여섯 마리를 밀어 넣을 수 있었다. 나머지: `TrySpawnSpecific` 리전 상한 검사, `OnDisable`만 있던 구독 비대칭에 `OnEnable` 재구독. 검증: 컴파일 error CS 0 / warning CS 0, ci_check 11종 통과, blight_lint 19검사 0 FAIL, PlayMode 704/704. +- 2026-08-23: SubAreaWorldBuilder 재감사 (큐 재생성분 · 후보 1건) — **P1:3 처리.** 가장 큰 것은 **[E] 삼자 충돌**이다: 포획과 상호작용은 서로 양보하는 사슬이 있는데 서브에리어 진입만 그 밖에 있었고, 진입 반경 12m와 포획 탐색 반경 8m가 겹쳐 입구 근처에서 [E]를 누르면 둘이 함께 발화한다(순서는 Update 실행 순서라 미정). 셋 중 서브에리어가 양보한다 — 여기만 620×100 전용 버튼이 따로 있다. 그 외 `OnEnable` 재구독 누락(`subscription_lint`의 사각지대: UI 루트 하위만 본다)과 토스트의 노치 겹침. 이 라운드로 **감사 큐가 다시 비었고, `audit_candidates.py`도 후보를 더 내지 않는다** — 미검토 영역 없음. 검증: 컴파일 error CS 0 / warning CS 0, ci_check 통과, PlayMode 672/672. +- 2026-08-23: 싱글턴 수명 · DontDestroyOnLoad 가드 — **P1:3 처리 + `singleton_lint.py` 신설.** 앞 라운드가 주석 하나를 의심한 데서 시작했는데, 파 보니 **DDOL 호출 3개가 전부 죽은 코드**였다 — `transform.parent == null` 가드 뒤에 있고 부트스트랩은 언제나 `World/` 아래 자식으로 만든다. 죽은 줄이 거짓 주석 둘을 낳았고 하나는 실제 손해(로그아웃의 마지막 클라우드 플러시가 다음 줄의 씬 재로드에 끊긴다)였다. 수명을 바꾸는 대신(루트로 올리면 재로드 때 중복이 생겨 부트스트랩이 죽는 컴포넌트를 배선한다) 재로드를 `IsSaving`이 내려갈 때까지 미뤘다. 그리고 싱글턴 9종 전부 파기 시 static을 비우게 하고 그 짝을 검사기로 고정했다 — 감사 시점엔 9종 중 **1종**만 하고 있었다. 검증: 컴파일 error CS 0 / warning CS 0, ci_check 통과(검사 10종), PlayMode 672/672. +- 2026-08-23: WorldInteractionController 재감사 — **P1:3 처리, 자체 발견 4건.** 우선순위 판정과 조우 카메라는 clean이었고, 결함은 전부 그 주변에서 나왔다. ① 원형 버튼 텍스처가 파기되지 않는다 — 런타임 `Texture2D`는 씬이 내려가도 안 사라지고 로그아웃이 씬을 재로드한다. **같은 누수를 형제 둘(`CaptureInputController`·`VirtualJoystickUI`)에서도 발견**해 함께 고쳤다. ② `Instance?.`는 Unity의 파괴 검사를 우회한다 — 저장소에 19곳인데 `Instance`를 비우는 싱글턴은 하나뿐이었다. 호출부가 아니라 뿌리(싱글턴 OnDestroy)를 고쳤다. ③ 데이터가 길이를 정하는 프롬프트가 고정 상자였다(`LabelFit`). 그리고 **주석이 거짓말을 하고 있었다**: "CloudSaveManager는 DontDestroyOnLoad"라는데 `World/` 아래 자식이라 그 가드가 통과하지 않는다 — 로그아웃의 마지막 클라우드 플러시가 실제로 끊긴다. 주석을 사실로 고치고 수명 자체는 큐에 올렸다. 검증: 컴파일 error CS 0 / warning CS 0, ci_check 통과, PlayMode 672/672. +- 2026-08-23: QuickAccessBarUI 재감사 — **P1:2, P2:3 처리/보고.** 라이브 P0은 없었다 — 배선도 인덱스 표도 지금은 맞다. 문제는 **맞다는 것을 아무도 강제하지 않는 것**이었다: 같은 표가 네 벌(배열·Update 키 switch·OnGUI 키 switch·IsActive/OnClick case)인데 서로 인덱스로만 이어져 있어, 버튼 하나를 중간에 끼우면 전부 한 칸씩 밀려 C가 훈련을 열고 배지가 지도에 붙는다. 키 표 2벌을 `buttons[].key`에서 파생시키고(파싱 실패는 경고), 배지는 플래그로, `OnClick` default에는 경고를 넣었다. 덤으로 죽은 `IModalUI` 구현을 걷어냈다 — `Register`를 부르는 곳이 없어 `IsOpen`이 영원히 false인데 시그니처만 모달이었다. 검증: 컴파일 error CS 0 / warning CS 0, ci_check 통과, PlayMode 672/672. +- 2026-08-23: **audit 큐 4건 전수 — 큐 소진**(사용자 요청 "남은 큐 다 진행해") — **P0:1, P1:9, P2:5 처리.** 가장 큰 둘은 서로 다른 파일에 있었다. ①`InsectSpawner`: 서브에리어 스폰포인트가 **부모 리전 ID를 그대로 달고 있어** 재배치가 그걸 플레이어 링(10~43m)으로 끌고 왔다 — 게이트 원 바깥에만 있어야 할 전용종이 필드 한복판에 뜨고, 숲 기준 필드 포인트 5개 대 서브 포인트 8개라 **주변 스폰의 과반이 전용종**이 됐다. `SpawnPoint.isSubAreaPoint` 명시 플래그로 재배치에서 빼고, 레벨 곡선 판정의 `Length <= 5` 휴리스틱도 같은 플래그로 교체했다. 겸사겸사 8초마다 살아 있는 곤충이 붙은 포인트를 0으로 밀던 `ResetCount`를 실제 개체 재동기화(`BeginRecount`/`NotifyLive`)로 바꿨다 — 상한 2가 "동시 2마리"가 아니라 "8초당 2마리"였다. ②`NpcDuelController`: **대결 결과 토스트가 한 번도 뜬 적이 없었다.** `SetResult`가 `DuelEnded` 시점(결과 화면이 열리는 프레임)에 시각을 찍는데 결과 화면은 4초를 채우고 닫히고 토스트 창은 3.5초다 — 필드가 보이기 0.5초 전에 죽었다. 시각 스탬프를 버리고 1회 소비형(`TryConsumeResult`)으로 바꿔 **필드가 처음 그릴 때** 창을 시작한다. 직전 라운드에 조립한 보상·정화 문구가 그제서야 실제로 보인다. `AuthManager`는 P1이 셋이었다: 로그아웃·계정삭제가 진행 중인 갱신 코루틴을 세우지 않아 **응답이 오면 지운 토큰을 다시 써 세션이 부활**했고(세대 카운터로 무효화), 네트워크 오류와 인증 거절이 같은 처리라 **타임아웃 한 번에 refresh 토큰이 영구 삭제**됐으며(게스트는 복구 불가), 계정 삭제 요청 둘만 timeout이 없어 무제한 대기 시 클라우드 저장이 세션 내내 막혔다. `LoginUI`는 회원가입 중 클라이언트 검증 실패가 **폼을 통째로 파괴**했고(닉네임·비밀번호 확인 소실), 캐릭터 생성 패널만 폰트 크기를 안 정해 직전 패널 값(60/50pt)을 물려받아 제목과 항목 이름이 잘렸다. 검사기도 늘렸다 — `text_fit_lint`에 `errorMessage` 출처를 추가하니 **`RegionMapUI`에서 새 위반이 하나 나왔다**(wordWrap=false + 26pt). 검증: 컴파일 error CS 0 / warning CS 0, ci_check 11종 통과, PlayMode 704/704. +- 2026-08-27: LedgerPressure(clean) + InsectBattleController 재감사 — **P0:1 처리 + `story_lint` 검사 23 신설.** 1순위 `LedgerPressure`는 clean이었다(공개 멤버 전부 19테스트, 소비 경로도 `BeginBattleCommon`이 6필드 전부 리셋, 다타 스킬이 없어 배율 중복도 없음). 진짜는 그 옆에 있었다 — **같은 라운드에 내가 만든 P0**다. 최종장 `fin_seal`을 `BattleWin mantis_unnamed`로 고쳤는데 **그 종이 Legendary**고, `CaptureChoiceUI.IsRaidTarget`은 Epic·Legendary에 **포획과 1v1을 둘 다 막고 레이드만 연다**. 그런데 레이드 승리는 `AddCapturedInsect`만 흘리고 `BattleEnded`는 안 울려서, `CaptureInsect` 비트(ch2_emperor·ch4_firefly, 둘 다 Epic)는 멀쩡한데 **`BattleWin`만 도달 불가**였다 — 게다가 스파인이라 캠페인이 엔딩 직전에서 영구 정지한다. **앞 라운드 검증이 이걸 통과시킨 게 핵심 교훈이다**: 걸음 도구가 1v1을 시뮬레이션해 `BattleEnded`를 직접 쏘아, 저작·트리거·순서는 다 맞다고 나왔지만 **플레이어가 그 자리에 도달할 수 있는가**는 아무도 안 봤다. 고친 방향은 레이드도 전투로 세는 것(`StoryDirector`가 `RaidEnded` 구독 → 보스 종과 함께 `BattleWin`). 무param 비트에도 이롭다 — 레이드만 이기고 넘어간 플레이어가 "그곳에서 전투 승리"를 통째로 건너뛰던 것도 함께 닫혔다. 검사 23이 구독·해제·주입 3지점을 고정하고(수정 전 소스에 걸어 보면 3건 FAIL), 걸음 도구도 `WinRaidAgainst`로 바꿔 **실제 플레이어 경로로** 걷는다. 검증: 컴파일 error CS 0, ci_check 통과, story_lint 23 PASS, PlayMode 733/733, campaign·blight 걸음 전 걸음 통과. +- 2026-08-27: BlightPolicy — **P1:1 처리.** 순수 계산부라 표면 점검이었는데 `MaxActiveFor`에 정의역 구멍이 있었다: `Mathf.Max(MinActive, baseMax / ScarcityDivisor)`가 **하한을 보장 수량으로** 써서, `baseMax=1`이면 오염 리전이 **2**를 돌려준다 — 멀쩡한 리전(1)보다 곤충이 많아진다. 줄이는 정책이 늘리는 정책이 됐다. 도달 가능한 값이다: `GameplayTuningProfile.maxActivePerRegion`이 `[Range(1, 15)]`라 인스펙터에서 1을 넣을 수 있고 `InsectSpawner.ApplyTuning`이 `Mathf.Max(1, …)`로 그대로 받는다. `Mathf.Min(baseMax, …)`로 가뒀다. **기존 테스트가 못 잡은 이유가 그대로 교훈이다** — `MaxActiveFor_Blighted_IsScarcerThanClean`이 `baseMax=10` 하나만 봤다. 튜닝 Range 전체(1~15)를 도는 검사를 더했더니 **기존 테스트와 정면으로 부딪혔다**: `NeverDropsBelowMinActive(1)`이 "어떤 기본값에서도 MinActive(2) 이상"을 요구해, `baseMax=1`에서 '2 이상'과 '1 이하'가 동시에 성립할 수 없었다. 그 테스트의 **자기 문서**가 판정을 줬다 — 지키려는 건 "0이 아님"이고 MinActive는 그 수단이다. 하한을 `min(MinActive, baseMax)`로 정확히 다시 적고 절대 불변식(1 이상)은 따로 못 박았다. 수단을 목적으로 적어 둔 탓에 결함이 테스트를 통과하고 있었다. `TintOf`의 누적 탈색 의심은 거짓양성이었다 — `BlightVfx`가 언제나 보관된 `site.groundOriginal`을 넘긴다. 검증: 컴파일 error CS 0, ci_check 통과, blight_lint 20 PASS, PlayMode 734/734. +- 2026-08-27: VillageBuilder 재감사 — **P1:1 + 형제 2건 처리.** 체크리스트로는 clean이었다(생명주기 메서드·조회·이벤트·싱글턴이 전무한 순수 빌더). 결함은 **머티리얼 수명**에 있었다 — `materialCache`에 `new Material(...)`을 색상당 1개씩 만들고 `OnDestroy`가 아예 없었다. GameObject를 지워도 머티리얼은 안 지워지고 이 게임은 **로그아웃·계정삭제가 씬을 재로드**하므로 재로드마다 마을 한 벌이 통째로 샌다. 형제 빌더 둘(`RegionTerrainBuilder` 88 호출부, `WorldTerrainBuilder` 16)은 **캐시조차 없이** 같은 누수였다 — 직전 라운드가 UI 텍스처에서 '형제 둘에서도 발견'한 것과 같은 형태다. 셋 다 회수하되 **방식이 갈린다**: VillageBuilder는 생성 뒤 아무도 안 건드려 색상 캐시가 성립하고, 지형 빌더 둘은 `SetTransparent`로 변형하므로 색으로 공유하면 같은 색 불투명 지형까지 투명해진다 — 그쪽은 목록 추적이다. `TintOf` 누적 탈색과 `SubAreaWorldBuilder` 미회수 의심은 둘 다 거짓양성이었다. 검사기는 두지 않았다 — 저장소 전수 설문에서 26파일 중 18이 '미회수'로 잡혔는데 그 상당수가 static 캐시(UIHelper 등)나 헬퍼 경유 회수(SubAreaWorldBuilder)라 규칙이 크리스프하지 않다(P2 보고). 검증: 컴파일 error CS 0, ci_check 통과, PlayMode 734/734. +- 2026-08-27: BattleScreenUI · RegionManager 재감사 — **둘 다 clean(P0:0 P1:0).** BattleScreenUI는 큐 최고점(score 372)이었지만 실제로는 방어가 촘촘했다: `OnGUI` 매프레임 할당 0, `FindFirstObjectByType`는 null 캐싱, 과거 P0 둘(`OnEnable` 재구독 · `OnDisable` 슬로우모션 복구)도 그대로다. 그리기 시점에 공유 `GUIStyle`을 변형하는 곳은 장부 게이지 하나뿐이고 색을 정확히 되돌린다. **스킬 문서가 적은 그대로였다 — 채점은 '열어볼 순서'이지 '무엇이 문제인지'가 아니다.** `GUI.DrawTexture(whiteTexture)` 617곳(BattleScreenUI 327 · RaidBattleUI.Draw 290)은 `ui-layout.md`의 `UISurface` 규칙과 어긋나지만 전투 화면 2종의 렌더 방식 자체이고 IMGUI는 배치 캡처가 안 돼 검증 수단이 없다 — 감사 자동수정 대상이 아니다(P2). RegionManager는 리전 원 겹침 2쌍(meadow―swamp 41.9m · swamp―mountain 0.15m)이 나왔으나 `SecondActRegions_DoNotOverlapAnyRegion`이 **주석으로 명시 제외**한 기존 배치다. swamp 중심이 meadow 밖 25.6m라 `RegionEnter swamp`가 도달 가능해 진행 정지도 없다 — 발견되지 않은 결함이 아니라 문서화된 수용 부채다. 클라우드 재로드 등록·조회 캐싱도 정상. 검증: ci_check 통과, PlayMode 734/734. +- 2026-08-27: StoryDirector 재감사 — **P0:0 P1:0 · P2:1 보고.** 이번 세션 제 변경 67줄이 대상이었다. 미뤄 둔 트리거 체계는 견고했다: 중복 제거((type,param) 동일 시 skip) · 12초 안전망(`Time.unscaledDeltaTime`) · 모달/컷신 대기 · `RaidEnded` 구독↔해제 짝. **종 param이 붙으며 중복 제거가 덜 뭉치게 된 것**은 회귀가 아니다 — 승리 둘이 각각 평가되고 `IsSeen`이 이중 발화를 막는다. P2: `OnInsectCaptured`만 **즉시** 발화한다(BattleWin·GuardianDefeat·RegionCleansed는 전부 미룬다). 전투 중 포획과 레이드 승리 포획은 결과 화면 직전에 일어나고 `BattleScreenUI`는 자기 그리기를 모달로 억제하지 않으므로, 대사창이 **획득 EXP·캔디 패널 위로 열린다** — 미루기를 도입한 그 이유 그대로다. 고치려면 '결과 화면이 **열렸다**'를 알리는 짝 API가 필요한데(지금은 닫힘 통지만 있다) UI 2파일과 StoryDirector에 걸치는 설계 변경이고, IMGUI라 배치 캡처로 결과를 눈으로 못 본다 — 자동 수정 대상이 아니다. 검증: ci_check 통과, story_lint 23 PASS, PlayMode 734/734. +- 2026-08-29: 캐릭터 리워크 11파일 일괄 — **P0:1 + P1:5 처리.** 같은 세션이 만든 변경 1,068줄이 대상이라 큐 12건이 전부 이번 작업물이었다. **P0는 내가 넣은 테스트가 통과시킨 결함이다** — `ProcMeshLibrary.Disc`의 삼각형 순서가 자기 정점 노멀(+Z)과 반대여서 눈·동공·하이라이트·홍조가 정면에서 백페이스 컬링됐다. 와인딩 검사를 4개 생성기에 걸면서 **평면인 Disc만 뺐기 때문**이다(bounds.center가 면 위에 놓여 '바깥' 기준이 성립하지 않는다). 더 나쁜 건 그 증상을 내가 이미 봤다는 것이다 — Phase 2 캡처에서 얼굴에 눈이 없는 걸 보고 **'각도 탓'으로 결론냈다.** 어느 각도에서도 안 보이는 상태였다. 평면에도 성립하는 기준(면 노멀 ↔ 정점 노멀 일치)으로 검사를 다시 짜 5개 생성기 전부에 걸었고, 축퇴 삼각형·극점 노멀 검사도 함께 넣었다. 나머지 P1 넷: `TaperedCapsule` 반구 노멀에 수평 성분(sin)이 빠져 극점이 45°로 눕던 것, 같은 함수의 극 링 축퇴 삼각형 20개, `PlayerVisualBuilder.OnDestroy`의 `SafeDestroyMat`이 **사실상 항상 스킵**하던 것(색을 안 칠하는 노드 10~12개가 끝까지 sharedMaterial이고, '플레이어는 영구 객체'라는 전제도 DontDestroyOnLoad가 없어 틀렸다 — b9a9771과 같은 유형), `LoginUI.OnAuthFailed`가 생성 화면을 떠나며 프리뷰 override를 안 풀어 이후 의상 화면의 썸네일 24장까지 '버려진 얼굴'로 굽던 것(OnDisable은 안전망이 아니다 — LoginUI를 SetActive(false)하는 코드가 저장소에 없다). 드래그 상태가 창 밖 MouseUp에서 굳는 것과 2D 폴백 색 배열이 프리셋 5개에 3개뿐인 것도 함께. 검증: 컴파일 error CS 0, PlayMode 808/808, ci_check 통과, **캡처로 눈이 실제로 나타난 것을 확인**. +- 2026-08-29: StarterInsectProbe — **P0:0 P1:0 · 검증 공백 하나를 메웠다.** audit 큐는 소진 상태였지만 그 판정 기준(미검토 파일·변경 줄 수)으로는 안 잡히는 게 남아 있었다 — **첫 파트너 선택→지급 경로에 검증 수단이 하나도 없었다.** `StarterInsectTests`는 `ResolveChoice` 순수부만 보고, `StoryBeatWalkthrough`는 `ch1_intro`를 **선행 비트로 미리 채워** 그 경로를 안 걷고, `story_lint`는 Story.json을 정적으로만 읽는다. 선택이 통째로 무시돼도 장수풍뎅이가 조용히 들어올 뿐이라 화면으로는 '고른 대로 됐다'와 구분되지 않는다. 그래서 `StarterInsectProbe`를 신설했다 — `OnNpcTalked` → 대사창 대기 → `CloseModal`이라는 **플레이어가 실제로 지나는 경로**를 두드리고 보유·도감을 확인한다(보상은 발화가 아니라 **닫을 때** 난다). **프로브 자체가 두 번 틀렸고 그게 교훈이다.** ① `EnterPlaymode()`의 도메인 리로드로 static과 `EditorApplication.update` 구독이 날아가 영원히 대기만 했다(`LiveSceneCapture`가 `SessionState` + `InitializeOnLoadMethod`를 쓰는 이유). ② 고친 뒤엔 수동 `MoveNext()`가 중첩 코루틴을 펼치지 않아 **검증을 한 건도 안 하고 PASS를 냈다** — `testing.md`의 '0건 보고는 통과가 아니라 실패다'가 그대로 재현됐다. 코루틴 스택으로 펼치고 `probedCases == 0`이면 FAIL로 떨어뜨리는 가드를 넣었다. 결과: 연속 실행에서 2건이 '비트완료=False'로 실패했으나 같은 종을 `-probeOnly`로 **단독 실행하니 PASS**였다 — `ResetAll`이 `StoryDirector` 인메모리 상태까지 되돌리지 못한 프로브의 인공물이고, 실제 플레이어는 `ch1_intro`를 일생 한 번만 겪으므로 게임 결함이 아니다. 그 한계를 클래스 문서와 보고서 양쪽에 못 박았다. **4케이스(3종 + 선택없음) 전부 정상 지급 확인.** 검증: error CS 0, ci_check 통과, verify_coverage 0. +- 2026-08-29: OutfitRenderProbe — **P0:0 P1:0 · 두 번째 검증 공백을 메웠다.** 의상 형태는 `OutfitShapeLibraryTests`·`OutfitShapeParityTests`가 좌표와 스키마를 고정하지만 **'그 좌표에 실제로 메시가 그려지는가'는 아무도 안 봤다** — 이번 라운드 `ProcMeshLibrary.Disc`가 정확히 그 종류(면이 뒤집혀 예외 없이 사라짐)였다. 앞서 나는 이걸 'IMGUI라 스탠드얼론 빌드가 필요하다'고 판단했는데 **틀렸다**: 의상 화면이 보여주는 마네킹은 3D 리그이므로(전용 레이어·카메라·RenderTexture) 같은 방식으로 직접 세워 찍으면 IMGUI를 통째로 우회한다. `OutfitRenderProbe`가 그렇게 한다 — 아무것도 안 입은 기준 컷과 픽셀을 비교해 **레시피가 있으면 형태 변화가 있어야 한다**를 판정한다. 결과 11종 전부 PASS: spawn+hideNodes(hat_crown이 캡을 숨기고 왕관을 세움), spawn(acc_wings·acc_halo·outer_wizard·bag_dragon), bind(tool_net·tool_magnify), 얼굴 파츠(acc_glasses·acc_eyepatch). **대조군이 제 역할을 했다** — 레시피가 없는 `top_polo`는 변화 16px(색만 바뀜)로 정확히 갈렸다. 회전을 안 걸면 기본 0°가 뒤통수라 얼굴 파츠가 안 보인다는 것도 이때 드러나 `FrontYaw`를 걸었고, 그 덕에 **Disc 수정이 마네킹에서도 적용됨을 눈으로 확인**했다(눈·동공·눈썹·코·입 정상). 안경은 변화 229px이라 축소 화면에서 안 보여 결함을 의심했으나 확대해 보니 테가 정상 렌더 중이었다 — 성급한 육안 판단이었고 프로브 판정이 옳았다. 검증: error CS 0, ci_check 통과, verify_coverage 0. diff --git a/.claude/audit-progress.md b/.claude/audit-progress.md new file mode 100644 index 0000000..1031403 --- /dev/null +++ b/.claude/audit-progress.md @@ -0,0 +1,414 @@ +# Audit 진척 + +`/audit` 스킬과 훅 2개(`audit_flow_inject`, `audit_reminder`)가 읽고 갱신합니다. +**구조 유지 필수** — 특히 `## Uncovered`의 `- [ ]` 개수가 자동 플로우의 트리거입니다. + +- **Covered**: 처리 완료 영역 인덱스. 서술 원문은 `.claude/audit-archive/covered-detail.md` +- **Uncovered**: 다음 audit 후보 큐. 위에서 아래로 우선순위. + **비면 `python -X utf8 .claude/scripts/audit_candidates.py --write`로 재생성** + (`--emit-md`는 stdout에만 찍는다 — `--write`가 이 절을 직접 갈아 끼운다) +- **Round Log**: **최근 5건만** 둔다(이 줄이 개수의 단일 출처). 넘치면 + `.claude/audit-archive/round-log-2026H2.md`로 이관 — 아카이브는 날짜순 정렬 + +## Covered (P0/P1 처리 완료) + +- [x] CharacterPortraitRenderer (P0:2, P1:2, 2026-05-20) +- [x] PlayerVisualBuilder (P1:5, 2026-05-20) +- [x] CloudSaveManager (P1:2, 2026-05-20) +- [x] PlaySceneBootstrap (P1:1, 2026-05-20) +- [x] CharacterOutfitUI (P1:2, 2026-05-20) +- [x] CharacterOutfitManager (P1:2, 2026-05-20) +- [x] PlayerMovement (P1:1, 2026-05-20) +- [x] CashShopManager (P1:1, 2026-05-20) +- [x] CaptureMinigameController (P0:1, P1:1, 2026-05-20) +- [x] GachaBoxManager (P1:1, 2026-05-20) +- [x] TutorialQuestManager (P1:1, P0:4, 2026-05-20) +- [x] BattleScreenUI (P2:1 DrawCombo + P1:2 P2:1 추가, 2026-05-20) +- [x] SubAreaWorldBuilder (P0:8 + UI:1, 2026-05-20) +- [x] AuthManager (P1:2, 2026-05-20) +- [x] LoginUI (P1:2, 2026-05-20) +- [x] RaidBattleUI (P0:2, P2:1, 2026-05-20) +- [x] RaidBattleController (P1:1, P0:1, 2026-05-20) +- [x] InsectBattleController (P0:2, 2026-05-20) +- [x] CaptureController (P0:1, 2026-05-20) +- [x] CaptureFeedbackController (clean, 2026-05-20) +- [x] BattleArenaController (P1:1, 2026-05-20) +- [x] DexController (clean, 2026-05-20) +- [x] DexDetailUIController (clean, 2026-05-20) +- [x] DexScreenUI (P0:31, 2026-05-20) +- [x] DexListUIController (clean, 2026-05-20) +- [x] SaveService 글로벌 atomic write 라운드 (P1:8, 2026-05-21) +- [x] CollectionUI 핫스팟 (P0:1, P1:3, 2026-05-21) +- [x] TrainingUI 핫스팟 (P0:1, P1:3, 2026-05-21) +- [x] CollectionUI 잔여 영역 (P1:27, 2026-05-21) +- [x] TrainingUI 잔여 영역 (P1:34, 2026-05-21) +- [x] PlayerStatusHUD (P0:1, P1:2, 2026-05-21) +- [x] TrainingManager (P1:1, 2026-05-21) +- [x] CashShopManager 잔여 영역 (P1:3, 2026-05-22) +- [x] InsectSpawner Spawn/Despawn 다중 호출 검증 (clean, 2026-05-22) +- [x] RegionMapUI 깊은 점검 (clean P0, P1 보류, 2026-05-27) +- [x] TutorialQuestUI DrawQuestPanel 캐시 (P0:1, 2026-05-27) +- [x] WorldChannelManager 깊은 점검 (clean P0/P1, 2026-05-27) +- [x] **RegionTerrainBuilder c.y 이중 합산 버그 +- [x] Forest/Ruins 환경 추가 축소 +- [x] 메인 월드 Forest/Ruins region 환경 높이 (P1:2, 2026-05-27) +- [x] SubArea 덮개 가시성 (P1:2, 2026-05-27) +- [x] 어깨 추가 + SubArea 부유/환경 높이 후속 (P1:4, 2026-05-27) +- [x] 캐릭터 시각 버그 3종 (P1:3+구조 변경, 2026-05-27) +- [x] TutorialQuestUI 잔존 캐시 (P1:8, 2026-05-27) +- [x] CapturePopupUI 캐시 (P1:12, 2026-05-27) +- [x] RegionMapUI 캐시 + OnDisable 정리 (P1:28+1, 2026-05-27) +- [x] WorldLobbyUI MakeTex 누수 (P0:7, 2026-05-27) +- [x] CharacterOutfitUI infoStyle 캐시 + OnDisable 정리 (P0:1+P1:1, 2026-05-27) +- [x] CapturePopupUI 점검 (clean P0, P1:1 보류, 2026-05-27) +- [x] CharacterPortraitRenderer 깊은 정합성 (clean, 2026-05-27) +- [x] PlaySceneBootstrap 깊은 점검 (clean, 2026-05-27) +- [x] SubAreaWorldBuilder 깊은 점검 (clean, 2026-05-27) +- [x] TutorialQuestManager 깊은 점검 (clean, 2026-05-27) +- [x] BattleScreenUI 깊은 로직 (clean, 2026-05-27) +- [x] RaidBattleUI 깊은 로직 (clean, 2026-05-27) +- [x] BattleArenaController 이펙트 (clean, 2026-05-27) +- [x] PlayerMovement 모달 차단 + InputAction 흐름 (clean, 2026-05-27) +- [x] InsectSpawner (P1:2, 2026-05-20) +- [x] RegionManager (P1:3, 2026-05-20) +- [x] RegionTerrainBuilder + RegionMapUI (UI:2, 2026-05-21) +- [x] ItemEffectManager (clean, 2026-05-21) +- [x] WorldStateProvider (clean, 2026-05-21) +- [x] WorldChannelManager (P1:1, 2026-05-21) +- [x] BattleTeamManager (P1:2, 2026-05-21) +- [x] BattleTeamUI (P1:16+22, 2026-05-21) +- [x] PlayerInsectCollection (P1:3, 2026-05-21) +- [x] PlayerProgressController (P1:3, 2026-05-21) +- [x] PlayerProgressSaveService (P1:1, 2026-05-21) +- [x] PlayerCandyInventory (P1:2, 2026-05-21) +- [x] PlayerCurrencyWallet (P1:5, 2026-05-21) +- [x] PlayerItemInventory (P1:3, 2026-05-21) +- [x] PlayerInsectLevelUpUIController (P1:3, 2026-05-21) +- [x] ShopUIController (P1:1, 2026-05-21) +- [x] GachaBoxUI → CashShopUI 가챠 영역 (P0:11, 2026-05-21) +- [x] ModalUIRegistry (clean, 2026-05-21) +- [x] CashShopUI 보석 충전/아이템 상점 영역 (P0:16, 2026-05-21) +- [x] UIHelper (P0:1, 2026-05-21) +- [x] AudioManager (P1:1, 2026-05-21) +- [x] ProceduralAudioGenerator (clean, 2026-05-21) +- [x] InsectEntity 외부 로직 (P0:1, P1:3, 2026-05-21) +- [x] CameraFollower (P1:2, 2026-05-21) +- [x] InsectDatabase (P1:1, 2026-05-21) +- [x] OutfitSetData (clean, 2026-05-21) +- [x] PlayerItemInventoryGridUIController (P1:1, 2026-05-21) +- [x] WorldFieldMultiplayerUI (P0:1, P1:1, P2:2, 2026-07-17) +- [x] UIHelper 텍스처 캐시 오버플로 (P1:1, 2026-07-17) +- [x] AccountLinkUI (P1:3, P2:2, 2026-07-17) +- [x] SubAreaEnvironment (P1:1, 2026-07-17) +- [x] AccountSettingsUI (P0:1, P1:2, 2026-07-17) +- [x] SaveConflictUI (P0:1, P1 보류, 2026-07-17) +- [x] KeyGuideHUD (P1:2, 2026-07-17) +- [x] QuickAccessBarUI (P0:1, 2026-07-17) +- [x] SocialPvpUI (P0:1, P1:2, 2026-07-17) +- [x] SceneAutoWire (clean — dead code 확인 후 삭제, 2026-07-17) +- [x] VillageBuilder (P1:2, P2:1, 2026-07-17) +- [x] NpcManager (P1:1 + 튜닝 프로필 동반 수정, 2026-07-17) +- [x] NpcDialogueUI (P1:1, 2026-07-17) +- [x] CatcherKidNpc (P1:1, 2026-07-17) +- [x] VillagerNpc + NpcWalkAnimator (clean, 2026-07-17) +- [x] CaptureItemPickup + CaptureItemSpawner + CaptureProximityTrigger (clean — dead code 확인, 2026-07-17) +- [x] HospitalUI (clean P0/P1, P2:1 처리, 2026-07-19) — score 36(프레임할당9/싱글턴9) 둘 다 거짓양성 확인; 모달 ESC·결제 원자성·stale 아이템 등 회귀 7종 전부 회피(TrainingUI 패턴). P2(OnEnable 재구독) 후속 수정 완료 +- [x] GuidedTutorialController (clean P0/P1, 2026-07-19) — score 15(프레임할당5) 거짓양성(Rect/Color struct + GUIStyle 1회 캐시); Start↔OnDestroy 페어링이라 SetActive 소실버그 무관, Dictionary 기반 진행이라 정지·범위초과 없음 +- [x] StoryDirector (P1:1, P2:1 처리, 2026-07-19) — CaptureInsect가 InsectUpdated(포획+XP+치료+진화)를 오발화(치료경제가 악화) → 포획 전용 InsectCaptured 이벤트 신설(1884971). 싱글턴2 거짓양성(CloudSaveManager 가드 내). P2(렌더러 미배선 시 인트로 무음소모): deferredBeat 보류/구독 시 flush로 수정(c6f7fc4) +- [x] GameConstants (P0:0, P1:2, 2026-07-22) — 6스킬 상수의 stale 테스트를 6/7번째 기준으로 정정하고 story_progress.json을 SaveScope v4 마이그레이션·삭제 대상에 등록 +- [x] PlayerProgressUIController (P0:0, P1:1, 2026-07-22) — 런타임 텍스트 참조 바인딩 후 현재 레벨·XP·사탕 값을 즉시 다시 그리도록 Refresh 경로 추가 +- [x] InsectElement (clean P0/P1, 2026-07-23) — 전체 enum 상성 매핑, 이중 타입·중복 타입 처리, 미등록 enum 중립 폴백 및 1v1·레이드 호출부의 None 폴백 확인 +- [x] InsectExpansionDefinitions (clean P0/P1, 2026-07-24) — 신규 ID 64개의 유일성·기존 ID 비중복·리전/서브에리어 풀 배정과 insectId 저장 호환성 확인 +- [x] InsectLoreService (clean P0/P1, 2026-07-27) — DB/Lore ID 128/128 일치, 중복·누락 없음. AutoWire 순서와 정적 캐시 초기화·조회 경로 확인 +- [x] NpcDialogueDatabase (clean P0/P1, 2026-07-31) — RegionLines 키 7개가 RegionManager 진행 체인의 리전 ID 7개와 완전 일치. FNV-1a 해시·음수 Mod·null 폴백 안전, GetLines/GetVillagerName은 대화 시작·NPC 생성 시 1회 호출(프레임 할당 아님) +- [x] CharacterOutfitUI 재감사 (clean P0/P1, 2026-08-17) — 좌우 배치 증분은 `Mathf.Max` 가드와 고정 가상 캔버스로 안전. 구독 0개라 OnEnable 불필요(OnDisable은 모달 상태만 정리), `new GUIStyle` 9건은 전부 `UIHelper.CachedStyle` 팩토리(키당 1회). **이번 세션의 `CharacterPortraitRenderer` 주변색 수정이 이 화면에서 온전히 동작함을 확인** — GUI.color 리셋이 OnGUI 맨 끝 위생 복구뿐이라 페이드가 초상 뒤에도 유지된다 +- [x] DexScreenUI 재감사 (P1:5 처리 + 저장소 전수 스윕, 2026-08-17) — 증분은 clean. 직전 커밋이 남긴 단서("리터럴 문구는 text_fit_lint 대상이 아니다")를 따라가 **잘린 리터럴 라벨을 전 저장소에서 세는 스캐너**를 만들었다 — 82건(10%↑ 부족 36건, 13파일). DexScreenUI에서 이웃 레이아웃을 검산할 수 있는 5곳을 `LineH()`로 고쳐 77건(31건)으로 줄였다. 나머지는 전용 정리 라운드로 위임 +- [x] PlayerMovement 재감사 (P1:1 처리, P1:1·P2:1 위임, 2026-08-17) — **처리**: frozen 안내 문구가 대화·컷신·연출까지 전부 frozen인 탓에 스토리를 읽는 내내 화면 아래에 남고, 그때 ESC는 모달을 닫는 키라 "이동 잠금 해제"가 틀린 안내였다 → `ModalUIRegistry.IsAnyOpen()` 가드. **위임**: 같은 문구와 리전 차단 문구가 `UIScale` 밖 픽셀 좌표라 스케일≠1 기기에서 크기가 어긋나고 제스처바 아래로 들어간다(고치려면 UI 계층으로 옮겨야 — Core→UI 금지). `hasReceivedInput`은 제거된 배너의 잔재로 CS0414가 매 빌드 발생. `ui_layout_lint`가 Rect 인라인 `Screen.height - N`을 놓치는 것도 확인(실제 3건) +- [x] CollectionUI 재감사 (clean P0/P1, P2:1 보고, 2026-08-17) — 정렬 증분(+112줄)의 핵심 불변식(정렬 순서 ↔ 행 문자열 캐시가 인덱스로 결합)이 무효화 2지점·재구축 1지점에서 모두 지켜짐. TeamChanged 구독으로 팀 변경 시 낡음 방지, 비교자는 instanceId 타이브레이크로 전순서 보장. 거짓양성 2건 철회. P2: BuildRowTextCache의 null 분기가 발화 불가(Sort가 상류에서 거름) +- [x] UIHelper 재감사 (P1:1, 2026-08-17) — `LabelFit`의 폰트 축소 캐시 키(`FitKey`)가 (텍스트·폭·높이·기준폰트)만 담아 **서로 다른 스타일이 답을 공유**했다. 측정은 `CalcHeight`/`CalcSize`라 폰트·볼드·wordWrap에 좌우되고, 특히 `checkWidth = !wordWrap`이라 가로 검사를 안 한 값이 가로로 넘치는 라벨에 쓰일 수 있었다(조용한 글자 잘림 — LabelFit이 막으려던 결함이 캐시로 되살아남). 참조 동일성을 키에 추가 + 회귀 테스트 5건. `AnimatePanelOpen`의 호출 계약(wasOpen 고착으로 두 번째 열림부터 페이드 소실)도 문서화 +- [x] OpeningSceneController 재감사 (clean P0/P1, 2026-08-17) — 스타일 1회 가드, `Resources.UnloadAsset`(Destroy 아님) 정확, 구독 짝 3지점 정합, 일시정지 복귀 시 재생 클럭 리셋, 스킵/결과 보고 멱등(`resultReported`), 타임라인 상수 12개가 이미지 3장·내레이션 3줄에 빈 구간 없이 배분됨. score 66은 `EnsureStyles` 단일 가드 안의 할당이라 거짓양성 +- [x] StoryDirector 재감사 (P1:1, 2026-08-17) — 발화가 `AllBeats()`(Dictionary.Values, 순서 비결정)의 **첫 일치**를 집어, 동시에 자격을 갖는 비트가 있을 때 실행마다 다른 비트가 뜨고 HUD 목표와 갈렸다. 실측 5쌍(어르신 대화에서 `ch1_intro`(1막 개막+프롤로그 컷신) vs `talk_elder`(잡담) 포함). 순위 규칙을 `StoryObjectiveResolver.CompareBeatPriority` 하나로 모아 목표 도출·발화가 같은 답을 내게 함 +- [x] BattleScreenUI 재감사 (P1:1, 2026-08-17) — 직전 라운드의 P0 수정(`ConcludeDefeatWithoutSwap` 호출)이 `EnterResult` **재진입**을 만들었다: 종료 확정 → `BattleEnded` 발화 → `OnBattleEnded`가 다시 `EnterResult` → 결과 진입이 통째로 2회, 패배 SFX·BGM 겹침. 상태(`phase=Result`)를 Conclude **앞**으로 옮기고 `phase == Result` 조기 반환 추가. 명암비·LabelFit 증분과 신규 `BattleEffectTextOverlay`는 clean +- [x] StoryBeat (P1:1, P2:1 처리, 2026-08-17) — 데이터 모델은 clean(JsonUtility 관례·null 가드 전부 정상). 결함은 소비자 쪽 enum↔switch 드리프트: `StoryJournalUI.HintFor`의 문자열 리터럴 사본이 `GuardianDefeat`·`DexProgress`를 놓쳐 82비트 중 9건이 저널에서 "조건 미상"만 표시 → `StoryDirector.Trigger*` const로 교체 + 2케이스 추가. **P2 처리**: 읽는 곳 0이던 `oneShot` 필드 + JSON 키 82개 삭제(반복 구현은 앰비언트 비트도 캔디 5개를 줘서 무한 파밍이 되므로 배제), 재발 방지로 story_lint 검사 14(JSON 키 ↔ StoryBeat 필드 실재성) 신설 — 주입 4건 정확 검출 +- [x] ItemRarityPalette (clean P0/P1, P2:1 처리, 2026-07-31) — ItemRarity 5값 ↔ switch 6개 정합. `.asset` 인스턴스 부재로 `Resources.Load`가 항상 null이었으나 소비자 3곳 전부 null 가드 + 하드코딩 폴백이라 무해했음. **P2 처리**: `ItemRarityPaletteBuilder`(에디터)로 `Resources/ItemRarityPalette.asset` 생성 + 그리드 아이템 프리팹에 팔레트 주입 +- [x] PlayUIRefs (P1:1, 2026-07-31) — 90필드 ↔ 생성기 77대입 ↔ WireFromRefs 88읽기 대조. 미채움 13개 중 12개는 Text/TMP 이중화(의도된 설계)이나 `itemRarityPalette`만 짝 없이 비어 프리팹 경로에서 팔레트가 null → PlayUIPrefabGenerator에 로드 1줄 추가 +- [x] NpcVisualBuilder (clean P0/P1, 2026-07-31) — NpcWalkAnimator가 Find하는 노드명 8개 전부 일치(직계 자식), 머티리얼 생명주기 3스폰 경로 모두 OnDestroy에서 CleanupMaterials 커버. NpcManager가 NPC를 파괴하지 않고 SetActive로만 토글하므로 OnDisable이 아닌 OnDestroy에 건 것이 정확 +- [x] OpeningSceneController (P1:2, P2:1 처리, 2026-08-03) — score 48 "프레임 할당 16" 중 진짜는 `new GUIContent` 3개뿐(나머지 Rect/Color는 struct). 타이틀 구간 CalcSize 3회/패스를 화면크기 키로 메모이즈, OnDestroy가 재생 중인 AudioClip을 언로드하던 순서 수정, 매 프레임 PlayerPrefs 조회 제거 +- [x] UISurface (P1:1, **P2:3 전부 처리**, 2026-08-03) — 스타일 헬퍼 6종이 `CachedStyle("리터럴"+fontSize, 캡처 람다)` 형태라 호출마다 문자열+클로저+델리게이트 3개 할당(적중해도). int 키 캐시 하나로 통합 + 폰트 크기 clamp. **P2 후속 완료**: ①`EvictAll`이 `Object.Destroy`로 텍스처를 파괴하고 `GetRoundedStyle`을 private으로 닫음(UISurface.cs:101-111) ②`Rounded`가 앰비언트 알파를 보존 ③얇은 것 전용 `Flat` 신설, `HospitalUI:155,241`이 사용 + +- [x] RaidRoundResolver (clean P0/P1, P2:1 보고, 2026-08-03) — 순수 정적 리졸버라 checklist 7항목 전부 해당 없음. 1v1과의 계약 대조에서 **버프·디버프가 레이드에선 영구 누적**(effectDurationTurns 미사용, RecalculateBonuses 부재)임을 확인. **P2 종결(2026-08-03 사용자 결정: 현행 유지)** — 이후 도입된 `MaxBuffStacks=3`이 방향별 상한을 줘 최악(보스 공격 0.3배 고정)이 유한해졌고, 만료를 넣으려면 장비 보너스와 전투 버프가 한 필드에 섞인 구조부터 분리해야 해 레이드 난이도가 실측 없이 오른다. 의도된 divergence로 코드 주석에 못박음 + +- [x] RaidRoundModels (P1:1, 2026-08-03) — 호출부 0인 `SetBossDamage`가 슬롯 기록 + TotalDamageToTeam 가산을 같이 해, 컨트롤러의 기존 합산과 겹치면 팀 피해가 두 배가 되는 함정이었다. 제거 + 재발 방지 주석 + +- [x] CaptureChanceCalculator (P1:1, 2026-08-03) — 레벨 차 상한(±5)이 전투 경로에만 있고 미니게임 경로엔 없어, 메인 필드가 항상 Lv.1부터 스폰하는 탓에 고레벨 플레이어의 저레벨 전설 포획이 사실상 보장됐다. 두 경로를 같은 MaximumLevelDelta로 통일 + +- [x] QuestRewardFormatter (P1:2, 2026-08-03) — GrantRewards가 적용하는 rewardInsectLevel이 표시에서 누락(q_approach의 Lv.6 장수풍뎅이가 종만 표시)되던 것 + HasAny가 포함 조건을 복사해 두 곳에 있던 것을 공유 술어로 통합 + +- [x] DexBrowseLayout (P1:1, **P2:1 처리**, 2026-08-03) — WrapIndex는 정확했으나 호출부가 항상 ±1이라, 좌측 1열 리스트→전체 폭 그리드 개편 뒤 ↑↓가 아래 행이 아니라 옆 칸으로 갔다. 행 이동을 lastListColumns로 수정. **P2 후속 완료**: 호출부 0인 `FormatElementLabel` 제거(테스트도 함께, 대신 생산 코드가 쓰는 `ShouldShowSecondary`를 직접 고정) + 보유 탭의 `floor(panelW/410f)` 자기 공식을 `GetGridColumns`로 통일(폭 300~1400 중 1229 한 곳만 2→3열로 1px 빨라짐) + +- [x] OpeningReplayCoordinator (P0:1, P1:2, 2026-08-03) — UI 루트 SetActive 토글이 BattleScreenUI/RaidBattleUI/RegionMapUI의 OnDisable 해지를 되살리지 않아, 오프닝 다시보기 한 번에 배틀·레이드 화면이 영구 먹통. OnEnable 재구독 + subscription_lint 신설로 CI 고정 + +- [x] OpeningSequenceState (clean P0/P1, 2026-08-03) — 타임라인 상수 8개 ↔ 이미지 인덱스/블렌드 전 구간 정합, Complete 이중 발화 가드, 스킵이 현재 페이드를 이어받는 처리, 시계 clamp·역행 방어, 게이트 armed 전이 전부 확인. 파일 수정 0건 + +- [x] UIDirectScroll (P1:1, 2026-08-03) — 클래스엔 z-order 개념이 없는데 도감 개편으로 상세 모달이 그리드 위에 겹치면서, 먼저 그려지는 그리드가 모달 위의 휠·드래그를 Use()로 가로채 상세가 스크롤 불가(터치는 두 인스턴스가 같은 손가락을 잡아 동시 스크롤). Handle에 interactive 매개변수 추가 + 겹친 배경은 입력 포기 + +- [x] UITween (P1:1, 2026-08-03) — `Evaluate`가 호출마다 `elapsed`를 더했는데 유일한 호출부가 `OnGUI`(프레임당 Layout+Repaint+입력마다 1패스)라 0.2초 페이드가 2배 이상 빨랐고 마우스를 움직이면 더 빨라졌다. `TweenHandle.lastFrame`으로 프레임당 1회만 전진. 이징 5종의 0→0/1→1 계약을 테스트로 고정 + +- [x] UISafeLayout (clean P0/P1, **P2:1 처리**, 2026-08-03) — 좌표계 계약(Px ↔ UIScale.Begin 미사용 / 가상 ↔ Begin 사용) 34개 파일 전수 일치, Begin/End 전 경로 균형 확인(불일치로 보인 6건은 주석 2 + 조기 return 4). **P2 후속 완료(문서화로 종결)**: 미사용 4멤버를 `rules/ui-layout.md` API 표에 올리고, `UISafeLayout.ContentWidth`(MarginX 24 고정판)와 `UIScale.ContentWidth(margin)`(일반판)이 중복이 아님을 명시 — 삭제하면 ContentTop/Bottom/Height만 남아 비대칭이라 누가 다시 넣는다. `Overflows`는 원래 표에 있었다 + +- [x] InsectBattleStats (clean P0/P1, 2026-08-03) — 부호 있는 스택 카운터(±MaxBuffStacks)가 반대 방향 전환을 막지 않는지, `protected set`이 `RaidBossStats` 상속 때문에 필요한지 확인. **핵심은 보너스 이중 적용 여부** — `DefenseBonus`는 `ApplyDamage` 안에서만, `AttackBonus`는 호출부 3곳에서만 곱해져 겹치지 않는다 +- [x] InsectSizeCalculator (clean P0/P1, 2026-08-03) — FNV-1a 음수 방어(`Abs(int.MinValue)` 회피), `-1` 센티넬 → instanceId 해시 폴백, 무게가 길이 배율의 세제곱인지, 라벨 포맷 경계(10g/1000g, 100mm) 전부 확인 +- [x] WeeklyContestSchedule (clean P0/P1, 2026-08-03) — 주차 경계(604800 전후)·음수 시각·빈 풀 폴백, insectId 사전순 정렬로 기기 간 같은 대상 종 보장, 티어 임계 3단 정합 확인 +- [x] WeeklyContestManager (clean P0/P1, 2026-08-03) — 포획 전용 `InsectCaptured` 구독(치료·레벨업 오발화 회피), World 루트라 SetActive 토글 무관(Start↔OnDestroy 페어링), PlayerPrefs 키가 `int.TryParse`만 써 culture 무관, 주차 캐시로 128종 정렬 반복 회피 확인 +- [x] NpcDuelController (P1:1, 2026-08-03) — 도주 시 `DuelEnded`가 발화하지 않아 90초 재도전 쿨다운이 통째로 우회됐다(주석은 "결과와 무관하게"라고 적혀 있는데 한 결과가 빠져 있었음). `TryEscape`에 발화 추가 + 풀 인덱스의 `Abs(int.MinValue)` 음수 방어를 순수 함수로 분리해 테스트 고정 + +- [x] InsectBattleController 재감사 (P1:1, 2026-08-03) — `RecalculateBonuses()`가 전투 **시작** 경로에 없어 의상·아이템 ATK/DEF 보너스가 첫 턴에 적용되지 않았다. 하필 `SeedPersistentState`가 감염 곤충일 때만 `AddEffect`→Recalculate를 태워서 "독에 걸려 있어야 의상 보너스가 켜지는" 상태. `BeginBattleCommon` 끝에 호출 추가 + +- [x] PlaySceneBootstrap 재감사 (clean P0/P1, 2026-08-03) — 감사 이후 2107줄 변경분 대상. EnsureComponent 경로 87개·AutoWire 오버로드·리플렉션 주입 154건·Resources.Load·부팅 canary·신규 등록 3종 전수 대조. 파일 수정 0건 + +- [x] RegionMapUI 재감사 (P1:1, 2026-08-04) — 도감 브라우저가 항목마다 매 OnGUI 패스에 `$"등급 | CP n"`(보간+enum 박싱+CP 재계산)과 `new string('?', len)`을 새로 만들었다. 둘 다 종 데이터 파생이라 불변 — insectId·이름 길이로 캐시 + +- [x] DexScreenUI 재감사 (P0:1, P1:2, **P2:3 후속 처리**, 2026-08-06) — 3D 썸네일 도입 접점의 결함. 그리드 2곳이 뷰포트 컬링 없이 전 항목을 돌아 24칸 LRU가 영구 스래싱(P0) + `GetInsectData`의 캡처 람다 O(N)(P1) + `OwnsShiny`가 매 패스 List 할당(P1) + +- [x] RaidBattleUI 재감사 (P1:3, **P2:2 + 배너 복원 후속 처리**, 2026-08-06) — 결함 3건 모두 5f0776f의 라운드 파이프라인 교체가 남긴 것. 유나이트 종료 상한이 2.5s→1.15s로 바뀌었는데 2D 오버레이 타임라인은 2.5s 그대로라 5인 팀의 착탄 3건·최종 폭발·TOTAL이 전부 미렌더 + `wantMouseClick` 탭 래치 + TurnAnnounce 배너 기계장치 사장 + +- [x] BattleScreenUI 재감사 (P1:2, P2:1 보고, 2026-08-06) — 인트로/결과 탭이 **지난 전투의 `escapeRect`로** 소비돼 턴을 보기도 전에 도주가 실행되던 탭 래치(RaidBattleUI와 같은 계열) + `GetSkillColor`가 `SkillEffectType` 7종 중 3종만 처리해 신규 4종이 전부 회색 + +- [x] SubAreaWorldBuilder 재감사 (P1:1, 2026-08-06) — 서브에리어 진입마다 런타임 머티리얼 41개가 새고 있었다(`Destroy(subAreaRoot)`는 GameObject만 지운다). 25m 이탈·[E] 재진입으로 세션 내내 반복되는 경로라 누적된다 + +- [x] CharacterModelPreviewRenderer (P1:3, 2026-08-06) — 카드마다·패스마다 썸네일 키 문자열 생성(구조체 키로 교체) + 렌더 프레임마다 PlayerPrefs 4회(InvalidatePreview 배선) + 경계 계산의 배열 반환판(리스트 채우기판으로) + +- [x] OutfitShapeLibrary (P1:2, 2026-08-06) — 슬롯 컨테이너 이름의 enum→문자열 할당을 미리 굽고, **프리뷰가 각도만 바뀐 프레임에도 옷을 통째로 다시 입히던 것**을 함께 차단(교차 수정) + +- [x] RaidSupportPlanner (P1:2, 2026-08-06) — Stage 4가 버프를 팀 전체로 바꿨는데 플래너는 시전자 스택만 보고 있었고(쓸 수 있는 스킬을 버림), 보스 연속기절 면역도 몰라 저항당할 시도를 반복했다 + +- [x] CharacterOutfitData (P1:2, 2026-08-06) — `GetPrimaryBonusText`가 카드마다·패스마다 문자열을 만들고(바로 윗줄 GUIStyle 회귀는 막아뒀던 자리), 세트 별 표시가 덧붙이기 루프였다. 둘 다 캐시로 교체 + +- [x] UIShapes (P1:1, 2026-08-06) — 중간 roundness 혼합 수식의 **방향이 뒤집혀** 있었다(큐브가 타원, 원통이 사각형). 2D 의상 카드 폴백이 실제로 그 값을 넘기고 있었음 + +- [x] TutorialQuestUI 재감사 (P1:1, 2026-08-06) — 상세 패널이 열려 있는 동안 패스마다 List 2개를 만들고 퀘스트 31개를 다시 갈랐다. 분류가 불변이라 원본 배열 참조를 키로 캐시 + +- [x] TrainingUI 재감사 (**P0:1**, P1:1, 2026-08-06) — 보유 목록이 컬링 없이 전 개체에 3D 썸네일을 요청해 24칸 LRU가 영구 스래싱(도감 P0와 같은 구조) + 개체마다 정보 문자열 2개를 매 패스 생성 + +- [x] 3D 썸네일 목록 컬링 일괄 스윕 (**P0:4**, 2026-08-06) — `InsectVisual.Draw` 호출부 11곳 전수. 팀편성·보유곤충·병원·지역맵 4곳이 컬링 없이 전 항목을 돌아 24칸 LRU가 영구 스래싱. 단일 진입점에 규칙 명문화 + +- [x] BattleArenaController 재감사 (P1:1, 2026-08-06) — 스킬 연출마다 만든 런타임 머티리얼 31경로가 아무도 안 지워 전투가 길수록 누적. CleanupArena에서 일괄 파기 + +- [x] RaidBattleController 재감사 (P1:1 + 방어 1, 2026-08-06) — 오늘 레이드 5단계로 924줄 바꾼 파일 자체 감사. **회복 수치가 시전자 슬롯에 뜨던 것**(Stage 4에서 대상이 최저 HP 아군으로 갈렸는데 표시가 안 따라감)을 받은 슬롯으로 이전 + +- [x] LoginUI 재감사 (clean P0/P1, 2026-08-06) — score 183은 InitStyles 가드 안의 GUIStyle 40여 줄이라 거짓양성. 텍스처 3종 생성기 전부 정리 목록에 등록, 구독 짝·싱글턴 가드 정상. 파일 수정 0건 + +- [x] TutorialQuestManager 재감사 (clean P0/P1, 2026-08-07) — quest_lint 7 PASS. 세이브 5키 × 클라우드 4지점 완전, 구독/해지 6/6 대칭(**q_team 영구정지를 냈던 `battleTeamManager.TeamChanged` 라인 존재 확인**), Update는 조기이탈 3중 + Find 캐시. 파일 수정 0건 + +- [x] ProceduralAudioGenerator 재감사 (clean P0/P1, 2026-08-07) — static 생성기라 검사 항목 1~7이 대부분 해당 없음. 요청 문자열 ↔ 생성기 case를 **양방향 전수 대조**해 누락 0·사장 0 확인(계산 문자열 경로 2종 포함). `CreateClip`의 in-place 게인은 배열 공유가 없어 이중 적용 불가. 파일 수정 0건 + +- [x] CashShopUI 재감사 (clean P0/P1, **P2:1 처리**, 2026-08-07) — score 203은 `mainStylesReady`/`gachaStylesReady` 가드 안 GUIStyle 29개라 거짓양성. 탭 3곳 전부 매니저 null 가드, 결제 2단계 검증·환불, 캐시 3종 정상. **P2 처리**: 카드 라벨을 `cardTextCache`로 품목당 1회만 굽되 **실결제 가격은 캐시 제외**(IAP 준비 시 스토어 현지화가로 바뀜) + 레이아웃 전환 시 무효화 + +- [x] InsectEntity 재감사 (P1:2, 2026-08-07) — `AnimateWings`가 매 프레임 `transform.Find` 2회 + `insectId.Contains` 최대 10회를 다시 했다(날개 없는 종은 그 Find가 영원히 실패 — 실패하는 Find가 가장 비싸다). `Update`의 NameLabel 조회도 배틀 모델에선 영구 실패. 둘 다 같은 파일의 캐시 7개와 같은 형태로 1회 확정 + +- [x] WorldChannelManager 재감사 (P1:1, 2026-08-07) — 온라인 필드에 올리는 내 레벨을 옛 저장소 `PlayerPrefs("player_level")`에서 읽어 **신규·로컬 플레이가 영원히 Lv.1**로 보였다(복원 계정은 복원 시점 값으로 고착). CloudSaveManager가 같은 이유로 이미 진행도 컨트롤러를 우선하는데 이 클래스만 그 정정에서 빠져 있었다 + +- [x] CollectionUI 재감사 (P1:1 + 사장 코드 2, 2026-08-07) — 보유 목록이 행마다·패스마다 문자열 4개를 새로 만들었다(enum 박싱 + SizeLabel + 보간 2). 전부 (종·개체·레벨) 파생이라 불변이고 무효화 배선(`ownedCacheDirty`)은 이미 있어서 `cachedOwned`와 같은 순간에 함께 굽게 했다. 빈 `Update()`·빈 `DrawToggleButton()` 제거 + +- [x] RaidBattleUI 재감사 (clean P0/P1, 2026-08-07) — "2961줄 변경"은 실체가 `Draw.cs` 분리(이동)라 신규 로직은 116줄뿐. 사장 필드 0건(과거 결함이 전부 이 계열이었다), 입력 표면 4종의 페이즈 간 잔존을 전부 추적해 안전 확인. 파일 수정 0건 + +- [x] RegionTerrainBuilder 재감사 (P1:1, 2026-08-07) — 배치 난수 87회가 시드 없이 돌아 **월드 지형지물이 실행마다 다른 자리에 섰다**. collider를 남기는 9종(나무·통나무·바위·죽은나무·생울타리·아치기둥·폐허 벽/기둥)이라 장식이 아니라 지나갈 수 있는 길 자체가 바뀐다. 고정 시드로 가두고 전역 난수 상태는 finally로 원복(스폰·IV·포획은 그대로 무작위) + +- [x] CharacterPortraitRenderer 재감사 (P1:2, 2026-08-07) — `DrawWithOutfit`가 `GUI.color`를 복원하지 않고 끝나 **호출부의 다음 라벨이 장착 악세서리 색으로 물들었다**(상점 "보유 재화/💎/🪙"). + `DrawOutfitAccessories`가 OutfitCache를 우회해 매 OnGUI 패스에 PlayerPrefs + 스코프 키 문자열을 새로 만들고 있었다 + +- [x] PlayerVisualBuilder 재감사 (P1:1 + 트랩 1, 2026-08-07) — `OnDestroy`가 슬롯 필드 11개만 손으로 나열해, 얼굴·머리 장식이 **지역 변수로** 만든 6~9개가 정리 목록에 오르지 못했다. 마네킹은 파괴가 정상 수명이라 다시 지을 때마다 샜다. 생성 지점(`MakeMaterial`) 하나에 등록을 걸어 호출 19곳 무변경으로 전부 덮음 + +- [x] CharacterOutfitManager 재감사 (P1:1, P2:1 보고, 2026-08-07) — `GetItemsForSlot`이 `allOutfits.Where(...).ToArray()`라 **의상 패널을 열어 둔 내내 프레임당 2회 이상 95벌을 훑고 배열을 새로 할당**했다(OnGUI는 Layout+Repaint로 최소 2패스). 카탈로그는 `BuildCatalog` 이후 불변이라 `Initialize`에서 슬롯별로 한 번만 갈라 캐시하고, 유일한 LINQ였던 터라 `using System.Linq`도 함께 뗐다. **P2로 넘긴 것**: `unlockCondition`을 **평가하는 코드가 어디에도 없다** — 그리는 곳(`CharacterOutfitUI:722`)만 있고, 부여 API `UnlockItem`은 호출부가 0건이다. 그래서 조건부 4벌(`hat_flower` region_garden / `shoe_waders` region_pond / `top_lab` level_15 / `acc_badge` quest_q_complete)이 `price=0`·`gemPrice=0`·`unlockedByDefault=false`라 **구매도 해금도 불가능한 영구 잠금 상태**다. 고치려면 해금 시점을 어디에 걸지(리전 해금 이벤트/레벨업/퀘스트 완료) 정해야 해서 게임 디자인 결정이 섞인다 — 자동 처리 범위 밖으로 두고 보고만 했다 + +- [x] CloudSaveManager 재감사 (P1:1, 2026-08-07) — **같은 세션에서 방금 만든 결함을 잡았다.** Phase 5의 명부회 간부 보스전이 격파 기록을 `InsectGame.DefeatedLedgerBosses`(PlayerPrefs CSV)에 쓰면서 **클라우드 동기 5지점을 배선하지 않았다.** `save-system.md`가 "퀘스트 세이브 필드를 늘리면 CloudSaveManager 4곳을 함께 고쳐야 한다"고 못박은 바로 그 함정이고, 증상은 **기기를 바꿀 때마다 같은 간부와 다시 싸우고 승리 보상을 다시 받는 것**이다. `defeatedGuardians`와 완전히 동형이라 그 5지점(수집·적용·직렬화·파싱·DTO)에 나란히 붙였다. 추가로 `NpcDuelController`를 `ICloudReloadable`로 만들고 Bootstrap에 `RegisterReloadable`했다 — PlayerPrefs만 갈아끼우면 인메모리 `defeatedBosses` 캐시가 낡아 그 세션 동안은 여전히 미격파로 보인다(RegionManager 해금 상태와 같은 이유). **통과 확인**: `storyProgress`(이번 2막에서 늘어난 세이브)는 이미 5지점이 전부 배선돼 있었다. `ApplyCloudFile`의 "빈 값이면 로컬 보존, forceReplace면 삭제"는 부트 로드(보존)와 명시적 '클라우드 사용'(치환)을 가르는 **의도된 설계**라 결함이 아니다(주석이 근거를 적어 뒀다). **거짓양성으로 제외 1건**: `CollectSaveData`의 `AuthManager.Instance.DisplayName`이 무가드로 보이나, 진입점 4곳이 전부 `Instance == null || !IsLoggedIn` 조기이탈을 통과하고 `CollectSaveData`는 첫 `yield` 이전(동기 구간)에 실행된다 + +- [x] CapturePopupUI 재감사 (P1:1 ×5파일, 2026-08-07) — **`"beetle"`이 `"bee"`를 품는데 `id.Contains("bee")` 분기가 `stag`/`rhinoceros`/`hercules`/`longhorn`보다 앞에 있었다.** 그래서 **딱정벌레 31종(사슴벌레·장수풍뎅이·헤라클레스·하늘소·물방개·비단벌레 포함)이 벌 그림으로 그려졌다.** 포획 팝업은 방금 잡은 곤충을 보여주는 자리라 증상이 가장 잘 보이는 곳이다. 같은 사슬이 저장소에 **5곳** 복제돼 있고 그중 `InsectEntity.BuildModel`만 `&& !id.Contains("beetle")` 가드를 갖고 있었다 — 나머지 4곳(`CapturePopupUI`·`DexScreenUI`·`BattleScreenUI`·`RaidBattleUI.Draw` ×2)에 같은 가드를 붙였다. 규칙이 5곳에 복제된 이상 또 어긋날 것이므로 `InsectPortraitRoutingTests`(4검사)를 새로 만들어 **소스에서 조건식을 읽어** 가드 누락을 고정했다. **거짓양성으로 제외 1건**: `InsectEntity.ResolveWings:510`의 `bee || dragonfly`는 모델이 아니라 **날갯짓 속도·진폭**을 정하는 분기라 딱정벌레가 걸려도 종이 바뀌지 않는다. **통과 확인**: 2026-05-27 라운드가 고친 GUIStyle 12개 캐시(`popupStylesReady` 가드)가 살아 있고, `stars` 배열은 포획 시 1회 할당(프레임 경로 아님), `OnEnable`↔`OnDisable`의 `CaptureResolved` 짝과 `AutoWire`의 `-=` 뒤 `+=`가 모두 성립한다 + +- [x] AuthManager 재감사 (인증 코어 clean, 드리프트 1건 처리, 2026-08-07) — **인증의 급소는 전부 정상이었다**: 토큰·비밀번호를 찍는 `Debug.Log`가 0건이고, `ClearAuth`가 인메모리 6필드 + PlayerPrefs 4키를 모두 지우며, `Logout`이 `ClearAuth`(토큰 무효화) **전에** 마지막 클라우드 플러시를 쏘고(첫 yield 전 헤더 설정 + `DontDestroyOnLoad`라 씬 리로드 후에도 완료), `DeleteAccount`가 `SetDeletionInProgress(true)`로 자동저장의 Firestore 문서 재생성(PII 부활)을 먼저 차단한다. `ScopedKey`는 uid가 없으면 전역 키로 떨어지는 **의도된 폴백**(비로그인 진행 보존)이다. **처리한 것 — 하드코딩 리전 목록 드리프트**: `ApplyMasterPrivileges`가 `"meadow,pond,forest,swamp,mountain,garden,ruins"`를 문자열로 박아 둬, 2막 6지역 + ruins 수문장 신설로 **해금 6건·격파 7건이 누락**됐다. 마스터는 `RegionManager.IsRegionAccessible`의 우회로 이동 자체는 되지만(그래서 증상이 낮다), `IsGuardianDefeated`엔 우회가 없어 **지도에 수문장이 미격파로 남고 필드에 스폰된다** — 진행을 건너뛰라고 있는 계정인데 2막에서만 안 건너뛴다. `RegionDefinitions.CreateAll()`에서 파생하도록 바꾸고, 다시 하드코딩되면 잡도록 `RegionProgressionTests.MasterPrivileges_DeriveRegionLists_NotHardcoded`를 추가했다. **증상 자체는 P2급(이동 가능·표시만 어긋남)인데 처리한 이유**: 고치는 방법이 새 동작 추가가 아니라 **단일 출처 파생**이라 위험이 없고, 두면 리전을 추가할 때마다 같은 자리가 또 낡는다 + +- [x] CharacterOutfitUI 재감사 (P1:1, 2026-08-07) — **잠긴 의상 카드에 원문 토큰이 그대로 그려졌다.** `GUI.Label(hintRect, item.unlockCondition, ...)`이라 한국어 게임 화면에 `region_garden`·`level_15`·`region_pond`·`quest_q_complete`가 노출된다(조건부 의상 4벌 전부). `DescribeUnlockCondition`으로 문장화했고 — 지역명은 **`RegionDefinitions`에서 파생**한다(이름을 박으면 리전을 고칠 때 낡는다, 같은 세션의 AuthManager 드리프트와 같은 형태) — 미지의 형식은 토큰을 그대로 돌려줘 새 조건을 추가해도 칸이 비지 않는다. 그리는 쪽은 `GUI.Label` → `UIHelper.LabelFit`로 바꿨다: 문구 길이가 데이터에서 오는데 상자가 고정 26px이고 `wordWrap = true`라, 한국어로 길어지면 아랫줄이 통째로 잘리는 자리였다(`rules/ui-layout.md`). `OutfitUnlockConditionTextTests` 5검사로 고정. **이 라운드는 P2와 겹치지만 다른 것을 고쳤다** — 직전 `CharacterOutfitManager` 라운드가 보고한 "조건부 의상 4벌이 영구 잠금"은 **해금 판정**이 없다는 것이고 게임 디자인 결정이 섞여 그대로 남아 있다. 여기서 고친 건 **표시**뿐이다. **거짓양성으로 제외 2건**: ①score 155의 실체인 `new GUIStyle` 12개는 전부 `stylesInitialized` 가드 뒤라 1회만 돈다(이번 큐에서만 다섯 번째로 나온 같은 형태다). ②호버 툴팁이 매 패스 문자열 7개를 만들지만 **한 번에 카드 하나**라, 목록 규모를 P1로 봤던 CollectionUI 라운드가 명시적으로 제외한 것과 같은 크기다. **통과 확인**: `OnDisable`이 `isOpen = false` + `Unregister`를 함께 해 stale 모달을 남기지 않고(옛 회귀의 방어 흔적이 주석에 있다), 툴팁·프리미엄·힌트 스타일이 전부 `UIHelper.CachedStyle`을 탄다 + +- [x] InsectSpawner 재감사 (P1:1, 2026-08-07) — **스포너 본체는 clean이었고, 결함은 그것을 먹이는 부트스트랩에 있었다.** `PlaySceneBootstrap.GetRegionLevelRange`가 ver1 7지역만 아는 switch에 `default: return 5`라, **2막 6지역이 전부 default로 떨어져 필드 스폰 레벨 상한이 47/51/55/59/63/67에 묶여 있었다**(의도는 48/52/56/60/64/70). 에러도 안 나고 화면상 티도 안 나서 오래 남을 결함이다. 6개 case를 `Docs/StoryBible.md`의 리전 표와 같은 대역으로 넣고, **default를 조용히 넘기지 않고 `LogWarning`을 남기게** 했다 — 다음에 리전을 추가하는 사람이 알아채야 한다. `RegionProgressionTests.EveryRegion_HasExplicitSpawnLevelRange`로 고정. **거짓양성으로 제외 3건 — 전부 내가 세운 가설이었고 코드를 읽어 철회했다**: ①`GetUnderpopulatedRegionId`가 플레이어 위치를 안 봐서 먼 리전에 스폰을 낭비할 것 같았으나, `GetAvailableSpawnPoint`에 **55m 거리 게이트**가 이미 있어(주석이 "60m 디스폰 밖 스폰은 5초 내 정리되는 낭비"라고 이유까지 적어 뒀다) 먼 리전이 선호되어도 실제 스폰은 플레이어 근처로 떨어진다. ②리전이 7→13이 되며 `spawnPointCount/regionCount`가 반토막 날 것 같았으나, `pointsPerRegion = Max(base, radius/11)`이라 **반경 비례가 지배**해 리전 수와 무관하다(6~8개 유지). ③`Update`의 리전 필터가 매 프레임 List를 할당하는 것처럼 보였으나 `spawnIntervalSeconds`(5초) 스로틀 뒤에만 돈다. **통과 확인**: `DespawnEntity`가 `activeSelf` 가드 뒤에 풀 반환하고, `DespawnAllActiveInsects`가 스냅샷 후 리스트를 먼저 비워 콜백 재진입이 no-op이며, `SubAreaChanged` 구독이 `AutoWire`(`-=` 뒤 `+=`)와 `OnDisable`에서 대칭이다 + +- [x] PlayerMovement 재감사 (P1:1, 2026-08-07) — **걷는 내내 매 프레임 `Collider[]`를 2개씩 할당했다.** `IsBlockedPosition`이 `Physics.OverlapSphere`(할당형)를 쓰는데, Update가 이동 중 **두 번** 부른다 — 다음 위치 차단 판정(`nextPos`)과 끼임 감지(`transform.position`). 초당 120개 배열이 GC로 가는 셈이라 모바일에서 특히 나쁘다. `OverlapSphereNonAlloc` + 고정 버퍼(16)로 바꿨고, 같은 형태였던 `IsClearAt`(안전 위치 탐색이라 한 번의 복구에서 여러 번 돈다)도 같은 버퍼를 쓰게 했다 — **둘은 호출이 겹치지 않는다**(끼임 판정이 끝난 뒤에야 `RecoverToSafePosition`이 불린다). 버퍼가 차도 오판정이 없다: 가득 찼다는 건 이미 막을 것을 찾았다는 뜻이라 결과가 바뀌지 않는다. **거짓양성으로 제외 2건**: ①이번 세션에 월드 경계를 320→520으로 넓혀 이동 제한이 낡았을까 봤으나, PlayerMovement엔 좌표 클램프가 **없다** — 경계는 `WorldTerrainBuilder`가 세운 물리 벽이 담당하므로 자동으로 따라간다. ②`IsBlockedPosition`의 `foreach (regionManager.Regions)`가 리전 7→13으로 두 배가 됐지만 `ContainsPoint`는 곱셈 두 번이라 프레임당 13회는 무시할 수준이다. **통과 확인**: 리전 잠금이 이동 단계에서 실제로 강제되고(`IsRegionAccessible`, 서브에리어 안에서는 좌표계가 달라 의도적으로 건너뛴다), OnGUI 스타일 3종이 캐시 필드이며, 클릭 이동 raycast가 `try-finally`로 player layer를 복원한다(본인 raycast 통과 회귀 방어 흔적) + +- [x] BattleTeamUI 재감사 (P1:2, 2026-08-07) — **①화면 전체가 한국어인데 이 화면만 영어로 남아 있었다**: 제목 `"BATTLE TEAM"`, 피커 제목 `"Select Insect for Slot {n}"`, 뒤로 버튼 `"< Back"`. 퀵바(`QuickAccessBarUI`)와 HUD(`PlayerStatusHUD`)가 이 화면을 **"배틀팀"**으로 부르고 있어 같은 말로 맞췄다. 화살표는 ASCII `<`를 유지했다 — 그 라벨에서 이미 렌더되던 글자라 폰트 아틀라스가 확실하다(전각 `<`로 바꿨다가 되돌렸다). **②빈 `Update() { }`** — 본문이 없어도 Unity는 매 프레임 managed→native 호출을 한다. `CollectionUI` 재감사(같은 날)가 지운 것과 같은 것이라 지우고 되살리지 말라는 주석을 남겼다. **거짓양성으로 제외 3건**: ①score 102의 실체인 `new GUIStyle` 11개는 전부 `teamStylesInit` 가드 뒤다 — **이번 큐에서만 여섯 번째로 나온 같은 형태**(LoginUI·TrainingUI·CollectionUI·CashShopUI·CharacterOutfitUI에 이어). ②피커 목록이 개체마다 `$"Lv.{n} | CP {n}"`을 매 패스 만들지만 `DexBrowseLayout.GetVisibleRowRange`로 **이미 컬링돼** 보이는 행에만 돈다(CollectionUI 라운드가 넣은 방어가 여기선 처음부터 있었다). ③슬롯 5칸의 `$"Lv.{lv} | {data.rarity} | CP {cp}"`는 enum 박싱을 포함하나 **5개 고정**이라, 목록 규모를 P1로 봤던 라운드가 "한 번에 개체 하나~다섯 행"을 명시적으로 제외한 것과 같은 크기다. **통과 확인**: `CloseModal`과 `OnDisable`이 `isOpen`·`selectingSlot`·`directScroll`·레지스트리를 동일하게 정리해 stale 모달이 남지 않는다 + +- [x] StoryJournalUI (P1:1, 2026-08-08) — **탭 한 줄이 프레임마다 스토리 전체를 훑고 있었다.** `DrawChapterTabs`가 챕터 15개마다 `SeenIn()`으로 그 챕터 비트를 세는데, 안쪽 `HasSeen`이 `progress.seenBeatIds.Contains`(**`List` O(진행도)**)라 패스당 **비트 72개 × 진행도만큼의 문자열 비교**가 든다. 다 본 세이브면 패스당 5천 회가 넘고, OnGUI는 프레임당 Layout+Repaint 최소 2패스에 마우스를 움직이면 더 돈다. 여기에 챕터마다 `$"{라벨} {done}/{total}"` 보간 문자열 15개와 `ChapterLabel`의 15칸 선형 탐색이 얹혔다. **열람 상태는 저널을 열어 둔 동안 절대 바뀌지 않는다** — 다시 읽기(`ShowStoryReplay`)가 seen을 마킹하지 않는 게 이 화면의 설계이고 클래스 주석이 그 이유를 적어 뒀다. 라벨과 `done == 0` 판정을 `SeenCount`를 키로 캐시했다(저널 밖에서 비트를 하나 더 열람하면 키가 달라져 자동으로 다시 굽는다). **자체 회귀 1건을 즉시 되돌림**: 처음엔 배경색 판정을 캐시된 라벨에서 문자열 파싱(`EndsWith`+`Substring`)으로 되읽게 썼는데, 그건 매 패스 문자열 연산을 도로 넣는 것이라 `bool[]`을 함께 캐시하는 형태로 고쳤다. **거짓양성으로 제외 3건**: ①score 41의 "프레임 할당 10"은 대부분 `new Rect`/`new Color`(struct, 스택) + `EnsureStyles`의 `new GUIStyle` 7개(`stylesReady` 가드 뒤 1회) — **이번 큐에서만 일곱 번째로 나온 같은 형태**. ②"싱글턴 참조 11"의 실체인 `UITheme.Instance`는 정적 필드에 캐시돼 첫 호출만 `Resources.Load`를 탄다. ③`DrawBeatList`가 컬링 없이 `list.Count`를 전부 도는 게 도감·보유목록 라운드의 P0와 같은 형태로 보이나, **3D 썸네일 요청이 없어 LRU 스래싱이 없고** 한 챕터 최대 8행(ch7)이라 뷰포트(824px)에 거의 다 들어와 스크롤조차 잘 생기지 않는다 — 규모로 보면 기존 라운드들이 명시적으로 제외한 "한 번에 다섯 행" 급이다. **통과 확인**: `IModalUI` 3짝(`Toggle`/`CloseModal`/`OnDisable`)이 `isOpen`·`directScroll`·레지스트리를 동일하게 정리하고(주석이 "isOpen을 남기면 ESC가 건너뛴다"는 옛 회귀를 적어 뒀다), `ReplayBeat`가 `HasSeen`으로 미열람 비트를 한 번 더 걸러 대사 소비/보상 누출을 막으며, `EnsureIndex`의 미등록 챕터 정렬이 Dictionary 순회의 비결정성을 없앤다. Bootstrap 배선(`EnsureComponent` → `AutoWire(storyDirector, npcDialogue)` → `quickBar.AutoWire`)도 완전하다 + +- [x] InsectExpansion2Definitions (P1:1, 2026-08-08) — **데이터 자체는 clean이었고, 결함은 그것을 소비하는 쪽과의 "짝"을 아무도 검사하지 않는다는 것이었다.** 54종의 ID 유일성·1막 64종과의 비충돌·레어도 밴드(weight/difficulty 5등급 × 2범위) 위반 0건·등급 분포(C18/U12/R12/E10/L2)·풀 배정·`BuildModel` 라우팅 54종 전수를 확인했고 파일 수정은 없다. **처리한 것**: 시드 파일 주석이 "habitat 태그는 `InferPrimaryType`의 zone 폴백과 **짝**이다 — 한쪽만 고치면 오타가 조용히 Bug 속성으로 떨어진다"고 경고하는데, 기존 `NewSeeds_HabitatTags_AreKnown`은 **시드 쪽만 자기 하드코딩 HashSet과 맞춰 봤다.** 짝의 반대편(부트스트랩)에서 분기가 사라지거나 오타가 나면 그대로 통과하고, 그 리전 종 6~12마리의 속성이 전부 Bug가 된다 — 속성은 상성 계산의 입력이라 전투가 통째로 어긋나는데 에러도 경고도 없다. 같은 형태가 하나 더 있었다: Epic/Legendary 12종의 **전용기 switch**도 "누락하면 default 『궁극 생태 해방』으로 조용히 떨어져 전용기가 전부 같은 이름이 된다"는 주석만 있고 검사가 없었다. 둘 다 소스를 읽어 대조하는 테스트로 고정했다(`InsectPortraitRoutingTests`가 같은 이유로 만든 선례를 따랐다 — 분기가 private 인스턴스 메서드라 실행할 수 없다). **검출력 실측**: `zone == "Canopy"`에 오타를 주입하니 Canopy를 잡았고, `case "mantis_canopy":`를 지우니 그 종을 잡았다. **거짓양성으로 철회 2건 — 둘 다 내가 세운 가설이었고 코드를 읽어 접었다**: ①"2막 6지역이 zone 폴백에 하나도 없다"고 봤으나 `grep -A 45`의 범위가 짧아 못 본 것이었다(3139~3151에 6개가 전부 있다). ②`IsAntInsectId`가 `mantis`의 'ant'를 잡아 사마귀가 전부 Earth로 떨어질 것 같았으나, 구현이 `StartsWith("ant_") || Contains("_ant_") || Contains("antlion")`이라 애초에 안 걸린다. 이번 큐에서 세 번째로 반복되는 교훈이다 — **의심스러운 구조를 발견하면 방어 코드를 먼저 찾을 것.** + +- [x] NpcBossDuels (P1:1, 2026-08-08) — **표 자체는 clean이었고, 결함은 주석이 약속한 검증이 실제로는 없다는 것이었다.** 파일 주석이 "곤충 ID·아이템 ID는 각각 InsectExpansion2Definitions / ItemDatabase에 실재해야 하며 `NpcBossDuelTests`가 그 정합을 고정한다"고 적었는데, 그 테스트 8건을 열어 보니 곤충은 `EveryBoss_InsectExistsInExpansion`이 실재성을 보지만 **아이템은 `EveryBoss_HasRewardAndDisplayName`이 `string.IsNullOrEmpty`만 본다.** 오타를 물어도 런타임엔 조용히 실패해(퀘스트 보상과 같은 함정 — `LogWarning`만) **간부를 이겼는데 보상만 안 들어온다.** 아이템 ID가 캡처아이템·상점 진열·상점 지급·ItemDatabase **네 소스의 합집합**이라 C#에서 다시 모으면 사본이 생겨 어긋나므로, 그 레지스트리를 이미 완비한 `quest_lint.py`에 검사를 붙였다(`extract_boss_duel_rewards` + 검사 4b). 추출이 0건이면 `ExtractorBroken`을 던지게 해 파서가 낡았을 때 "위반 0건"으로 조용히 죽지 않게 했다 — 이 파일의 관통 원칙이다. **검출력 실측**: `net_gold` → `net_goldd` 오타를 주입하니 `ledger_grip:net_goldd`를 잡았고, 현재 3건은 전부 실재한다(`net_gold`·`full_restore` ×2, 아이템 31종 대조). 주석도 "고정하는 곳이 둘로 나뉜다"로 정확히 고쳤다 — 약속과 실제가 어긋난 게 이 라운드의 본질이라 문서를 그대로 두면 다음 사람이 또 믿는다. **통과 확인**: 상대 곤충 3종(`centipede_sand`/`mantis_icicle`/`moth_effaced`)이 2막 정의에 실재하고, `storyNpcId` 3개가 story.json의 `NpcTalk` 트리거·화자와 일치하며, `ledger_ink`가 표에 없다(잿불 골짜기에서 이탈해 아군이 되므로 — 테스트가 명시적으로 고정한다). `All()`이 struct 배열을 복사해 돌려주고 `TryGet`은 3항목 선형 탐색이라 성능 표면이 없다. 호출부 3곳(`NpcDuelController` 진입·표시·보상)도 배선돼 있다 + +- [x] OutfitShapeLibrary 재감사 (P1:1, 2026-08-08) — **직전 라운드가 고친 머티리얼 누수의 "나머지 절반"이었다.** 2026-08-07 `PlayerVisualBuilder` 라운드가 `OnDestroy`의 손나열을 `MakeMaterial` 생성 지점 등록으로 바꿔 17~20개를 덮었는데, **spawn 의상 파츠의 머티리얼은 생성 지점이 `OutfitShapeLibrary.CreatePartMaterial`로 따로 있어 그 목록에 들어가지 않는다.** 이쪽 정리는 `TrimContainer`뿐이고 그건 갈아입을 때 **남는** 파츠만 지우므로, **마지막까지 입고 있던 파츠**는 루트가 통째로 파괴될 때 아무도 안 지운다. 마네킹은 파괴가 정상 수명이다 — `CharacterModelPreviewRenderer`가 외형 해시(성별·피부·머리·얼굴)가 바뀔 때마다 `Destroy(mannequin)` 후 다시 짓고, 그건 캐릭터 커스터마이즈 화면에서 토글 한 번마다 일어난다. 동시 착용 기준 **최대 22개**(hat 7 / acc 6 / outer 5 / bag 4)가 매번 샜다. `DestroySpawnedMaterials(root)`를 신설해 `PlayerVisualBuilder.OnDestroy`가 자기 목록을 비운 직후 부르게 했다. **bind 노드는 건드리지 않는다** — 그쪽 소유자는 PlayerVisualBuilder라 이중 파기하면 캐릭터가 분홍색이 된다(그 파일 주석이 적어 둔 검정/마젠타 회귀와 같은 자리). 가르는 기준이 노드 이름의 `OP_` 접두 하나뿐이라 `BindNames_NeverStartWithSpawnPrefix`로 고정했다. **거짓양성으로 철회 2건 — 둘 다 내 가설이었고 코드를 읽어 접었다**: ①`hideNodes`(HideCap/HideBackpack)를 끄는 코드는 있는데 되살리는 코드가 없어 "모자를 바꾸면 기본 캡이 영영 안 돌아온다"고 봤으나, 복원은 `ApplyPartColor`가 하고(알파가 있으면 `SetActive(true)`) `ApplyToCharacter`가 **ApplyPartColor → ApplyShapeRecipe 순서**로 부른다 — 켜고 나서 필요할 때만 다시 끄는 구조다. `OutfitShapeLibrary` 주석 270행이 "SetActive(true)도 하지 않는다 — 판정은 ApplyPartColor 한 곳에만 둔다"고 그 계약을 명시해 뒀다. ②`TryGet`이 `id.Contains` 사슬이라 부분문자열 오매칭이 있을 줄 알았으나, Tool만 그렇고(주석이 순서 의존을 명시 + `OutfitRecipeProjectionTests`가 분기를 고정) 나머지 슬롯은 `ExactRecipes.TryGetValue` **정확 매칭**이다. **통과 확인**: 갈아입기 경로의 누수는 이미 막혀 있다(`TrimContainer`가 머티리얼을 함께 파기, 벗을 때도 `TrimContainer(container, 0)`로 도달) / `ApplySpawned`가 파츠 재사용 시 `sharedMaterial.color`만 바꿔 `renderer.material` 게터의 인스턴스 증식을 피한다 / `GetPrimMesh`가 PrimitiveType별 built-in 메시를 1회만 추출 / 기존 테스트 22건이 카탈로그 정합·bind 노드 실재·도구 분기 도달성을 이미 덮는다 + +- [x] CharacterModelPreviewRenderer 재감사 (P1:2, 2026-08-08) — **`RenderTexture.Release()`는 GPU 리소스만 반환하고 객체는 남긴다**(다시 `Create()`하면 되살아나는 게 그 설계다). 캐시에서 버리는 텍스처를 `Release()`만 하고 놓아 두면 참조가 끊긴 껍데기가 쌓여 씬 전환(`UnloadUnusedAssets`) 전까지 남는다. 이 파일의 세 정리 지점(24장 LRU 축출 · 외형 변경 시 전체 무효화 · `OnDestroy`)이 전부 그랬다. 외형 토글 한 번마다 최대 24장이 버려지므로 커스터마이즈 화면에서 반복 누적된다. `DisposeRT` 헬퍼로 `Release()` + `Destroy()`를 묶었다. **같은 결함이 형제 파일에도 있었다** — 도감 3D 썸네일 `InsectModelPreviewRenderer`의 세 지점(축출 · `OnDestroy` ×2)이 같은 형태라 같은 헬퍼로 고쳤다(이번 세션에서 네 번째로 반복된 "형제 파일 복제" 결함이다: 상점↔PvP, PlayerVisualBuilder↔OutfitShapeLibrary에 이어). **두 번째 P1 — 마네킹이 고아로 남았다**: `mannequin`은 씬 루트에 서 있어(부모가 없다) 이 컴포넌트가 죽어도 따라 사라지지 않는데, `Destroy(mannequin)`이 외형 변경 경로에만 있고 수명 종료 경로엔 없었다. `OnDestroy`에 추가했고, 그러면 그쪽 `PlayerVisualBuilder.OnDestroy`가 머티리얼과 spawn 의상 파츠까지 연쇄로 정리한다(직전 라운드가 붙인 `DestroySpawnedMaterials`가 여기서 값을 한다). **통과 확인**: 직전 감사(2026-08-06)가 넣은 구조체 썸네일 키·`InvalidatePreview` 배선·경계 계산의 리스트 채우기판이 그대로 살아 있고, `EnsureRig`가 1회 가드를 갖는다 + +- [x] PlayerStatusHUD 재감사 (**P0:1**, P1:2, 2026-08-08) — **P0: `AutoWire`가 `insectCollection` 구독을 안 걸어 COLLECTION의 "보유" 숫자가 세션 내내 고정됐다.** Bootstrap이 `EnsureComponent` → `AutoWire` 순서라 `AddComponent`가 동기로 부르는 `OnEnable` 시점엔 `insectCollection`이 아직 null이고, 그쪽 `insectCollection != null` 가드가 거짓이라 구독이 통째로 건너뛰어진다. 그러면 `ownedCountCacheDirty`를 되살릴 경로가 `OnEnable` 하나뿐이라 첫 프레임 이후 영구 stale — 곤충을 잡아도 방출해도 숫자가 안 변한다. **바로 아래 `regionManager`가 같은 이유로 이미 지연 구독 형태를 쓰고 있는데 여기만 빠져 있었다**(형제 UI `CollectionUI`·`HospitalUI`도 전부 AutoWire에서 재구독한다). `rules/ui-layout.md`가 못박은 구독 소실 계열인데 `subscription_lint`는 `OnDisable` 해지↔`OnEnable` 재구독 짝만 봐서 이 형태(애초에 구독된 적 없음)를 못 잡는다. **P1-1: 펼침 패널이 좌측 스택을 통째로 덮는데 그 아래 버튼이 여전히 클릭을 받았다.** 패널은 x[safeL+20, safeL+500] × y[ContentTop, +540]이고 미니맵(ContentTop+150~370)·퀘스트 칩·목표 행(ContentTop+380~)이 완전히 그 안이다. IMGUI는 겹침으로 입력을 막지 않으므로 **안 보이는 목표 행 버튼이 눌린다**(오늘 Phase A에서 그 자리에 버튼을 새로 놓아 실입력 결함이 됐다). `expanded` 기본값이 true라 **데스크톱은 첫 프레임부터** 해당된다. `IsExpanded` 공개 + `MinimapUI.LeftStackOccluded`로 좌측 스택이 스스로 빠지게 했다(칩은 모바일에서만 그 자리라 `IsMobileLayout &&` 조건이 붙는다 — 데스크톱 칩은 좌하단이라 무관). **P1-2: 서브에리어 진입 배너만 세이프에어리어 하네스를 안 썼다.** 같은 파일 패널은 `UISafeLayout.ContentTop`으로 전환됐는데 이 배너만 y가 70/74/110으로 박혀 있어, 노치 기기(`VirtualSafeTop ≈ 130`)에서 배너 전체가 상태바 뒤로 들어간다 — 하네스를 도입한 바로 그 플랫폼에서 실패한다. `ContentTop + 70`으로 옮겨 상단 중앙 관례를 지켰다(ContentTop 토스트 → +30 퀘스트 토스트 → **여기 +70~142** → +150 가이드 코치). `ui_layout_lint`는 금지 관용구 목록 기반이라 맨 정수 리터럴은 안 잡는다. **거짓양성으로 제외**: `OnGUI`의 `new Rect`/`new Color`(struct — 파일 주석도 같은 결론), `stylesInitialized` 가드 뒤 GUIStyle(이번 큐에서만 여덟 번째), `Update`/`OnGUI` 내 Find류 없음, `SubAreaChanged`는 `subscribedSubArea` 플래그로 이중구독·누수 모두 방어됨, 세이브 데이터 없음, 508줄 + `DrawXxxSection` 분리가 이미 돼 있어 모놀리스 분해 실익 없음 + +- [x] GachaBoxManager 재감사 (P1:1, P2:1 처리 · P2:1 보고, 2026-08-08) — **확률 공시의 마지막 구멍은 표기가 아니라 풀 배치였다.** `data_lint`가 이미 지키는 것은 ①`gachaExclusives` ID ↔ displayName 매핑 ②등급 임계값 분포(합 100%·단조증가) ③UI 표기가 코드에서 파생되는가 셋인데, **`normalPool`의 티어 배치는 아무도 안 봤다.** 이 파일 주석이 "각 티어의 ID는 InsectDatabase 실제 rarity와 일치해야 함(저가 박스가 상위 곤충을 누출하지 않도록)"이라 못박아 뒀는데도 그렇다. `GetDbRarity`는 결과 **표시**만 보정하고 **풀 선택은 바꾸지 않으므로**, 배치가 어긋나면 브론즈 상자가 55% 확률로 상위 곤충을 흘린다 — 공시한 확률과 실제 분포가 갈리는 것이고 이건 스토어·법 이슈다. 기존 검사 9번이 지키는 건 "표기가 코드에서 파생되는가"이지 "그 코드가 옳은가"가 아니다. `data_lint`에 검사 10번(`extract_gacha_normal_pool` + `extract_insect_rarities`)을 붙였다 — 등급 출처 셋(1막·2막 시드 + 부트스트랩 `CreateStableInsect`)을 합쳐 대조하고, 추출 실패는 `ExtractorBroken`으로 죽어 조용한 PASS를 막는다. **실측: 현재 풀 64종 전부 정합**(불일치 0, DB 미존재 0, 등급 194종 대조)이라 이번 라운드는 회귀 방지가 목적이다. **검출력 실측**: 한 종의 등급을 바꿔 주입하니 `stag_beetle(풀=Rare 실제=Legendary)`를, DB에서 빼니 `Uncommon:mantis_green`을 잡았다. **P2 처리**: 도감 등록이 상자를 열 때마다 `FindFirstObjectByType()`로 씬을 훑었다 — 바로 위 `database`만 캐시가 있고 여기는 빠져 있어서, 같은 형태의 lazy 캐시로 맞췄다. **P2 보고(자동 처리 범위 밖)**: 가챠 전용 곤충 확률(gold 50% / silver 30% / bronze 20%)이 `GetRateText`에 **표기되지 않는다** — 등급 확률만 공시된다. 한국 게임법의 확률형 아이템 구성 비율 공시 의무에 걸릴 수 있으나 법적 판단 + 표기 문안이 섞여 결정이 필요하다. **통과 확인**: `isOpening` 가드가 rapid-click 이중 지급을 막고, 세 보상(곤충·캔디·도감)이 각자 try-catch라 하나가 실패해도 나머지가 지급되며, `ValidateInsectId`가 data drift를 `beetle_basic`으로 폴백하고, 임계 배열 3종이 전부 4칸이라 `rates[t.Length]`가 Legendary에 정확히 떨어진다 + +- [x] CameraFollower 재감사 (**P0:1**, P1:2, 2026-08-08) — **P0는 같은 세션에서 두 시간 전에 내가 만든 것이었다.** Phase D의 `CutsceneDirector.Play`에 재진입 가드가 없었다. 재생 중에 `Play`가 다시 불리면 "되돌릴 상태 기록"(`restoreBattleMode`/`restoreFrozen`)이 **컷신 1번이 스스로 만든 값**(`battleMode=true`, `frozen=true`)을 읽고, 그러면 `Stop()`이 복구를 **둘 다 건너뛴다** — 조작이 영영 안 돌아오고 카메라도 배틀 모드에 갇힌다. 클래스 주석(18행)이 "못 되돌리면 영구히 움직일 수 없는 상태가 된다"고 스스로 경고해 둔 바로 그 상태다. `if (playing) Stop();` 한 줄로 막았고 `ModalUIRegistry` 중복 등록도 함께 사라졌다. **도달 경로는 지금 데이터로는 안 닿는다** — Explore가 제시한 연쇄(`GrantReward` → `ProgressChanged` → `LevelReach` 비트 발화 → 그 모달을 닫으면 재생 중 `Play`)는 실재하지만, 컷신이 붙은 2비트가 둘 다 `SubAreaEnter`라 그 연쇄에 걸리지 않는다. 그래도 P0로 처리한 이유는 **위험이 비대칭**이라서다: 방어가 한 줄이고 증상은 영구 먹통이며, `StoryBible`이 "컷신을 더 붙일 때"의 절차를 이미 적어 뒀다. **P1-1: 배틀 구도 클로버.** `restoreBattleMode`가 true면 `ExitBattleMode`를 건너뛰는데(그건 맞다), `battlePos`/`battleRot`는 컷신 마지막 컷 값으로 남아 **전투 화면 카메라가 그 구도로 굳는다**(`LateUpdate`가 매 프레임 적용한다). `EnterBattleModeFramed`가 덮어쓰기만 하고 되읽을 방법이 없던 게 원인이라 `GetBattleFraming`을 신설해 빌려 가기 전에 기억하고 끝나면 되돌린다. `BattleWin` 트리거 비트가 `BattleEnded`에 발화하고 `BattleScreenUI`의 `ExitBattleMode`는 결과 화면 정리에서만 도므로 그 사이가 창이다. **P1-2: 쉐이크 래치가 역대 최댓값을 되감았다 — 이건 컷신과 무관한 기존 결함이다.** `Max(shakeIntensity, intensity)`로 최댓값을 붙들고 `shakeTimer = shakeDuration`으로 그 값을 통째로 되감아서, `Shake(0.55, 0.55)` 0.2초 뒤 `Shake(0.1, 0.3)`을 부르면 **0.55/0.55가 처음부터 다시 재생**됐다. 래치는 타이머가 완전히 만료될 때만 풀리므로, 레이드처럼 타격이 연달아 오면 흔들림이 영영 잦아들지 않는다. 비교 대상을 "지금 **남아 있는** 세기"(`curIntensity × curTimer/curDuration`)로 바꿨다 — 새 호출이 더 강하면 갈아타고, 약하면 세기는 두고 지속만 늘린다(여진이 본 흔들림을 끊지 않게). 순수 함수 `ResolveShake`로 분리해 `CameraShakeTests` 7건으로 고정했다(이 저장소 관례 — `CutsceneTimeline`·`StoryObjectiveResolver`와 같은 형태). **Explore가 검증해 준 거짓양성 1건 — 내가 위임 전에 세운 가설이었다**: `EnterBattleModeFramed`를 컷신이 매 프레임 다시 부르면 `normalPos`/`normalRot`(복귀 지점)가 배틀 카메라 위치로 덮여 복귀 지점을 잃을 줄 알았으나, 그 두 필드는 **저장소 어디서도 읽히지 않는 죽은 필드**다(grep 확인). 복귀는 `battleTransition` 감쇠로 살아 있는 팔로우 위치를 향해 이뤄진다. **통과 확인**: `battleTransition`을 `Enter*`가 리셋하지 않는 것은 주석이 적어 둔 의도라 매 프레임 재진입에도 전환이 정상 완료되고, `Shake`는 배틀 모드에서도 적용되며(그 블록이 `battleMode` 분기 밖) `Time.unscaledDeltaTime`을 써 컷신의 무배율 시계와 맞는다. 기존 소비자 4곳은 `Enter*`를 각 1회만 부르고, `FocusOn`은 `battleMode`에서 자기 차단, `ReleaseFocus`는 `focusTimer <= 0`이면 no-op이라 컷신과 다투지 않는다 + +- [x] WorldLobbyUI 재감사 (**P0:1**, P1:2, 2026-08-08) — 2026-05-27 라운드는 **텍스처 누수(P0:7)만** 처리했고 그 뒤 267줄이 바뀌었다. **P0: 월드 선택 화면 뒤에서 캐릭터가 걸어 다녔다.** 이 클래스는 `IModalUI`를 구현하지 않고 `ShowLobby()`의 정상 경로가 `SetFrozen(true)`를 걸지 않아, 전체화면 오버레이 뒤에서 WASD 이동(`frozen`만 게이트)과 클릭 이동·포획(`ModalUIRegistry.IsAnyOpen()`만 게이트)이 전부 살아 있었다. **계약 불일치가 증거다** — 같은 `WorldSelect` phase로 들어가는 다른 경로 `OnWorldLeft`는 "로비로 돌아오면 이동 잠금" 주석과 함께 이미 잠그고 있었다. 최초 진입만 빠진 것이다. **Bootstrap 주석이 그 자리를 뺀 이유를 적어 뒀다**("월드 선택 미완료 시 frozen이 풀리지 않는 버그 회피 / 이전: SetFrozen(true) 호출") — 그래서 되돌리기 전에 퇴출 경로를 전수 확인했다: `WorldSelect`를 빠져나가는 길은 셋뿐이고(입장 성공 `OnWorldJoined` / 오프라인 진입 / `skipLobby`) 전부 `SetFrozen(false)`를 부른다. 유일한 예외 `HideLobby()`는 **호출부가 0인 죽은 public 메서드**였고, 나중에 누가 부르는 순간 그 옛 버그가 되살아나므로 거기에도 해제를 넣었다. `IModalUI` 구현은 `IsOpen => phase != Hidden`이고 `CloseModal`은 **InWorld 오버레이만** 닫는다 — 월드 선택은 필수 단계라 ESC 하나로 온라인을 포기시키면 안 된다(Explore는 오프라인 라우팅을 권했으나 채택하지 않았다). phase 대입이 파일 곳곳에 10군데 흩어져 있어 등록/해제를 따로 붙이면 반드시 하나를 빠뜨리므로 `SetPhase()` 단일 통로로 모았다. `OnEnable`/`OnDisable`에도 등록·해제를 넣었다(오프닝 다시보기가 UI 루트를 껐다 켜는 계열). **P1-1: 입장 클릭이 조용히 삼켜지고 "접속 중"에 갇혔다.** 두 호출부가 phase를 **먼저** Joining으로 바꾸고 매니저를 부르는데, `CanStartRequest`는 `IsBusy`면 **이벤트조차 쏘지 않고** 반환한다. 로비에 있는 동안 목록 새로고침이 5초마다 자동으로 돌아 `IsBusy`가 상시 켜졌다 꺼지므로 하필 그 구간의 클릭이 증발한다 — 화면만 "접속 중"으로 남는다. 매니저가 이미 노출한 `IsBusy`를 눌린 순간 확인하는 `BeginJoinRequest()`로 통일하고, 그래도 이벤트가 끝내 안 오는 경우(다른 요청이 `ActionCompleted`만 쏘며 `IsBusy`를 잡고 있었던 경우 — 이 UI는 그 이벤트를 구독하지 않는다)를 대비해 15초 타임아웃 폴백을 뒀다(매니저 자체 타임아웃 12초보다 길게 잡아 정상 실패가 먼저 오게 한다). **P1-2: InWorld 패널에 에러 표시가 없었다.** `OnError`가 `errorMsg`를 채우는데 그리는 코드가 월드 선택 패널에만 있어서, 나가기가 실패하면 phase는 InWorld 그대로라 **화면에 아무 변화도 없고** 버튼만 계속 눌렸다("눌러도 아무 일 없는 버튼"). 에러 라벨 + 요청 중 `GUI.enabled = false` + "나가는 중..." 라벨을 넣었다. **통과 확인**: `InitStyles`는 `stylesInitialized` 1회 가드(이번 큐에서만 아홉 번째 같은 거짓양성), 이벤트 4쌍 `+=`/`-=` 대칭, Bootstrap이 매니저를 먼저 생성해 `OnEnable`의 `Instance`가 non-null, 플레이어 목록은 `MaxPlayersPerWorld = 5`라 행 문자열은 규모 제외 대상 + +- [x] AudioManager 재감사 (P1:2, P2:1 보고, 2026-08-08) — **두 결함 다 "오늘 내가 넣은 기능"과 부트스트랩 사이에서 났다.** **P1-1: 게임을 시작하면 리전 곡이 안 나왔다.** `DelayedAudioStart`가 2프레임 뒤 무조건 `PlayBGM(Explore)`를 부르는데, 그 사이 frame 0의 `RegionManager.Update`가 이미 `RegionChanged(meadow)`를 쏴 `ExploreMeadow`가 걸려 있다. `PlayBGM`의 조기 반환 가드는 "같은 곡이면 무시"라 **다른 곡인 이 호출은 그대로 통과해 리전 곡을 덮었다** — 리전 곡 13개를 만들어 놓고 시작할 땐 늘 범용 곡이 흐르고, 리전을 옮기거나 첫 전투가 끝나야 제 곡이 나오는 역전이었다. 공교롭게 오늘 넣은 `RestoreExploreBGM()`이 부트스트랩보다 정확해서(마지막 리전 곡을 알고, 없으면 Explore로 떨어진다) 그걸로 교체했다. **P1-2: 보스 BGM에 긴장 램프가 안 걸렸다.** Update의 피치 램프 조건이 `Battle || RaidBattle`을 직접 적어 둔 형태라 보스 2종이 else로 떨어져 피치가 능동적으로 1.0으로 되돌려졌다 — 벽으로 설계된 간부·최종전이 유일하게 긴장 램프를 잃었다. **enum 주석이 등록 지점을 "3지점"이라 못박아 둔 것이 화근이었다** — 이 Update 분기가 목록에 없는 4번째 지점이라 내가 그대로 빠뜨렸다. 판정을 `IsCombatBgm`으로 모으고 주석을 4지점으로 고쳤으며, `BossBgmRegistrationTests`에 검사를 붙여 **누락을 주입해 검출력을 실측**했다(BossLedger·BossFinal 제거 시 정확히 그 둘을 지목). **Explore가 clean 판정한 것 2건 — 내가 위임 때 의심으로 지목했던 것들이다**: ①보스전 복귀 경로는 승/패/도주/fallback이 전부 `BattleEnded`로 수렴해 `RestoreExploreBGM`에 도달한다(주석의 주장이 실제로 성립). ②서브에리어는 BGM을 건드리지 않고 ambient만 바꾸며 `subAreaSticky`가 위치 판정을 스킵해 `RegionChanged`가 울리지 않으므로 `lastExploreBgm`이 리전 기준으로 정확히 유지된다. **P2 보고**: `SetBGMVolume`/`SetMasterVolume`/`SetSFXVolume`의 유일한 호출부 `SettingsPanel`이 어느 씬에도 없고 부트스트랩도 생성하지 않아 **인게임에서 볼륨을 바꿀 수단이 없다**(`bgmVolume`은 0.6f 하드코딩 + 저장 없음). 오늘 diff 밖의 선재 문제이고 UI 신설이 필요해 자동 처리 범위 밖으로 뒀다 + +- [x] PlayerInsectCollection 재감사 (P1:1, **P2:2 처리**, 2026-08-08) — 세이브의 심장부라 되돌리기가 가장 어려운 영역이었다. **P1: `GetAllOwned()`가 호출마다 List를 새로 만드는데 캐시 없이 매 OnGUI 패스에서 부르는 호출부가 2곳 남아 있었다**(`DexScreenUI` 보유 탭, `BattleTeamUI` 피커). 나머지 5곳은 전부 `cachedOwned + InsectUpdated invalidate`로 캐시하고 있었고, 같은 파일의 `OwnsShiny`는 **이미 같은 함정을 캐시로 고쳐 놓은 상태**였다 — 두 곳만 남은 것이다. 공유 캐시로 바꾸지 않고 **재사용 버퍼 `OwnedView`(IReadOnlyList)를 따로 뒀다**: 캐시로 만들면 무효화 지점을 하나만 빠뜨려도 stale 목록이 뜨는데 그건 "안 보이는 곤충"이라 할당보다 훨씬 나쁜 결함이고, 기존 호출부 다섯은 반환 리스트를 **보관**하므로(`cachedOwned = GetAllOwned()`) 공유 버퍼를 주면 그쪽이 깨진다. `GetAllOwned`는 그대로 두고 매 프레임 경로 2곳만 옮겼다. **P2-1 처리 — 문서가 보증한다고 적은 동작이 실제로는 죽어 있었다.** `save-system.md`는 "로드 루프의 `EnsureHp`가 -1이면 실제 MaxHp로 채워 구세이브 곤충이 0 HP 기절로 뜨는 것을 방지한다"고 못박았는데, **부트에서 한 번도 실행되지 않는다** — Bootstrap이 `AddComponent`로 만들어 `Awake`→`LoadAndIndex`가 `AutoWire`보다 먼저 돌고, 그 시점엔 `database`가 null이라 `GetInsectData`가 전 개체에 null을 준다("insect null이면 다음 로드에 미룸" 분기로 전부 스킵되고 그 "다음 로드"도 순서가 같아 영영 오지 않는다). **지금 증상이 없는 이유까지 확인했다**: `GetEffectiveHp`가 음수를 풀피로 보고 `IsFainted`가 `==0`이라 -1이 기절로 읽히지 않으며 `HospitalUI`도 자체 `currentHp < 0` 가드를 갖는다. 즉 P2가 맞다 — 다만 가드 없는 소비자가 하나만 생겨도 그대로 터지고, 고치는 비용이 `AutoWire`의 기존 루프에 세 줄이라 처리했다(`EnsureLevelSkills`가 같은 이유로 이미 거기서 재실행되고 있었다 — `EnsureHp`만 빠졌던 것). **P2-2 처리 — 주석의 전제가 낡았다.** `ReloadFromDisk`가 아무 이벤트도 쏘지 않으면서 "컬렉션 UI는 매 프레임 읽어 자동 반영하므로 발화 불필요"라고 적어 뒀는데, 지금은 그 UI 넷이 전부 목록을 캐시하고 `InsectUpdated`로만 무효화한다. 클라우드 복원은 `saveData`/`lookup`을 통째로 새 객체로 바꾸므로 알리지 않으면 열려 있던 UI가 **고아 `PlayerInsectData`**를 들고 남고, 거기서 레벨업·치료를 하면 **재화만 빠지고 변경은 사라진다**. `InsectUpdated?.Invoke(null)` 한 줄 + 주석 갱신(구독 4곳이 전부 인자를 무시해 null 안전). 잠복인 이유도 확인했다 — 복원 트리거가 로그인 한정이고 그 시점엔 해당 UI가 닫혀 있다. **결함 아님으로 확인**: 센티넬 로드 경로는 `Awake`/`ReloadFromDisk` 둘 다 `LoadAndIndex`를 공유해 우회가 없고 `EnsureSize`는 중복 GUID 재발급 **뒤**에 호출돼 결정적이다(FNV-1a라 실행마다 안 바뀐다) / `instanceId`는 로드 시 중복 검출→재발급+저장 / `SetAfterBattle`의 `Clamp(_,0,Max)`가 "0=기절" 계약을 지키고 `HealInsect`는 `currentHp<0`을 max로 승격한 뒤 회복해 -1 오염이 없다 / 상태 변경 public 메서드 전부 `MarkDirty`·`SaveNow` 보유, 외부 직접 변형 2곳은 `ForceSave` 호출. **테스트를 붙이지 않았다** — 이 클래스는 `Awake`가 실제 세이브를 읽고 `needsSave`면 덮어쓰므로, 하네스를 만들면 사용자 세이브를 건드릴 위험이 이득보다 크다 + +- [x] RaidSupportPlanner 재감사 (P1:2, 2026-08-08) — **직전 감사(2026-08-06)가 고친 2건은 재발하지 않았다** (팀 전체 버프 인식은 `BuffRoom`이 `RaidRoundResolver.ApplyTeamBuff`와 부호까지 일치, 보스 연속기절 면역 가드도 살아 있다). **문제는 그 다음이었다 — 이 파일은 순차 턴 커밋에서 한 줄도 안 바뀌었다.** 컨트롤러 391줄·리졸버 11줄이 바뀌는 동안 플래너만 옛 전제로 남았다. **P1-1: 이번 라운드에 이미 명중한 기절을 모른다 — 2026-08-06 결함이 새 통로로 재발했다.** 기절은 팀 전체에서 **한 번만** 판정되는데(`roundStunLanded` bool 하나를 `FinishTeamPhase`가 소비한다), 플래너는 `bossStunImmune`(=지난 라운드 면역)만 받고 이번 라운드 명중은 못 본다. 팀 턴이 순차가 되면서 슬롯 0이 맞히면 슬롯 1~4의 기절은 전부 확정 무의미인데, Guard 스탠스에서 기절 가중치가 1.4로 최고라 그대로 골라진다 — "전원 자동"이면 라운드당 최대 4턴이 날아간다. 호출부 한 줄(`|| roundStunLanded`)로 막았다(플래너 시그니처 무변경). **P1-2: 전체공격이 꺼지면서 방어 버프가 5명 중 4명에게 영구히 죽었다.** `RaidBossUsesAreaAttack = false`(같은 커밋에서 꺼짐)라 `incomingArea`가 상시 false이고, `if (!incomingArea && !targetedAtMe) return 0f;`가 "보스가 지목한 그 한 슬롯"으로 축소됐다 — 나머지 4명은 방어 버프를 **영원히 후보에 못 올렸다**. 레이드 버프는 만료가 없고(2026-08-03 의도된 divergence) 팀 전체에 걸리므로 지목 여부와 무관하게 값이 있는데, 바로 옆 `BuffAttack`은 `×2f`로 다라운드 가치를 매기는 비대칭이었다. 게이트를 없애되 **밸런스 변화를 최소화**했다 — 지목된 경우는 기존 가치 그대로 두고 미지목은 `×0.5`(후보에 오르되 뒤로 밀린다). **테스트가 초록인 채로 통과하던 이유도 고쳤다**: 두 테스트가 `Intent(true, -1)`(AOE)로만 검증해 **프로덕션이 안 타는 분기**를 고정하고 있었다. 둘 다 단일 대상 예고로 바꿨고, 그 과정에서 **내 기대값이 틀린 것도 하나 잡았다** — 단일 대상에서 기절 가치는 전체공격의 `alive`배가 아니라 `0.4`배라 Assault로는 데미지가 이기는 게 정상이라, Guard 스탠스로 조건을 바꿨다. **검출력 실측**: 옛 게이트를 되돌려 주입하니 `GuardStance_ShiftsPickToDefensive`가 정확히 실패했다. **결함 아님 확인**: 순수 함수이고 컨트롤러 상태를 변형하지 않으며, 회복 대상은 `RaidRoundResolver.LowestHpAlly`를 직접 호출해 리졸버와 같은 대상을 본다. `TeamCooldowns[i] = new int[sc]`로 길이 계약이 코드로 보장돼 짧은 배열 허용 분기는 도달 불가 + +- [x] CashShopManager 재감사 (P1:1, P2:2 보고, 2026-08-08) — **돈이 오간 직후 클라우드에 확정하지 않았다.** 실결제는 공급자 검증 → `ApplyVerifiedGemBalance`(서버 권위 잔액 반영)가 유일한 릴리스 경로이고, 그 아래 `GrantGemPackage`는 주석이 못박은 대로 **에디터/개발 빌드 전용**이다. 그런데 `SaveToCloud()`를 부르는 쪽은 에디터 전용인 후자뿐이었다 — 결제 직후 앱을 끄면 120초 자동저장을 못 만나고, 다음 실행의 클라우드 복원이 결제 전 잔액을 덮어쓴다. 서버가 권위 잔액을 들고 있어도 그건 재검증 시점 이야기고 사용자에겐 "결제했는데 보석이 없다"로 보인다. 두 경로가 대칭이 되도록 한 줄 추가했다. **통과 확인(결제 코어는 정상)**: 클라이언트가 `rewardCount`를 직접 더하지 않아 재전달·재시작 중복 지급이 없고(`ApplyVerifiedGemBalance`가 서버 계산 잔액을 SetGems), 프로덕션에서 결제 모듈이 없으면 무료 지급 대신 거부하며(Play 정책), `PurchaseWithGems`가 **사전 검증 → 차감 → 실패 시 환불** 3단계로 원자적이고 환불 기준을 `Gems` 프로퍼티(wallet 단일 소스)로 캡처해 stale 캐시를 피한다. `AddGems`는 wallet이 있으면 wallet으로만 쓰고 캐시는 read-back한다. **P2 보고 1 — 보석 *소비*는 즉시 저장하지 않는다**: 아이템 구매 후 앱을 끄면 보석은 그대로인데 아이템만 사라지거나(또는 반대) 할 수 있다. 다만 충전과 달리 실제 화폐가 오간 게 아니고, 매 소비마다 클라우드를 치면 호출량이 는다 — 즉시 저장을 어디까지 넓힐지는 아키텍처 결정이라 넘긴다. **P2 보고 2**: `PurchaseWithGems`가 같은 `FindFirstObjectByType`를 사전 검증과 지급에서 두 번 한다(구매는 버튼 클릭 경로라 프레임 비용은 아니다). **거짓양성으로 철회 1건**: 가챠 분기가 `OpenBox` 호출 직후 `delivered = true`를 세워 `isOpening` 가드에 걸리면 보석만 나가는 것처럼 보였으나, `OpenBox`는 동기 실행이고 재진입 경로가 없어 현재 도달 불가다 + +- [x] CashShopManager 재감사 (P1:2 처리, **P1:1 보고만**, 2026-08-08) — 재화를 다루는 파일이라 결함이 곧 사용자 손실이다. **P1-1 처리 — 젬 소비가 클라우드에 확정되지 않아 복제가 가능했다.** 서버 IAP 지급은 Firestore의 `gems`를 읽어 `currentGems + rewardCount`로 계산하는데 그 필드는 **클라이언트 PATCH로만** 갱신된다. `PurchaseWithGems`가 wallet 로컬 파일만 쓰고 `SaveToCloud()`를 부르지 않아서, 젬으로 상자를 산 뒤 자동저장(120초) 전에 젬 패키지를 결제하면 서버가 **차감 전 잔액**에 보너스를 더해 확정한다 — 쓴 젬이 돌아오고 아이템은 남는다. 지급 경로 둘(`ApplyVerifiedGemBalance`·`GrantGemPackage`)은 이미 즉시 저장하고 있었고 **소비 경로만 빠져 있던 비대칭**이었다. **P1-2 처리 — 재검증이 로컬 잔액을 뒤집었다.** 서버는 이미 청구된 토큰에 `newlyGranted: false` + **마지막 PATCH 시점 잔액**을 돌려준다(재시작 시 미확정 주문을 다시 검증하는 정상 경로다). `ApplyVerifiedGemBalance`가 그 값을 무조건 절대 세팅해서, 그 사이 로컬에서 번 젬은 증발하고 쓴 젬은 되살아났다. `newlyGranted`는 **DTO에 이미 있었는데 호출부가 안 넘기고 있었다** — 인자로 전달해 지급이 없었으면 잔액을 건드리지 않게 했다. 같은 메서드의 `Mathf.Max(0, verifiedBalance)`도 함께 고쳤다: Firestore에 `gems` 필드가 없는 신규 문서면 0으로 클램프돼 **결제 직후 잔액이 통째로 0이 됐다** — `CloudSaveManager`가 같은 필드에 `-1` 센티넬 + `>= 0` 가드를 두는 것과 정반대였다. 이제 음수는 "미상"으로 보고 스킵한다. **P1-3은 보고만 했다 — 고치려면 구조를 바꿔야 하고 잘못 고치면 더 나빠진다.** `AutoWire`의 레거시 젬 마이그레이션이 부트에서 도는데 그 시점 `AuthManager.UserId`가 null이라 wallet이 **전역** 파일을 가리키고, 병합 직후 PlayerPrefs 키를 지워 원본이 사라진다. 로그인 후 계정별 파일을 재로드하면 그 병합분이 버려질 수 있다(레거시 유저 한정). 그런데 단순히 키 삭제만 막으면 **다음 AutoWire에서 또 병합돼 젬 복제**가 되고, 마이그레이션을 로그인 시점으로 옮기려면 `AutoWire`가 부트에 한 번만 불리는 구조 자체를 바꿔야 한다. 재화가 걸린 자리라 자동 처리하지 않았다. **결함 아님 확인**: 클라우드 5지점 전부 배선(수집/적용/직렬화/파싱/DTO) / 구매 원자성은 사전 deliverable 검증 + try-catch + `gemsBefore - Gems` 차분 환불로 정확하고 `AddGems` 실패 시 refund=0이라 이중 환불이 없다 / 중복 구매는 `GachaBoxManager.isOpening` 재진입 가드와 IAP `verifyingOrders` orderKey 중복 차단이 막는다 + +- [x] RaidRoundResolver 재감사 (**P1:1**, P2:1 처리, 2026-08-08) — **리졸버 자신은 순차 턴 개편과 정합했고, 결함은 그것을 부르는 컨트롤러의 합체공격 경로에 있었다.** 직전 `RaidSupportPlanner` 라운드가 "주변이 바뀌었는데 이 파일만 안 바뀜"이었다면 이번은 그 반대다. **P1: 합체공격을 라운드 중간에 쓰면 이미 행동한 슬롯이 한 라운드에 두 번 때린다.** 개편 전엔 팀 커맨드가 라운드당 1회라 합체가 팀 턴을 **대체**했는데, 지금은 게이지가 슬롯마다 차올라(`CommitMemberAction`이 `+2.5 + Damage×0.15`) 팀 턴 **도중에** 100을 넘고 `CanUniteAttack`은 `ActiveSlot`을 보지 않는다. 슬롯 0~3으로 스킬을 다 쓴 뒤 슬롯 4 차례에 [F]를 누르면 0~3이 2회 공격한다. **컨트롤러 주석이 반대쪽 절반만 막고 있었다** — "아직 차례가 오지 않았던 팀원도 참여했다"고 적고 전원을 acted로 찍는데, 이미 행동한 쪽은 기여 루프에서 거르지 않았다. `HasActedThisRound(i)` 한 줄로 막았다. **같은 자리의 두 번째 증상도 함께 고쳤다**: `roundInProgress`를 새 결과 객체로 갈아끼우면서 합체 이전에 커밋된 피해가 통째로 버려져 `LastDamageToBoss`와 UI 기여도가 과소 집계됐다 — `TotalDamageToBoss`를 이월한다. **P2 처리: `CreateBossIntent`의 `allowAreaAttack` 기본값이 `true`였다.** 프로덕션은 상수를 넘기지만 기본값이 살아 있으면 **인자를 빠뜨린 새 호출부가 조용히 AOE를 부활**시키고, 그 경로는 `rageMultiplier`를 무시하고 전원을 깎아 곧 전멸이다. 기본값을 없애 필수 인자로 만들었다(컴파일 오류 0 — 모든 호출부가 이미 넘기고 있었다). **검출력 실측**: 두 수정을 모두 되돌리니 신규 테스트 2건이 각자의 결함을 정확히 잡았다. **정합 확인(재점검 불필요)**: 리졸버는 라운드 상태를 전혀 들지 않고 `AttackBonus`·`CurrentHp`를 전부 **호출 시점 라이브 읽기**로 가져와 앞 슬롯의 디버프·피해가 뒤 슬롯에 즉시 반영된다 / `IsArea` 분기는 도달 불가일 뿐 잘못된 값을 내지 않는다 / 기절은 리졸버가 명중만 판정하고 컨트롤러가 `roundStunLanded`로 라운드당 1회 소비해 중복 적용이 없다 / `LowestHpAlly`가 죽은 슬롯을 거르고 HP를 라이브로 읽어 앞 슬롯의 회복이 반영되며 플래너가 같은 함수를 써 대상이 어긋나지 않는다 / `ApplyTeamBuff`가 대상별 `TryStack*`을 타 5명이 연속 시전해도 상한에서 정확히 멈춘다 + +- [x] RegionManager 재감사 (P1:1, 2026-08-08) — **진행 사슬이 세 곳에 적혀 있는데 둘만 대조하고 있었다.** ①`RegionDefinitions`(데이터) ②`RegionProgressionTests.MainChain`(테스트 사본) ③`RegionManager`의 `GetNextRegionId`/`GetPreviousRegionId` switch(런타임 진행). 기존 12검사는 ①↔②만 봤고, ③은 그 배열 주석이 "switch를 고치면 여기도 고칠 것"이라고 **사람에게 부탁**만 하고 있었다. ③이 빠지면 리전을 추가해도 전부 통과하는데, **Next 누락은 수문장을 잡아도 다음 리전이 안 열려 진행이 영구 차단**되고(해금 경로가 `DefeatGuardian` 하나뿐이다), **Prev 누락은 `GetGuardianPosition`의 fromCenter가 원점이 되어 수문장이 맵 한복판에 선다**(그 자리 주석이 add-region 시나리오 E로 적어 뒀다). 둘 다 런타임엔 에러 없이 조용히 막힌다. 소스에서 메서드 본문을 잘라 사슬을 양방향으로 대조하는 테스트 3건을 추가했다(정방향·역방향·종착지에 전방 간선 없음). **실측: 현재 13리전 사슬이 완전 정합**(Next 11 / Prev 12 + garden 분기, 불일치 0). **자체 발견·즉시 수정 1건**: 처음엔 파일 전체에서 문자열을 찾게 썼는데 두 switch가 서로의 역방향이라 `case "nameless": return "canopy";`(Prev)가 종착지 검사에 걸려 **거짓 실패**했다 — 중괄호 균형으로 메서드 본문만 잘라 쓰도록 고쳤고, Prev 항목이 Next 본문에 없음을 실측으로 확인했다. **검출력 실측**: Next에서 `canopy→nameless`를 지우면 그 간선을, Prev에서 `dunes→hollow`를 지우면 그 간선을, 종착지에 전방 간선을 주입하면 그것을 각각 잡았다. **거짓양성으로 철회 1건**: `UnlockKey`/`GuardianKey`가 전역 PlayerPrefs라 계정 간 해금이 공유될 줄 알았으나 둘 다 `SaveScope.PrefsKey(...)`를 거친다. **통과 확인**: `ICloudReloadable.ReloadFromDisk`가 클라우드 로드 후 인메모리를 갱신하고, `LoadUnlockState`가 `RemoveEmptyEntries`로 ",,meadow,," 잔존을 막으며, 이벤트 4종은 발행 전용이라 구독 해제 짝이 필요 없다 + +- [x] 컷신 3파일 — CutsceneData / CutsceneLibrary / CutsceneDirector (clean P0/P1, 2026-08-08) — **파일 수정 0건.** 큐 1·2순위를 함께 돌리고 인접한 `CutsceneDirector`(250줄, 큐에 없음)까지 봤다. 이 세션이 다른 영역에서 반복해 지적한 "짝 규칙을 아무도 검사하지 않는다"가 **여기엔 이미 적용돼 있다**. **①타임라인**(`CutsceneData`, 순수 static): 누적 합으로 컷을 찾아 경계에서 건너뛰지 않고(컷별 시작 시각을 따로 들면 duration 수정 시 조용히 어긋난다는 주석이 근거를 적어 뒀다), `TryGetShot`이 음수 elapsed를 0으로 clamp하며, 자막 페이드가 짧은 컷에서 깜빡이지 않게 `duration <= fade*2`면 불투명 고정이다. 테스트 15건이 `EveryInstantIsCoveredExactlyOnce`까지 덮는다. **②라이브러리**: cutsceneId를 `public const`로 두어 리터럴 중복이 없고, `story_lint.py` 검사 9번이 **JSON↔Library 실재성 + switch 미배선**을 양쪽으로 본다(내가 다른 라운드에서 신설한 것과 같은 형태가 이미 있었다). **③디렉터**: `Play`에 **재진입 가드**가 있고(없으면 복원값이 컷신 자신이 만든 상태를 읽어 조작이 영구히 안 돌아온다 — 주석이 그 시나리오를 적어 뒀다), 모든 종료 경로가 `Stop` 하나로 모이며 `OnDisable`도 그걸 부른다. 배틀 카메라를 **구도까지** 기억해 복원하고(안 하면 전투 화면이 컷신 마지막 컷 구도로 굳는다), `Time.unscaledDeltaTime`을 써 전투 슬로모션에 끌려다니지 않으며, `IModalUI`라 ESC로 건너뛸 수 있다. 알 수 없는 cutsceneId는 `LogWarning`을 남긴다("컷신이 안 나오는 건 화면상 티가 안 난다"). **거짓양성으로 철회 2건 — 둘 다 내 가설이었다**: ①`TotalDuration`은 음수를 0으로, `TryGetShot`은 0을 0.0001로 보정해 둘이 다른 총길이를 본다고 봤으나, 실제 duration이 전부 1.4~3.4초 양수이고 종료 판정은 `TryGetShot`만 쓴다(`TotalDuration`의 소비자는 테스트뿐). ②UI 루트 토글(`OpeningReplayCoordinator`)에 `SetFrozen(true)`가 물려 영구 정지할까 봤으나 Bootstrap이 `World/CutsceneDirector`에 붙이므로 그 토글 대상이 아니고, 설령 꺼져도 `OnDisable → Stop`이 복구한다 + +- [x] RegionManager 재감사 · 후속 (**P0:1**, P1:1, 2026-08-08) — **다른 세션이 같은 라운드를 동시에 돌려 `984e3cb`로 먼저 커밋했다. 겹치지 않고 짝을 이룬다** — 그쪽은 진행 사슬의 세 번째 사본(`RegionManager` switch)을 테스트로 묶었고, 이쪽은 **좌표의 네 번째 사본**을 찾았다. **P0: 실물 수문장이 지도 마커와 다른 자리에 서 있었다.** `PlaySceneBootstrap.GetGuardianWorldPosition`이 `RegionManager.GetGuardianPosition`의 낡은 사본이었다 — 같은 공식(`(이전 리전 중심 + 이 리전 중심)/2`)을 쓰면서 이전 리전 switch만 자체 보유해 **ver1 5개 case에서 멈춰 있었다**. 그래서 `ruins`와 2막 6리전은 `prevId`가 null → 원점 기준 → 실물이 `center/2`에 섰다(최대 105m 오차, `dunes` 수문장은 연못 안, `canopy`는 유적 안). 지도 마커(`RegionMapUI`)와 목표 추적기(`StoryObjectiveTracker`)는 `RegionManager` 쪽을 쓰므로 **가리키는 곳에 아무것도 없다** — `ruins` 수문장은 2막의 유일한 문이라 에러 없이 조용히 진행이 막힌다. 사본을 삭제하고 `CreateGuardians`가 `RegionManager`를 직접 물어보게 했다(단일 출처화). **P1: 2막 서브에리어 4종에 조명 프로필이 없었다 — 이건 내가 Phase C에서 만든 누락이다.** `SubAreaWorldBuilder`에 `vault`/`archive`/`kiln`/`ledger` 지오메트리를 넣으면서 `SubAreaEnvironment.GetProfileForType`을 빠뜨려 전부 `default`(야외 주광)로 떨어졌다 — 밀폐 공간을 지어 놓고 바깥 햇빛을 쬐는 꼴인데 지오메트리만 보면 티가 안 난다. **검출력 실측**: 4종을 제거하니 신규 테스트가 정확히 그 넷을 지목했다. 좌표 사본 재발도 소스 검사로 고정했다(`private Vector3 GetGuardianWorldPosition` 부재 + `regionMgr.GetGuardianPosition(r)` 존재). **다른 세션이 확인한 것(재점검 불필요)**: 진행 체인 13리전 전부 배선, `ruins`→`hollow` 해금, `GuardianDefeated` 구독 짝, PlayerPrefs 직렬화, 서브에리어 sticky의 리전 수 비의존, `ReloadFromDisk`가 이벤트를 안 쏘지만 소비자가 라이브 조회라 무해 + +- [x] RaidBattleController 재감사 (P1:2 + 문서결함:1, 2026-08-08) — 순차 행동 전환(590줄 변경) 후속. 이 세션이 반복해 만난 **"짝 규칙을 한쪽 입구만 지킨다"**가 여기서 두 번 나왔다. **①합체 게이트가 참가 규칙과 다른 수를 봤다**: `ResolveUniteCommand`는 이미 행동한 슬롯을 참가에서 빼는데(빼지 않으면 한 라운드 2회 공격 — 주석이 근거를 적어 뒀다) `CanUniteAttack`은 `AliveCount() >= 2`로 판정했다. 게이지는 행동마다 차서 보통 슬롯 3~4 차례에 100에 닿으므로, 마지막 슬롯에서 [F]를 누르면 **참가자 1마리**분만 나오고 게이지 100이 통째로 소각된다 — 같은 게이지를 슬롯 0에서 쓰면 5마리분이라 **가치가 5배** 차이나고, 1마리분은 그 곤충의 평범한 스킬보다도 약했다. `RemainingActors >= 2`로 교체. **②`CanUseSkill`에만 `HasActedThisRound` 검사가 없었다**: `RaidMemberActionResolved` 발화 시점에는 `ActiveSlot`이 아직 **방금 행동한 슬롯**이고 `roundStage`도 `Ready`라 `CanSubmitTeamCommand`가 true다. 그 창에서 커맨드를 넣는 구독자가 생기면 한 곤충이 라운드에 두 번 때린다(현재 구독자는 없어 잠재 결함). 형제 입구인 `ResolveUniteCommand`는 막고 있었다. **③`RaidBossHpMultiplier = 8.5`의 산출 근거가 무효화됐다**: 주석은 "리더 1×1.0 + 서포트 4×0.6 = 3.4유닛" 전제로 뽑은 값이라 명시하는데, 지금은 `ResolveTeamCommand`가 **전 슬롯**을 `ResolveLeaderSkill`(배율 1.0)로 태워 **5.0유닛(+47%)**이고 AOE도 꺼졌다. **값은 바꾸지 않았다** — 사용자가 "레이드가 너무 세다"고 해 AOE를 껐으므로 HP를 올리면 그 요청을 되돌리는 셈이다. 주석에 현재 상태와 그 판단을 적고, 조일 때는 이 숫자가 아니라 서포트/리더 배율 구분부터 재설계하라고 남겼다. **Explore가 정상 확인한 경로(재보고 불필요)**: 팀 페이즈 중엔 아군이 죽지 않아 `BeginRound`가 전멸 상태로 열릴 수 없고, `raidEndedRaised`가 `RaidEnded` 중복을 막으며, 종료 후 `IsActive=false`가 커맨드 전체를 닫는다. 쿨다운은 `CompleteRoundPresentation`에서 **라운드당 1회**만 감소(순차 전환으로 5배 빨라지지 않았다), `AudioManager.Instance`는 전부 null 가드, `PersistTeamHp`는 전 슬롯 순회, `OnEnable`/`OnDisable` 구독 짝 6개 모두 정상 + +- [x] VillageBuilder 재감사 (P1:2, 2026-08-08) — 2026-07-17 이후 479줄 증분(순수 추가). 둘 다 **"주석이 약속한 것을 아무도 검산하지 않는다"** 형태다. **①병원과 집5가 같은 자리에 겹쳐 세워졌다**: 병원 주석은 "빈 각도 315°"라 적어 뒀지만 점유 각도는 25·55·90·145·180·215·270·**320**이었다. 315°/12m와 집5(320°/13m)의 중심 거리는 **1.48m**인데 병원 벽이 6×5, 집 벽이 3.8×3.2 — 집이 병원 안에 박혔다. 둘 다 `keepCollider: true`라 통행까지 막혔고, 같은 각도의 주민 앵커(320°/9.5m "집5 앞")는 병원 정면 벽에 붙어 스폰됐다. 거리만 늘리는 방식(315°/17m)은 4.2m라 여전히 겹친다. **352°**(320°와 25°의 이등분선)로 옮겨 양쪽 여유를 6.96m/7.17m로 맞췄다 — 350°도 겹치지는 않지만 집5 쪽 여유가 0.15m뿐이라 작은 조정에 다시 깨진다. 상호작용 포인트는 `pos`에서 파생되므로 함께 따라간다. **②서릿길 얼음벽이 호가 아니라 방사형 스파이크였다**: 이 파일의 극좌표 배치물은 yaw를 `-(각도 + 90)`으로 접선 정렬한다(울타리·가챠 육각벽 2곳에 명시). `BuildIceShelter`의 얼음 블록 2단(9개)만 **+90이 빠져** 블록의 긴 축(1.1)이 반지름 방향을 향했다 — 바람막이 벽이 통로로 튀어나온 스파이크가 됐고 `keepCollider: true`라 충돌체까지 같이 틀어졌다. **회귀 방지 테스트 신설**: `VillageBuildings_DoNotOverlapEachOther`가 본마을 건물 9개의 실제 벽 크기(외접원 반지름)로 쌍마다 겹침을 검산한다 — 각도 목록을 눈으로 세는 방식이 이미 한 번 틀렸기 때문이다. 도입 즉시 옛 315°를 잡는 것을 확인했다(수식 검산 1.48m < 6.39m). **거짓양성 1건 철회**: `Prim`의 `Destroy(col)`가 EditMode에서 LogError를 쏟아 `OutpostFacilityTests`를 무력화한다는 지적 — 이 저장소의 테스트는 **PlayMode 러너**로 돌고(`rules/testing.md`, EditMode 러너는 0건을 잡는다) 직전 실행에서 해당 픽스처 11건이 모두 통과했다. **정상 확인**: `Build`는 Bootstrap에서 1회만 호출돼 재빌드 누적 없음, `materialCache`가 색상당 1개 공유라 머티리얼 누수 없음, 상호작용 id 9건 전부 유일, `InteractionKind` 4값 ↔ switch 4분기 충족, `BuildOutpost`의 12리전 분기·`OutpostAngles` 12개 모두 등록(default 낙하 없음), `Polar`가 `center.y`를 보존해 RegionTerrainBuilder식 y 이중 합산 없음 + +- [x] BattleArenaController 재감사 (P1:2, 2026-08-08) — 2026-08-06 이후 56줄 증분(아레나 가시성). 증분 자체는 깨끗했고, 결함은 **3D 연출 컴포넌트가 화면 문구까지 직접 그리던 옛 `OnGUI`**에 있었다. **①전투 문구가 `UIScale` 밖 픽셀 좌표로 그려졌다**: `Screen.width/height`와 `fontSize 40`을 그대로 썼는데 나머지 전투 UI는 전부 가상 캔버스(가로 1920×1080 / 세로 1080×1920) 안이다. 스케일은 `min(w/refW, h/refH)`라 **1920×1080에서만 1.0**이고, 1440×3200 폰이나 2560×1440 데스크톱은 1.333이라 "효과가 굉장했다!"만 다른 라벨보다 **25% 작게** 찍히고 `Screen.height*0.35` 위치도 가상 기준보다 아래로 내려갔다 — Game View를 1080p로 보면 안 보이는 종류다. 그리기를 `BattleEffectTextOverlay`(UI 신설)로 옮겨 `UIScale.Begin()` 안에서 그린다. **아레나가 `UIScale`을 직접 부를 수는 없다** — UI가 이미 Battle을 참조해 순환이 된다(`architecture.md`). `GetActiveEffectTexts()`가 처음부터 공개였던 게 그 이음매였고 호출부가 0이었다. 만료 정리는 `OnGUI`에 있었으므로 목록의 주인(아레나) 쪽으로 함께 옮겼다. **②같은 자리 고정 600×50 상자에 `fontSize 40`**: 한글 세로 폭(약 54)을 못 담아 잘렸고, `$"{skill.displayName}!"`는 길이를 데이터가 정하는데 wordWrap이 꺼져 있어 **가운데 정렬로 앞뒤가 같이** 잘렸다. 58×760 + `UIHelper.LabelFit`으로 교체. `text_fit_lint`가 못 잡은 이유는 Rect 인자의 높이가 리터럴이 아니라 지역변수(`height`)여서 파서가 건너뛰기 때문이다. **정상 확인**: `CleanupArena`가 `StopAllCoroutines` 직후 `playingSkill=false`로 되돌려 코루틴이 종료점 전에 죽어도 플래그가 남지 않고 `OnDisable`도 거기에 위임한다. `playingSkill=true` 이후의 `yield break`는 한 곳뿐이며 거기서 false로 복구한다(나머지 조기 반환은 전부 true 이전). 레이드 카메라 z=-9.5, 1v1 카메라(팔로워 계산) x=-3.6·z=1.7로 둘 다 `ArenaWallSpan`(13) 안. `AudioManager.Instance`는 전부 null 가드, `cachedCameraFollower` 지연 캐싱, `rendererCache`가 `GetComponentsInChildren` 반복을 막는다 + +- [x] NpcDuelController 재감사 (P0:1, P1:1, 2026-08-08) — **전멸 패배에서 `DuelEnded`가 영영 발화하지 않았다.** `PlayerFainted`는 **구독자가 있기만 하면** `fainted=true`로 보고 종료를 UI에 넘기는데, 유일한 구독자 `BattleScreenUI`는 벤치가 비면 결과 화면으로 가면서 컨트롤러에 되돌려 알리지 않았다(`EndBattle`도 안 알림). 결과: 재도전 쿨다운 90초·120초가 통째로 우회되고, `activeBossId`가 남아 **다음 아이 대결의 승리가 간부 격파로 오기록**됐다 — PlayerPrefs 영구 저장 + 클라우드 동기라 되돌릴 수 없고, 관장을 싸우지도 않고 격파 처리하며 보상까지 나간다. 도주 경로는 같은 누락을 이미 한 번 고쳤는데 전멸 패배만 `fainted` 가드에 가려 남아 있었다. `InsectBattleController.ConcludeDefeatWithoutSwap()`(멱등) 신설 + `EnterResult`가 호출. `TryStartDuel`의 `activeBossId` 초기화도 대칭으로 추가(방어선 2겹) +- [x] SubAreaWorldBuilder 재감사 (P1:3, 2026-08-08) — **신규 4개 방(장부 창고·빙하 서고·잿불 가마·장부의 방)에 경계벽이 하나도 없었다.** 기존 9개 방은 예외 없이 `CreateBoundaryWalls`를 부르는데 2막 신규분만 빠져, 바닥 Plane 가장자리를 넘으면 그대로 낙하 → Y<-3 안전망이 경고와 함께 강제 퇴장시킨다(25m 자동 이탈은 발동조차 못 한다). 최종장 무대인 장부의 방까지 포함. 방마다 크기를 재서 12~14f로 봉했다. **빙하 서고 얼음기둥 8개가 `i*45°`라 i=6이 정확히 (0,0,-8)** — `FindSafeSpawnPosition`의 1차 입구 좌표다. 반지름 1.25 실콜라이더라 이 방만 입구가 막혀 3m 밀려난 데서 등장했다 → 반칸(22.5°) 위상 이동. **장부 판의 yaw가 `i*24f`로 배치각과 무관**해 방 한가운데서 보이는 게 옆날뿐이었다 → 저장소 접선 관례 `-(각도+90)` +- [x] SocialPvpUI 재감사 (P1:1, 2026-08-08) — `OnDisable`이 `ModalUIRegistry.Unregister`만 하고 `isOpen`을 안 지워 **레지스트리엔 없는데 열린 것으로 아는** 상태가 됐다. 오프닝 다시보기가 UI 루트를 껐다 켜면 도달한다 — ESC가 이 모달을 건너뛰어 안 닫히고, 퀵바의 `IsAnyOpen()` 가드가 무력화돼 **친구코드 입력 중 N/T/G/C가 다른 화면을 토글**한다. `CashShopUI`는 같은 커밋에서 이 처리를 주석까지 달아 받았는데 이 파일만 빠졌다 +- [x] TrainingManager 재감사 (P1:1, 2026-08-08) — 훈련이 스킬을 갈아 끼우고 `ForceSave`까지 하면서 `PlayerInsectCollection.InsectUpdated`를 발화하지 않았다. 자체 `TrainingCompleted`는 **구독자 0건**이라 아무에게도 안 갔고, 그래서 훈련 직후 캐시를 쓰는 화면(레벨업·선택 UI, 상태 HUD)이 옛 스킬셋으로 남았다. 레벨업 경로(`TryLevelUpWithCandy`)는 `MarkDirty` + `InsectUpdated`를 제대로 부르는 대칭 경로다. 컬렉션에 `NotifyInsectChanged` 공개 API 신설 — `GetByInstanceId`가 실참조를 주므로 외부 수정 경로가 더 생길 자리다 +- [x] HospitalUI 재감사 (P1:1, 2026-08-08) — `TryHealAll`만 결제 **전에** `collection == null`을 가드하고 (주석: "재화만 빠지고 아무도 낫지 않는다") `TryHeal`/`TryGemHeal`/`ApplyTreatmentItem`은 결제·아이템 소비 **후에** 무가드 역참조했다 — 이 세션이 반복해 만난 "한쪽 입구만 지킨다" 형태. 셋 다 대칭으로 맞췄다 +- [x] BattleTeamUI 재감사 (P1:1, P2:2, 2026-08-08) — 신설한 "회복하러 가기" 버튼 라벨이 잘렸다(31px 글자 6자에 필요 폭 ~190인데 버튼이 172, wordWrap이 없어 가로로 잘린다) → 224로 확장. `hospitalUi.Toggle()`이 말 그대로 토글이라 병원이 이미 열려 있으면 두 창이 다 닫히는 자리를 `IsOpen` 검사로 못박음. 정렬 칩 `Rect[4]`는 대입만 하고 아무도 읽지 않는 죽은 필드 + 길이가 `InsectBrowseSort.Order`와 안 묶여 모드를 늘리면 IndexOutOfRange가 날 자리라 제거(`CollectionUI`의 동일 사본도 함께) +- [x] RaidBattleUI 재감사 (P1:2, 2026-08-08) — 순차 팀 턴 전환의 잔재 2건. **①버프·회복 오라가 엉뚱한 곤충 위에 떴다**: 컨트롤러가 `RaidMemberActionResolved` 직후 `ActiveSlot`을 **다음** 슬롯으로 옮기고 `RaidUpdated`를 쏘므로, `OnRaidUpdated`의 `selectedSlot = ActiveSlot`이 방금 세운 행동 슬롯을 같은 프레임에 덮었다(라운드 마지막이면 -1 → 0번 곤충). 연출은 `lastMemberAction.SourceSlot`을 `RaidSlotLayout.AnchorX`에 넣도록 교체 — 손으로 복사한 앵커식의 1인 팀 중앙정렬 누락도 함께 해소. **②단일 공격에 라운드 누적분이 나갔다**: 투사체 루프가 `activeRound.TeamActions` 전체를 돌아 3번째 곤충 차례에 1·2번 곤충 투사체가 다시 날아가고, 피해 숫자는 한 마리분인데 "TEAM RUSH ×3"이 붙었다 +- [x] InsectBrowseSort (P1:1, 2026-08-08) — 신규 파일 표면 점검. `Label()`의 `default`가 "등급"을 돌려줘 모드를 하나 더 늘리면 칩 두 개가 나란히 "등급"으로 뜨고 정렬도 조용히 Rarity로 동작했다 — `Order_CoversEveryMode`는 enum↔Order만 보고 `Label_IsNeverEmpty`는 공허하게 통과한다. 전 case 명시 + `default`는 enum 이름 노출 + **라벨 중복 금지 테스트** 신설. **정상 확인**: 비교자는 반대칭·추이적이라 `List.Sort`의 `InvalidOperationException` 경로 없음, null 3중 방어, 두 호출부 모두 `OwnedView` 버퍼를 즉시 복사해 보관 계약 준수 +- [x] CaptureMinigameController 재감사 (P0:0, P1:0, 2026-08-08) — 증분(9줄)은 깨끗했다. 포획률 이중 구현은 이미 `CaptureMinigameProbability` 위임으로 해소됐고, 레벨 차 ±5 clamp도 미니게임·전투 두 경로가 같은 상수를 쓰며 테스트가 그 일치를 단언한다. **거짓양성 1건 철회**: "`panelRoot`에 `SetActive(true)`가 없어 첫 포획 뒤 패널이 영구히 사라진다" → 부트스트랩이 `capturePanel.SetActive(false)`로 시작하고 아무도 켜지 않으므로 이 UGUI 패널은 **처음부터 한 번도 뜨지 않는 죽은 패널**이다(미니게임은 전부 IMGUI로 그린다). 따라서 그 위에 얹힌 "UGUI 버튼 이중 확정" 지적도 도달 불가. 켜는 것이 수정이 아니라 **제거가 정리**이므로 P2로 위임 +- [x] TutorialQuestUI 재감사 (P1:0, 2026-08-08) — P1 3건을 받았으나 **자동 처리하지 않고 P2로 위임**한다. 지목된 `DrawActiveQuestDetail`의 고정 높이(34/40)와 `detailHeaderStyleCache` 복원값(37→31) 어긋남은 실재하지만, 이 파일은 실제 한글과 리터럴 `\uXXXX` 이스케이프가 섞여 있어 문자열 매칭 편집이 위험하고 (같은 세션에서 이미 Python 줄범위 치환으로 우회한 이력), 폰트 파생 리팩터는 1229줄 화면의 16곳을 동시에 건드린다 — `audit-flow.md`가 자동 처리 범위를 "표면 회귀만"으로 제한한 자리다 +- [x] PlaySceneBootstrap 재감사 (P1:0, 2026-08-08) — 증분(20줄)은 정상. 지적 2건은 **설계 판단이라 위임**: ①마을 try 블록이 커지면서 `VillageBuilder.Build` 실패 시 HospitalUI·NpcDialogueUI·StoryObjectiveTracker·CutsceneDirector가 함께 사라지는 단일 실패점이 됐다(회복 소프트락 + 메인퀘스트 무증상 정지) — 단계 분리는 5005줄 모놀리스의 초기화 순서를 건드리는 일이라 architect 상담 대상. ②배틀팀의 회복 버튼이 위치 게이트 없이 병원 전체를 연다 — 이건 사용자가 요청한 기능 그대로이므로 결함이 아니라 **경제 설계 결정**이다. **정상 확인**: 수문장 좌표 이전은 리전 안이라 이동 차단에 안 걸리고 테스트가 고정 중, 구독 1건 중복 없음, 싱글턴 전부 null 가드, 클라우드 세이브 배선 누락 없음 +- [x] NpcDialogueUI 재감사 (P1:0, 2026-08-08) — P1 3건을 받았으나 **전부 데이터·연출 설계라 위임**한다. ①`GetStoryPortrait`에 2막 간부 4인(`ledger_grip/scale/ink/chief`) case가 없어 Story.json 11비트가 초상 없이 뜬다 — 얼굴 디자인은 visual-dev 영역이고 story_lint 검사 추가가 함께 필요하다. ②이름은 줄마다 화자가 바뀌는데 초상은 비트 고정이라 2인 대화에서 얼굴이 안 바뀐다 — `StoryLine`에 `speakerNpcId`를 더하는 데이터 모델 변경이다. ③진행 표시 상자 30f는 경계값(추정). **정상 확인**: 본문은 `LabelFit` 경유, 패널은 전부 `UISafeLayout`, 구독 짝·인덱스 범위·모달 전환 정상 + +- [x] CashShopUI 재감사 · 2회차 (P1:2, 2026-08-17) — 가챠 결과 초상이 파사드(`InsectVisual.Draw`)가 아니라 **그 아래 2D 폴백**(`CapturePopupUI.DrawTypedInsectPortrait`)을 직접 불러, 다른 9개 화면이 전부 3D 썸네일을 보여주는 동안 **가챠 결과만 홀로 2D**였다 → DB 조회 캐시 + 파사드 경유로 교체(조회 실패 시에만 2D 폴백 유지 — 파사드는 data가 null이면 아무것도 안 그려 빈 사각형 회귀가 난다). Phase 2 플래시가 `850x620` 하드코딩이라 1200x820(모바일 1200x1560) Area의 좌상단만 덮는 **잘린 흰 판**으로 보였다 → 감싸는 Area 크기를 인자로 받게 변경. 부수: 3D 썸네일은 첫 요청이 null이라 결과가 열리는 프레임에 2D→3D로 튀고 그 프레임에 `InsectEntity` 생성/파괴 비용까지 겹쳐, 상자 흔들림 1.5초 구간으로 예열을 옮겼다. **Explore 부분 오류 1건 정정**: "shiny 플래그가 빠진다" → `GachaResult`에 shiny 필드 자체가 없다(지급 시점 `AddCapturedInsect`가 굴린다). **정상 확인**: `DrawChest` 계수는 전부 양수 배수 + 진입 가드, `BeginArea`는 여전히 OnGUI 1회, 구독 짝 대칭, 신규 코드에 프레임 할당 없음 + +- [x] PlayerItemInventory 재감사 (clean, 2026-08-17) — 증분은 3가지뿐(`ICloudReloadable` 구현 / `ReloadFromDisk` 신설 / 경로를 `SaveScope.FilePath`로 이관)이고 전부 정상. 이 라운드의 급소는 **`RegisterReloadable` 등록 누락**이었다 — 빠지면 인터페이스가 죽은 코드가 되고 계정 전환 시 아이템이 영구 stale이 된다. `PlaySceneBootstrap:225`에 실재하고 순회 지점 2곳(`ApplySaveData` 직후 / `ReloadAllLocalFromDisk`)도 살아 있으며 파일 쓰기→리로드 순서도 맞다. 직렬화 필드 추가가 0건이라 마이그레이션 논점 자체가 성립하지 않고, 소비처 4곳 모두 차감 **앞**에 실패 가드가 있어 아이템 증발/이중 지급 경로가 없다. `Update()` 자체가 없다 +- [x] CaptureController 재감사 (P1:2 처리, P1:1 **현행 유지 결정**, 2026-08-17) — ①`CaptureResolved` 이벤트가 `AddCapturedInsect`보다 **먼저** 발화해, 팝업의 `GetLatestOwnedBySpecies`가 방금 잡은 개체 대신 **같은 종의 직전 개체**를 읽었다(그 종의 첫 포획이면 조회가 비어 이전 팝업의 등급·IV가 그대로 남았다 — 초기화 코드가 없었다) → 지급 뒤로 이동 + `CapturePopupUI`에 방어적 초기화. ②`NotifyCapture`가 연출 컴포넌트(`CaptureFeedbackController`)에만 있었다. 멀티캐스트는 앞선 구독자가 던지면 뒤를 부르지 않으므로 `CapturePopupUI` 예외 하나로 포획 퀘스트가 영구 유실될 수 있었다 → 컨트롤러 성공 분기로 이동(전투 경로가 이미 같은 이유로 직접 부른다). ③**밸런스 되돌림**: 07-17에 `CaptureController`+`GameplayTuningProfile` 두 파일을 같은 값으로 내린 너프(base .6→.35 / 난이도 .4→.45 / 퍼펙트 .15→.1)를 08-03 리팩터가 통째로 원복했고, 같은 커밋이 그 잘못된 쌍을 고정하는 테스트까지 심었다 — 사용자 판단으로 **현행(.60/.40/.15) 유지**. 되돌리면 floor가 전 등급을 지배해 더 나빠지기 때문이다. 경위를 `CaptureChanceTuning` 상단 주석 · `GetRarityFloor` 요약 · `rules/balance.md` 신설 절 · 해당 테스트 주석 4곳에 남겼다 +- [x] AccountSettingsUI 재감사 (clean, 2026-08-17) — 증분은 오프닝 다시보기 버튼 추가 + 좌표계 하네스 이관 둘. 다시보기가 `playUiRoot`를 통째로 껐다 켜는 그 기능이라 **구독 소실 P0 계열의 정면**인데, `AccountDeleted`는 `OnEnable`에서 복구되고 `openingReplayService`는 이벤트가 아닌 필드라 무관하다. `ReplayOpening`이 `TryReplay` **전에** `SetOpen(false)`를 불러 `OnDisable` 시점엔 이미 모달 해제 상태(실패 시 복원). 버튼 4개 누적 y가 448 박스 안이고 `UIScale.Begin/End`가 `finally` 짝이라 조기 return 3곳에서도 matrix가 새지 않는다 +- [x] DexController 재감사 (**P0:1**, P1:1 처리 + 검사기 1개 신설, 2026-08-17) — 본문은 clean인데 마지막 증분이 `CapturedSpeciesCount`를 **스토리 진행 판정값으로 승격**시키면서 기존 도감 등록 누락이 표시 결함에서 진행 결함으로 격상됐다. **P0**: 튜토리얼 보상 곤충(첫 파트너)이 소유·출전까지 하면서 도감엔 영원히 미발견 — 100% 완주 불가 + 전 플레이어의 DexProgress 비트가 한 종 늦게 열림. `dexController`가 선언·대입만 되고 한 번도 읽히지 않는 죽은 필드였다. **P1**: `StoryDirector` 보상도 같은 형태(현재는 전 비트 `rewardInsectId:""`라 휴면). 둘 다 수정 후 `dex_grant_lint.py` 신설 +- [x] DexBrowseLayout 재감사 (clean, 2026-08-17) — 증분은 `GetVisibleRowRange`/`GetVisibleItemRange`/`NumberLabel` 신설이고 기존 함수는 한 줄도 안 바뀌었다. 컬링이 **덜 돌려주지 않음**을 양쪽 경계에서 확인: 상단은 잘리는 행이 전부 화면 위, 하단은 `floor(x) ≥ ceil(x)-1`이라 항상 그 이상 + overscan 1행. 호출부 7곳 전부 stride가 실제 그리기와 일치하고 전부 실제로 소비하며, `scroll.y`를 `BeginScrollView`가 클램프한 **반환값**에서 읽어 오버스크롤에도 안 어긋난다. 같은 커밋에 테스트 119줄이 동반 신설됐다 + +- [x] PlayerHintOverlay (첫 감사, P1:2, 2026-08-17) — 차단 문구가 **모달 위에 영구 고착**했다: `PlayerMovement`의 frozen 분기가 `return`해 `blockedMsgTimer`가 안 줄고(바로 옆 `catchSwingTimer`는 이미 같은 이유로 줄이고 있었다) 오버레이엔 모달 가드도 없었다. 리전에서 튕긴 직후 2초 안에 대화가 열리면 붉은 굵은 글씨가 모달 닫을 때까지 박힌다 — 스토리 NPC가 스스로 걸어와 말을 거는 지금 구조가 그 창을 자주 연다. 둘 다 수정. 하단 잠금 안내는 퀵액세스 바 **안쪽에 통째로** 들어가 있었다(겹침 100%) → `QuickAccessBarUI.BarReservedHeight` 신설 후 그만큼 올림 + x 기준을 `ContentLeft`로 통일(폭만 마진 제외분이라 24px 쏠려 있었다) +- [x] StoryStageDirector (첫 감사, P1:2, 2026-08-17) — **콜백 계약은 clean**: 종료 11경로(NPC 부재·길 막힘·이동 타임아웃·시퀀스 하드 타임아웃·ESC·OnDisable·새 비트·BeginTalk 절단·파괴 등)에서 `onPreludeDone`이 정확히 1회. invoke 전 null 대입 + `mode==Idle` 조기 반환이 2회를 막는다. **P1 2건**: ①복귀 래치가 앵커 도착에서만 풀리는데 복귀 이동은 8초(=25.6m) 타임아웃으로 중도 종료된다 → 스토리 NPC가 들판에 영구 유기(`wanderRadius=0`이라 배회 복귀도 없다). 진입 데드존을 `engaged`로 고쳤던 것의 거울상 → `!approachReturning || !approachNpc.IsScripted`. ②`SnapToFinalPose`가 `stepIndex`부터만 훑어 **정상 완주 시 한 번도 안 돌았다** — 걷다 만 `ReturnToAnchor`가 보정되지 않아 라온이 들판에 정착하고 그 개체가 목표 후보로 남아 HUD 쐐기·자동 주행을 엉뚱한 곳으로 보냈다(역설적으로 ESC로 건너뛰면 결과가 더 나았다) → 지나간 스텝 중 `ReturnToAnchor`만 소급 보정(앵커는 절대 좌표라 낡지 않는다). **P2 위임**: 마지막 `ReturnToAnchor`가 최대 9초 조작을 잠근다 — 기다리지 않게 바꾸면 `SnapToFinalPose`가 즉시 워프시켜 걷는 연출 자체가 사라져 더 나쁘다. 시퀀스 진행↔조작 잠금 분리나 건너뛰기 버튼이 필요한 연출 설계 판단 +- [x] 스토리 하네스 순수부 4종 (첫 감사, clean, 2026-08-17) — NpcGesture / StoryNpcApproach / StoryStageDirection / StoryStageLibrary. NaN 위험 전수 확인: 분수 지수 `Pow`는 저장소 통틀어 Recoil 한 곳이고 `Max(0,·)` 가드가 있으며, 나머지 곡선은 sin의 곱뿐이라 sin(π)의 -8.7e-8이 그대로 흘러도 무해. `Trapezoid`는 ramp를 `[0.01,0.5]`로 clamp해 0 나누기가 없고, `gestureTimer/gestureDuration`은 `PlayGesture`가 `duration>0`일 때만 타이머를 켜 0/0이 불가능. `Time`/`Screen`/`Random` 유입 0건. `StoryStageLibrary` 상수 3 ↔ case 3 ↔ Story.json 참조 3 일치, `story_lint`가 실제로 잡는 것도 확인 +- [x] VillagerNpc 재감사 (**P0:1**, 2026-08-17) — `WarpTo`가 목적지 +5m 레이의 **첫 히트를 무조건 groundY로 채택**했다. 자기 주석이 "`SampleGround`는 지붕 오인을 막으려 ±0.75m를 거부한다"고 적어 놓고 정작 그 가드를 대체 없이 버린 것이다. 집·상점·병원·회관 몸통은 콜라이더를 남기므로(지붕만 제거) 목적지가 건물 발자국 안이면 상판 y=2.8~3.8m가 잡히고, 이후 `SampleGround` 클램프가 **지상 복귀를 영구 거부**해 자가 회복이 불가능하다. `StoryStageLibrary`의 라온 등장 워프 좌표가 실제로 그 입력을 만든다 → `hit.point.y <= worldPosition.y + MaxGroundStep` 추가. `CompleteScripted` 6경로 정확히 1회는 clean +- [x] NpcWalkAnimator 재감사 (clean, 2026-08-17) — base 회전 드리프트가 구조적으로 불가능함을 확인: `netHandleBaseRot`/`netRingBaseRot`가 `readonly`(대입은 생성자 1곳)이고, 생성자가 `NpcVisualBuilder.Build` 바로 다음 줄에 돌아 애니메이션 전 pristine 값을 읽으며, 같은 오브젝트에 `Initialize`가 두 번 불리는 경로가 없다. 관절 4종은 절대 `Quaternion.Euler` 대입이라 누적 자체가 표현 불가 — `PlayerMovement`에서 났던 툴 base 재캐싱 결함과 형태가 다르다. `PlaySwing` 하위 호환도 상수 일치 확인 +- [x] NpcManager 재감사 (P1:0, 2026-08-17) — 3분기 라운드로빈이 전원을 덮는 것을 수식으로 확인(증분 1, gcd(1,N)=1이라 커서가 ℤ/Nℤ의 생성원 → N스텝 안에 전수 1회 방문). 분기 사상이 [0,N) 분할이라 누락·중복 0, `total==0`은 조기 반환이라 mod 0 도달 불가. 리스트에 `Remove`가 전 코드베이스에 없어 순회 중 변경도 불가. **P1 후보 1건은 위임**: `AiTicksPerFrame=3` 고정인데 N이 30→49로 늘어 한 바퀴 0.272s가 `AiInterval` 0.3s와 앨리어싱해 주민 AI 유효 주기가 0.545s로 배증한다(60fps에서만). 프레임 예산 튜닝이라 game-designer 판단 +- [x] BattleArenaController 재감사 (clean, 2026-08-17) — 증분은 `OnGUI` 전체 삭제 + 그리기를 `BattleEffectTextOverlay`(UI 레이어)로 이관한 것. 호출부 2곳 모두 `UIScale.Begin/End` 사이 마지막 순서이고, `EndRaid`가 `CleanupArena` → `phase=None` 순이라 연출 손실 창이 없다. Bootstrap이 4개 소비자에 **같은 인스턴스**를 주입해 "아레나는 활성인데 UI 참조만 null"인 경로가 없다. 오히려 인스턴스 `GUIStyle` 2개가 사라져 보유가 줄었다 +- [x] GameConstants 재감사 (P1:2, 2026-08-17) — ①`WeeklyContestClaimed`가 클라우드에 안 올라갔다. 대결 **기록**은 곤충 블롭에서 파생돼 따라오는데 수령 상태만 안 따라와서, 기기를 바꾸면 큰 개체는 돌아오고 `ClaimedTier`는 None → 그 주에 대상 종을 한 번 더 잡으면 **보상 재수령**. 바로 위 `defeatedBosses`가 같은 형태를 고친 선례다 → DTO·수집·직렬화·파싱·복원 5점 배선. ②`SaveScope.ScopedStringPrefsKeys`에 5키 누락(`QuestSideProgress`/`QuestSideRepeat`/`QuestUnseen`/`TutorialHidden`/`WeeklyContestClaimed`) — `ScopedKey`를 실제로 거치면서 목록엔 없어 `ClearCurrentAccountLocal`이 못 지웠다. 같은 provider 재로그인은 uid가 같으므로 **삭제한 계정의 서브 퀘스트 진행이 부활**하고 낡은 "이미 수령"이 정당한 보상을 막았다 → 5키 추가 + `MigrationVersion` 4→5 +- [x] OutfitSetData 재감사 (P1:0, 2026-08-17) — ID 정합은 **기계 대조로 음성 확인**: 11세트 `requiredItemIds` 55개 전부가 `BuildCatalog()`의 95엔트리에 실재(누락 0), 두 세트 겹치는 itemId 0, 슬롯 1:1, 세트는 직렬화 경로 밖이라 세이브 무관. **P1 후보 2건 위임**(둘 다 표시·노출 설계): 신규 7세트 전원이 `captureChanceBonus`를 full에 얹어 `GetPrimaryBonusText`의 고정 우선순위가 **최소 수치를 광고**한다(ninja의 이속 +10%가 끝내 안 보인다), 0개 착용 세트는 목록에서 빠져 젬 전용 프리미엄 세트가 구매 전엔 존재조차 노출되지 않는다. **수치는 정상, 표시만 틀렸다** +- [x] PlayerInsectLevelUpUIController 재감사 (P1:2, 2026-08-17) — 표시와 차감이 갈려 있었다. UI는 `insect.levelCurve`를 직접 보고 null이면 `cost=0`으로 뒀는데, 이 프로젝트엔 `InsectLevelCurve` **에셋이 하나도 없고** `InsectData`는 런타임 생성이라 `levelCurve`에 대입하는 코드도 0곳 — 즉 항상 null이라 화면은 늘 "사탕 0"인데 결제는 폴백 곡선(4+(Lv-1)×2)으로 **실제로 캔디를 뺐다**. 만렙 가드도 없어 눌렀을 때의 false를 무조건 "사탕 부족"으로 표시했다(캔디가 충분한 사람이 부족한 줄 알고 더 사게 된다) → 둘 다 `collection.GetCandyCostForLevel`/`GetMaxLevel` 정본 API로 교체(`CollectionUI`가 이미 쓰던 것). 차감 원자성은 clean(가드 5개가 전부 `SpendCandy` 앞) +- [x] QuickAccessBarUI 재감사 (**P0:1**, P1:1, 2026-08-17) — **P0**: `FieldHudInput.RegisterBlockingRect` 호출이 파일 전체에 0건이었다. `PlayerMovement`는 클릭-이동을 Update에서 따로 폴링하는데 탭한 프레임엔 아직 모달이 안 열려 `IsAnyOpen()`이 false이고 IMGUI라 `pointerOverUI`도 false다 → 버튼 아래 월드 지점이 목표로 잡혀 **모달을 닫는 순간 캐릭터가 걸어간다. 메뉴를 열 때마다 매번.** 모바일은 키 입력 기반 자동 해제도 없어 더 나쁘다 → 데스크톱 바·모바일 컬럼 두 rect 등록(`CaptureInputController` 선례). **P1**: `GUI.Button`→`OnClick`이 모달 가드를 안 거쳐(핫키만 거쳤다) 도감 켠 채 상점을 누르면 모달이 중첩됐다 → `TryHotkey` 경유(열린 화면 자신의 버튼은 통과하므로 토글 닫기는 유지) +- [x] PlayerProgressController 재감사 (clean, 2026-08-17) — 증분은 `maxLevel 50→80` 실질 1줄. 다중 레벨업은 `if`가 아니라 `while`이고 요구치를 레벨마다 재계산해 한 번에 N레벨분도 소화, 상한 초과분은 0으로 클램프. 이벤트는 xp/level을 **다 갱신한 뒤** 발화해 구독자가 옛 값을 보는 구간이 없다(같은 세션에 `CaptureController`에서 고친 것과 반대 순서). 다중 레벨업 시 이벤트가 총 1회지만 소비처가 전부 pull 방식이라 견디고, 스토리 판정이 `cur >= need`라 건너뛴 레벨의 비트도 발화한다. 80 승격도 씬 직렬화 오버라이드 없음·최대 리전 요구 62 이내 확인 + +- [x] TrainingUI 재감사 (P1:1, 2026-08-17) — 08-07 증분이 넣은 `ownedInfoCache`가 **이 화면 자신의 장착·해제로는 안 비워졌다.** 무효화가 `InsectUpdated` 하나뿐인데 `DrawSkillEquip`은 `ForceSave()`만 부르고 그건 디스크에만 쓴다(`NotifyInsectChanged`만 이벤트를 쏜다). 캐시 이전엔 매 패스 새로 만들어 항상 최신이었으므로 증분이 만든 회귀다. 목록의 "장착: c/d"가 얼어붙고 닫았다 열어도 유지된다 → 두 호출부에 `NotifyInsectChanged(pid)` 추가. 컬링 stride·stale 개체·구독 짝은 clean +- [x] InsectEntity 재감사 (P1:1 처리, P1:1·P2:1 위임, 2026-08-17) — 풀링은 clean(`erased`가 `Initialize`/`BuildForBattle` 양쪽에서 무조건 대입돼 상속 경로 없음). **「지워진 개체」 연출이 3D 모델 밖으로 전파되지 않았다** — 월드는 검은 실루엣 + `"??? Lv.N"`인데 포획 선택창과 미니게임 HUD가 본명을 그대로 띄워, `CaptureFeedbackController`의 "이름을 되찾아주었다" 회수 문구가 이미 아는 이름을 반복하는 말이 됐다 → `InsectEntity.DisplayNameForPlayer` 단일 출처 신설 후 두 곳 경유. **위임**: `BuildForBattle`의 `erasedOverride` 파라미터를 넘기는 호출부가 **하나도 없어** 전투 진입 시 원색·본명으로 복귀한다(플러밍이 battle 경로 다지점) + 포획 선택창의 곤충 **그림**은 여전히 원색(실루엣 모드가 `InsectVisual`에 없다). P2: 서브에리어 스폰이 `erasedChance`를 안 넘겨 2막 실내 4곳만 0% +- [x] SubAreaEnvironment 재감사 (clean, 2026-08-17) — 증분은 `GetProfileForType` switch에 2막 4종 case 추가뿐이고 기존 좌표·높이는 무변경. 이탈 경로 8개(F2·OnGUI 백업·나가기 버튼·추락 안전망·25m 자동복귀 + 오프닝 다시보기·씬 리로드·프리픽스 숨김)가 전부 `ForceExitSubArea` 단일 깔때기로 수렴하고 진입/이탈 쓰기 8개가 짝을 이룬다. 다시보기는 `playUiRoot`만 끄는데 코디네이터 자신이 `World/`에 있고 `IsCoordinatorUnderUiRoot`를 거부 조건으로 쓰므로 **`playUiRoot`가 `World`일 수 없음이 구조적으로 증명**된다. 중첩 진입은 이중 게이트 + `initialized` 래치로 원본 스냅샷이 덮이지 않는다 +- [x] WorldFieldMultiplayerUI 재감사 (**P0:1** 처리, P1:1 위임, 2026-08-17) — **P0**: `FieldHudInput.RegisterBlockingRect` 호출이 파일 전체에 0건이라, 모달이 아닌 상태로 그려지는 버튼 6개(필드 초대·대화·3:3 대전·차단·초대 수락/거절) 위의 탭이 월드 클릭-이동으로 샜다. "3:3 대전"을 누르면 도전과 동시에 캐릭터가 상대 뒤로 걸어간다 → `BlockFieldClicks` 헬퍼 신설 후 상호작용 패널 5곳 등록. **이 화면은 픽셀 좌표계라 `UIScale.Scale`로 나눠 등록해야 한다**(등록은 가상 좌표를 받는다). **위임**: 친구 패널과 초대 팝업이 폭·x가 같고 y만 달라 겹치는데, IMGUI는 **먼저 그린 쪽이 MouseDown을 Use**하므로 "거절"을 누르려다 뒤에 깔린 "초대"가 눌린다 — 상호 배타 처리는 화면 흐름 설계 +- [x] NpcVisualBuilder 재감사 (clean, 2026-08-17) — 증분은 `StoryNpcAppearance` switch 81줄 **순수 추가**이고 `Build`/`BuildLeg`/`MakePart`/`CleanupMaterials` 본문은 무변경. **애니메이터가 찾는 노드 8개를 생성 지점과 1:1 대조**해 전부 일치 확인(`ArmL/ArmR/LegLPivot/LegRPivot/Body/HeadPivot/NetHandle/NetRing`, 모두 root 직계라 1-depth `Find`로 잡힌다). 뜰채 base 회전 리터럴도 무변경이고 애니메이터가 곱셈으로 쓰므로 결합 없음. 짝 switch(`StoryNpcDisplayName`)와 VillageBuilder 배치 id 9개까지 3자 대조 — default로 조용히 떨어지는 id 없음 +- [x] CharacterOutfitData 재감사 (clean, 2026-08-17) — 증분은 `OutfitLoadout` 클래스 50줄 순수 추가. 의도적으로 `[System.Serializable]`이 아니고 직렬화 필드도 안 늘어 세이브 무관. 신규 멤버 5개 전부 소비처 있음. 세트 `requiredItemIds` 고유 51건을 `MakeItem`/`MakePremiumItem` **두** 등록 경로에 대조해 누락 0(한쪽만 보면 45건이 없어 보이는 거짓양성 주의). 보너스 상한도 실재(`Clamp01`/`Clamp(0.5,2)`) +- [x] NpcDialogueUI 재감사 (P1:2, 2026-08-17) — 둘 다 **캠페인 영구 정지** 계열이다. ①프렐류드 게이트에 예외 봉쇄가 없었다 — `StoryDirector.FireBeat`는 이벤트를 쏘기 **전에** `pendingBeatId`를 커밋하고 `EvaluateTriggers`가 그걸 영구 스킵하므로, 연출에서 예외가 튀면 그 세션에 재발화 경로가 없다. 연출의 자체 타임아웃도 못 구한다(재생 상태를 세우기 전에 던지면 타임아웃이 무장되지 않는다) → try/catch로 감싸 대사로 낙하. ②`stagePrelude != null`은 필드가 **인터페이스 타입**이라 C# 참조 비교로 컴파일된다 — `UnityEngine.Object`의 파괴 검사 `==`가 적용되지 않는다. 제공자는 `World/`, 대화창은 `UI/`라 루트가 달라 한쪽만 죽을 수 있고, 그때 모달 등록 + `SetFrozen(true)`만 남고 `Update`가 안 돌아 하드 타임아웃이 영영 발화하지 않는다 → `TryPlayPrelude` 첫머리에 `isActiveAndEnabled` 가드. **대사를 띄우지 못하는 경로가 없음을 14개로 확인** +- [x] PlayerCurrencyWallet 재감사 (P1:1, 2026-08-17) — 지갑 내부(차감 원자성·음수 가드·젬 이중 관리·이벤트 순서)는 전부 clean인데, **젬 싱크 4곳 중 3곳이 클라우드 확정을 빠뜨렸다.** 로컬 파일만 쓰고 120초 자동저장을 기다리면 그 사이 젬 패키지 결제 시 서버가 **차감 전 잔액**에 보너스를 더해 확정한다 — 쓴 젬이 되돌아오고 아이템·의상·치료는 남는 복제다. `CashShopManager.PurchaseWithGems`가 이 시나리오를 주석으로 적고 자기 경로에만 넣어 뒀고 병원·상점·의상이 빠져 있었다 → 차감 초크포인트인 `SpendGems` 성공 분기 한 곳에 `SaveToCloud()`를 두어 네 경로를 덮었다(연속 호출은 `pendingSave`가 합친다) +- [x] CatcherKidNpc 재감사 (clean, 2026-08-17) — 증분은 듀얼 API + 스냅샷 5줄 **순수 추가**, 기존 상태머신 무변경. 스냅샷이 `SetEngaged(true)`·`Despawn()` **앞**에 있어 순서 역전 없고, `InsectData`가 ScriptableObject 공유 핸들이라 풀 반환 후에도 원래 종을 가리킨다. 예약 해제 3경로(성공·`EnterGiveUp`·`OnDisable`) 전부 확인 + `NpcManager`의 5초 sweep이 2차 방어선. `TickAI`가 거리 무관 라운드로빈이라 쿨다운도 항상 만료된다 +- [x] PlayerItemInventoryGridUIController 재감사 (**P0:1**, P1:1 처리, P1:1 위임, 2026-08-17) — **P0**: `BuildGrid`가 `contentRoot`의 **모든** 자식을 파괴하는데 부트스트랩이 `itemPrefab`을 바로 그 `contentRoot`의 자식으로 만든다 → 첫 갱신에서 템플릿이 파괴 예약되고 두 번째부터 `itemPrefab == null` 가드에 걸려 조기 반환, **그리드가 옛 수량으로 영구 동결**(아이템을 써도 개수가 그대로다) → 파괴 루프에서 템플릿 스킵. **P1**: 템플릿이 `SetActive(false)`이고 `Instantiate`가 그 상태를 복사하는데 켜 주지 않아 셀이 보이지 않고 `GridLayoutGroup` 배치에서도 빠졌다 → `SetActive(true)`. **위임**: 대상지정 아이템이 `hospital` 미배선 시 무음 소멸(부트스트랩 배선이 `buildWorld` try 안이라 실패 시 미배선인데 치료 아이템은 무조건 판매된다) +- [x] OpeningSequenceState 재감사 (clean, 2026-08-17) — 증분 43줄은 내레이션 블록 한 덩어리이고 **인스턴스 필드를 하나도 추가하지 않았다**(전부 `const` + `static` 순수 함수). 재진입은 다시보기마다 씬을 로드·언로드하고 `BeginPlayback`이 `new OpeningSequenceState()`로 갈아 끼워 6개 필드가 전부 기본값에서 시작한다 — `Reset()` 부재는 결함이 아니라 "인스턴스 교체" 계약이다. 전진이 전부 `realtimeSinceStartupAsDouble`이라 timeScale 0 차단과도 일치. **위임(테스트 보강)**: 가장 짧은 내레이션 창(1.4s)의 알파가 어디서도 고정돼 있지 않아 창이 `2×NarrationFade` 아래로 줄면 깜빡이는데 테스트가 안 잡는다 +- [x] NpcDialogueDatabase 재감사 (clean, 2026-08-17) — 증분은 2막 리전 6개 항목 40줄 순수 추가. **리전 키 13/13을 `RegionDefinitions`와 기계 대조**해 오타·누락·잉여 0 확인. 가장 위험했던 건 신규 항목이 사문화되는 경우였는데(`SyncSpawns`가 앞에서부터 잘라 캡이 옛값이면 전초기지 주민 0명), `NpcManager`·`GameplayTuningProfile` 양쪽 다 앵커 수에 맞게 갱신돼 있고 직렬화 오버라이드도 없어 13항목 전부 도달 가능 + +- [x] 스토리 진행 게이트 (StoryDirector/StoryObjective/Story.json) (P0:1, P1:1, 2026-08-23) — 여운 비트가 "같은 NPC의 직전 여운"만 prereq로 물어 **진행과 무관하게 뒷 챕터가 샜다**(초원에서 라온 3회 → 12장, 숲에서 세라 4회 → 엔딩 에필로그 전문 + 캔디120/XP250). `requiredBeatId` 신설로 체인(순서)과 진행(단계)을 AND로 분리 — 발화와 목표 도출 **양쪽**에 같은 게이트. **P0**: 앰비언트 `talk_*` 3종이 무게이트라 튜토리얼 중 HUD 목표가 잡담을 가리키고 첫 목표 자동 주행까지 태웠다(도착해 말 걸면 개막이 아니라 잡담이 소비됨) → `ch1_intro` 게이트. 검사 15·16·17·18 신설 +- [x] StoryObjectiveTracker 목표 문구 (P1:1, 2026-08-23) — 82비트 중 34개(41%)가 위치 없는 목표로 떨어져 HUD가 "모험을 이어가세요"만 띄웠고 **1막 전체가 그 구간**이었다. 28개는 `requiredRegionId`를 이미 갖고 있었다 → `ActInRegion` 종류 신설(리전 중심을 타깃으로) + 레벨·도감·퀘스트는 현재값 표시. 문구 조립은 순수 함수로 빼 테스트 고정. 지금 그 문구는 **0건** +- [x] StoryStageDirector 재감사 (P1:3, 2026-08-23) — 서브에리어 대치 연출 6곳이 배우를 워프해 등장시키는데 퇴장이 없으면 **서브에리어 좌표에 영구 정착**해 목표 트래커가 그 개체를 집는다(라온이 겪은 회귀의 재발). `GoHome`은 대사 후 최악 8초 조작 잠금을 6배로 늘리므로, **시야 밖(>27m)에서 조용히 워프 복귀**로 처리. 내가 넣은 지연 복귀 자체가 새 나가는 경로 둘(새 입장 연출이 목록을 통째로 비움 / 비활성화 시 Update 정지)도 함께 막음 +- [x] CutsceneDirector 전투 경계 (P1:1, 2026-08-23) — `fin_seal`이 **BattleWin에 컷신을 붙인 첫 사례**다. 결과 화면은 4초 뒤 스스로 닫히는데 대사를 그보다 빨리 넘기면 컷신이 전투 UI 밑에서 재생되고, 더 나쁘게는 `Play`가 `InBattleMode=true`를 "원래 상태"로 기록해 **끝날 때 카메라가 전투 구도에 갇힌다**. BattleScreenUI는 IModalUI가 아니라 레지스트리로는 알 수 없어 **배틀 모드를 신호로** 대기(12초 상한, 넘으면 연출만 버림) +- [x] 전투 종료의 프리즈 소유권 (BattleScreenUI/RaidBattleUI) (**P1:1**, 2026-08-23) — `EndBattle`의 finally가 무조건 `SetFrozen(false)`인데 프리즈는 bool 하나라 주인이 여럿이면 마지막에 쓴 쪽이 이긴다. 전투 승리로 스토리 비트가 뜨면 대화 모달이 프리즈를 걸어 두는데 결과 화면이 4초 뒤 그걸 풀어 **대사를 읽는 동안 캐릭터가 걸어다녔다**(`BattleWin` 비트 12개 전부). 모달 중에는 풀지 않게 가드 — 해제는 모달이 닫힐 때 자기가 하고, 안 풀려도 `AutoUnfreezeTime`(20s)이 받는다 +- [x] 보스전 소개 게이트 (WorldInteractionController) (P1:1, 2026-08-23) — 도전 조건이 `!storyFired`뿐이라 **"이미 소개를 봤다"와 "아직 차례가 아니다"를 구분하지 못했다.** 집게·저울·관장의 소개는 서브에리어 대치 비트에 걸려 있어 리전 도착 후 본진에서 말만 걸면 **이름도 모르는 채 보스전**이 시작됐다(최종 보스 포함) → `HasMetNpc`(순수 함수, 저작 데이터에서 판정). 그 대가로 생기는 새 잠금 위험(소개 없는 보스 = 영구 도전 불가)은 검사 19가 막는다 +- [x] 스토리 인물 앰비언트 (NpcDialogueDatabase/NpcDialogueUI) (P2:1, 2026-08-23) — 비트가 없을 때 마을 주민 풀로 떨어져 **명부회 간부가 날씨 이야기를 했다**(최종 보스인 관장도). 9인 전용 2줄씩 추가 — 줄거리는 진행시키지 않는다(두 번째 서사 경로를 만들지 않기 위해) +- [x] RegionMapUI 목표 마커·범례 (P1:1, P2:1, 2026-08-23) — 지도에 스토리 목표 마커가 0건이었다(리전·서브·수문장·레이드·플레이어만) → 민트 맥동 마커 + 라벨 + `OpenAt`. **P1**: 범례에 한 행을 더하니 7행이 되어 **세로 화면 정보 패널(334px)에서 안내 문구를 뚫었다** → 남은 높이에서 간격을 내는 방식으로(세로 29.4 / 가로 34). **P2**: 마커 라벨 280px가 지도 밖으로 삐져나갈 수 있어 영역 안으로 클램프(위에 자리 없으면 아래로) + +- [x] PlaySceneBootstrap 재감사 (P2:1 처리, P2:1 보고, 2026-08-23) — 2026-08-08 이후 증분 131줄(반투명 머티리얼 / 수문장 좌표 사본 제거 / 스토리 배선 / RestoreExploreBGM / 라벨 스케일 상쇄)을 훑었다. **배선 순서는 clean** — `Initialize`가 `LoadUnlockState`를 부른 뒤에 `CreateGuardians`가 오고(격파 상태 선로드 확인), `StoryDirector`의 AutoWire 3건은 전부 마을 try **밖**이라 빌더 예외에도 `dex` 주입이 살아 DexProgress 트리거가 안 죽는다. 라벨 스케일 상쇄(1.8× 곤충의 자식 글자)도 월드 스케일 1 / 높이 2.5m로 산술이 맞다. **P2 처리**: `SpawnGuardianInsect`가 `FindFirstObjectByType()`로 격파 여부를 **다시** 판정했다 — 호출부가 아우라를 그릴지 정하려고 이미 물어본 값이라 같은 판정의 사본이 둘이었다(바로 그 커밋이 `GetGuardianWorldPosition`이라는 같은 형태의 사본을 걷어낸 참이다) → 호출부에서 한 번만 판정. **P2 보고**: 수문장 엔티티는 부팅 1회 생성이라 **클라우드 로드가 나중에 진행을 내려도 반영되지 않는다** — 기기 교체 후 첫 접속에서 이미 격파한 수문장이 아우라와 함께 서 있고 다시 싸울 수 있다(`DefeatGuardian`이 idempotent라 보상 중복은 없고 재부팅하면 사라진다). 고치려면 엔티티 생명주기를 `ICloudReloadable`에 물려야 해 부트스트랩 구조 변경이다 + +- [x] TutorialQuestManager 재감사 (**P0:1** 처리, P2:1 보고, 검사기 1개 신설, 2026-08-23) — 2026-08-07 이후 증분 52줄(q_move 선두화 / q_talk_elder 삽입 / NotifyTalkToElder / 도감 등록). **P0**: 첫 파트너 지급이 퀘스트 보상에서 `ch1_intro` 비트로 옮겨가며 `CompleteQuest` 안의 1번 슬롯 자동 배치가 **죽은 코드**가 됐다 — 튜토리얼 퀘스트에 `rewardInsectId`가 **0건**으로 남았고 `SetSlot` 자동 배치 호출부는 저장소 전체에서 그 한 곳뿐이었다. 그래서 **배틀팀이 영원히 빈다**. `CaptureChoiceUI`의 [B] 배틀은 `HasAnyInsect()`만 보고 버튼과 키 입력을 함께 막으므로(`FindPlayerLeader`와 달리 컬렉션 폴백이 없다) `q_battle`(#9)에서 튜토리얼이 정지하고, 팀 편성을 가르치는 `q_team`은 **세 단계 뒤**(#12)라 안내조차 없다. 스토리도 함께 멈춘다(`ch1_first_battle`·`ch1_guardian_call`이 BattleWin) → 규칙을 `BattleTeamManager`의 `InsectCaptured` 구독 **한 곳**으로 옮겨 지급 6경로를 한 번에 덮음(도감 등록이 6곳으로 흩어져 `dex_grant_lint`가 필요해진 것과 반대 방향). `quest_lint` 검사 8 신설 — 구독 제거·`SetSlot` 제거 두 형태 모두 FAIL 지목, 복구 시 PASS 실측. **정상 확인**: `TalkToElder`가 5지점(enum·배열·Notify·게임플레이 호출부·lint) 전부 등록, 구독/해제 5쌍 대칭, `q_talk_elder` 활성 구간에 `ch1_intro`가 목표로 잡혀 조우 접근이 작동(힌트 "먼저 다가와 인사합니다"가 실제와 일치). **P2 보고**: 배열 중간에 퀘스트를 삽입해 **기존 세이브의 튜토리얼 순서가 어긋난다** — 이미 진행한 유저가 나중에 "마을 어르신을 만나다"로 되돌아간다(정지는 아니고 어르신에게 가면 풀린다) + +- [x] 튜토리얼 배열 삽입의 기존 세이브 순서 (P1:1, 검사기 1개 신설, 2026-08-23) — `q_talk_elder`를 3번 자리에 끼우자 **이미 진행한 세이브가 뒤로 되돌아갔다**(`ActivateNextQuest`가 배열을 앞에서부터 훑어 첫 미완료를 고른다). 완주한 유저에게 "마을 어르신을 만나다"가 부활해 완주 상태가 깨지고 튜토리얼 칩이 다시 떴다 → `BackfillSkippedStoryQuests`로 소급 완료. **보상은 주지 않는다**(안 한 일에 대한 지급이고 곤충 보상이면 복제다). 판정 경계는 "가장 뒤에 완료된 것" — 그보다 앞만 채우므로 **아직 할 차례인 퀘스트는 삼키지 않는다**(q_move만 깬 세이브에서 q_talk_elder는 그대로 다음 차례). 순수부(`TutorialQuestOrder.CollectBackfillTargets`)로 빼고 테스트 7건으로 경계 고정. `quest_lint` 검사 9 신설 — 전제인 "완료 순서 = 배열 순서"(모든 prereq가 배열 앞을 가리킴)가 깨지면 소급이 아직 할 차례인 퀘스트를 건너뛴다. **검사기를 만들다 껍데기를 하나 만들 뻔했다**: `game_facts`가 주는 키는 `prereq`인데 `prerequisiteQuestId`로 읽어 항상 None이었고, 첫 실측에서 주입한 위반을 못 잡아 발각됐다(`literal_fit_lint`의 정규식 누락과 같은 계열 — 검출력 실측 없이 올리면 아무것도 안 보는 검사기가 남는다) + +- [x] CutsceneLibrary 저작 재감사 (P1:2, 2026-08-23) — `cs_story_prologue`가 **재생 시점을 거꾸로 알고** 쓰여 있었다. 컷신은 `StoryBeatCompleted`(대사가 끝난 뒤)에 도는데 자막은 도입부로 쓰여, 셋째 컷이 어르신 대사 2번째 줄을 거의 그대로 되풀이하고 마지막 컷이 "마을 어르신이 그 이야기를 알고 있다"로 **방금 만나고 나온 사람을 찾아가라**고 안내했다 — 게임의 첫 1분이다. 카메라 워크는 그대로 두고 자막 3줄만 나가는 방향으로 고쳤다. **P1**: 테스트의 컷신 목록이 손 관리라(`AllCutscenes` 배열 + `[TestCase]` 5개 이중) 6번째 컷신을 추가하면 길이·복귀 검사가 조용히 그걸 안 본다 → 리플렉션 열거 + `TestCaseSource`, 그리고 "목록이 비면 전부 통과"를 막는 검사를 하나 더. 컷 연결(camFrom==직전 camTo)·시선 퇴화·플레이어 관통도 함께 고정(+11 케이스) +- [x] RegionMapUI 전면 재감사 (P1:2, P2:2, 2026-08-23) — **P1**: 도감 브라우저 헤더가 `{리전} 도감`인데 제목을 먼저 그리고 `< 뒤로` 버튼을 나중에 그려 **리전 이름이 통째로 버튼 밑에 깔렸다**(어느 지역 도감인지가 그 제목에만 있다) → `titleInset`. **P1**: 세로 정보 패널(334px)에서 정보 4줄 + 도감 진행바가 하단 [이동]/[도감] 버튼을 10px 파고들었다 — 지난 라운드에 **같은 패널의 범례 분기만 고치고 이 분기를 빠뜨렸다** → 버튼 자리를 먼저 잡고 남는 높이에서 행 간격을 낸다. **P2**: 목표 마커가 지도 경계 밖(서브에리어·NPC는 리전 원 밖일 수 있다)이면 정보 패널 위에 떴다 → 테두리 클램프. **P2**: 레이드 마커가 스폰 좌표에 굳어 배회한 보스를 못 따라갔다 → 현재 위치 +- [x] 수문장 봉인의 생명주기 (PlaySceneBootstrap) (P1:2, 2026-08-23) — 아우라·곤충이 **부팅 때 한 번** 지어질 뿐이라 두 자리에서 어긋났다. ①**인게임 격파**: 이겨도 아우라를 걷는 사람이 없어(`GuardianDefeated` 구독자는 StoryDirector 하나뿐) 지름 5m 붉은 구체가 다음 부팅까지 서 있었다. ②**기기 교체 첫 접속**: 클라우드 로드가 부팅 뒤라 다른 기기에서 깬 수문장이 멀쩡히 서 있었다. 봉인만 딕셔너리로 들고 `GuardianDefeated`와 `ICloudReloadable` 두 신호에 맞춰 걷거나 다시 세운다(**양방향** — 진척이 적은 계정으로 갈아타면 다시 선다). 등록은 `RegionManager` **뒤에** 해야 갱신된 격파 집합을 읽는다 +- [x] 전투 결과 화면 ↔ 스토리 대사 겹침 (StoryDirector/BattleScreenUI) (P1:1, 2026-08-23) — `BattleEnded`는 KO 순간에 울리는데 결과 화면은 4초(연출 페이즈 끼면 6초 가까이) 더 떠 있다. 그 위로 대화 모달이 열려 **획득 EXP·캔디 보상 패널을 통째로 덮었다**(`BattleWin` 비트 12개 전부). 결과 화면이 스스로 닫힐 때까지 미룬다 — 통지는 UI→Story 방향으로만 흐른다(그 반대는 의존 방향 위반). **연출인 컷신과 달리 이야기 진행이라 버리지 않는다**: 12초 안에 통지가 없으면 겹치더라도 그냥 쏜다 + +- [x] StoryDirector 재감사 (P1:2, 2026-08-23) — **읽고 있는 대사가 다른 비트에 밀려 사라졌다.** `EvaluateTriggers`는 같은 비트의 중복 발화만 막았을 뿐(`beat.beatId == pendingBeatId`) **다른** 비트가 끼어드는 건 막지 못했는데, `NpcDialogueUI.ShowStory`는 열린 모달을 `CloseModal`로 정리하고 그 경로가 `CompleteBeat`를 부른다 — **읽지 않은 대사가 보상까지 받고 열람 처리된다**(다시 볼 곳은 저널뿐). 실제 경로가 있다: 유적 이후 6개 리전은 수문장을 잡으면 `gd_X`(GuardianDefeat)와 `chN_clash`(BattleWin)가 **함께** 자격을 얻는다. **P1**: `DefeatGuardian`은 `CheckGuardianDefeat`에서 불려 **결과 화면이 뜨기도 전**이라 `gd_*`가 전투 UI와 겹쳐 열렸다(지난 라운드에 BattleWin만 미루고 이쪽을 빠뜨렸다 — 1v1·레이드 양쪽에서 수문장을 잡는다). 둘을 트리거 큐로 묶어 화면이 닫힌 뒤 **순서대로** 흘린다. 검사 6은 지연 발화를 배선으로 인정하되 **배출부가 살아 있을 때만** 센다 — 그 판정이 처음엔 `EvaluateTriggers` **선언부**의 `string`에 매칭돼 배출부를 통째로 지워도 PASS였다(검출력 실측으로 잡음) +- [x] TutorialQuestUI 재감사 (**P0:1**, P1:1, P2:1, 2026-08-23) — **P0**: 필드 위에 겹쳐 그리는 버튼(퀘스트 칩·✕·"퀘스트 보기"·목표 행)이 `FieldHudInput.RegisterBlockingRect`에 **한 번도 등록되지 않았다** → 그 탭이 월드 클릭-이동으로 새어 캐릭터가 버튼 아래로 걸어갔다. 목표 행은 더 나쁘다 — 누르면 자동 주행이 시작되는데 같은 탭이 클릭-이동으로도 발화해 서로 싸운다. 2026-08-17에 `QuickAccessBarUI`·`WorldFieldMultiplayerUI`에서 P0으로 처리한 것과 **같은 결함이 세 번째 파일에** 있었다. **P1**: `DrawQuestSectionHeader`가 패널 헤더 스타일의 fontSize를 21로 바꿔 그리고 **상수 31**로 되돌리는데, 헤더가 37로 커진 뒤에도 그 31이 남아 **패널 제목이 첫 프레임 이후 영구히 작아졌다**(이 파일의 `RowH` 주석이 경고하는 "숫자를 박아두면 어긋난다"의 실례가 자기 안에 있었다) → 전용 스타일로 분리. **P2**: 상세 팝업의 진행 카운트 상자가 62px 그대로라(칩은 84px로 넓혔다) 두 자리 수에서 가로로 잘렸다 → `LabelFit` +- [x] BattleTeamManager 재감사 (P1:1, 2026-08-23) — 지난 라운드의 `InsectCaptured` 자동 편성은 **앞으로의 획득**만 덮는다. 그 결함을 이미 겪은 세이브(곤충은 있는데 `battle_team.json`이 빈 상태)는 업데이트해도 그대로 막혀 `CaptureChoiceUI` [B]가 잠긴 채 `q_battle`에서 멈춘다 → `SeatFirstOwnedInsect`를 `AutoWire`·`ReloadFromDisk`에 걸어 부팅·클라우드 로드 시점에 구제한다. `TeamChanged`는 발화하지 않는다(사용자 액션이 아니라 시스템 정리 — `MigrateLegacySlots`와 같은 취급) +- [x] NpcDialogueDatabase 재감사 (검사기 1건, 2026-08-23) — 데이터 자체는 clean(9인 전원 키 정확, 오타 0). 다만 **아무도 검사하지 않고 있었다** — 인물을 새로 배치하고 `StoryNpcLines`를 빠뜨리면 주민 잡담으로 조용히 떨어진다(지난 라운드에 실제로 그랬다) → story_lint 검사 20(양방향 대조) 신설. 그 과정에서 `game_facts.story_npc_ids()`가 **VillageBuilder 주석의 예시 문자열(`storyNpcId = "리터럴"`)을 실제 NPC로 세고 있었다**(파이썬 `\w`가 한글을 먹는다 — 추출기가 자기 사용설명서를 데이터로 읽었다) → 줄 주석 제거. `strip_cs`는 못 쓴다, 그쪽은 문자열 리터럴까지 지워 읽을 값이 사라진다 +- [x] TutorialQuestData 표면 점검 (clean, 2026-08-23) — `QuestType` 19종·`TutorialQuest` 필드 기본값·신설 `TutorialQuestOrder` 순수부. 퀘스트 정의는 직렬화 대상이 아니라(코드 배열) 세이브 호환 문제가 없고, 사용 중인 QuestType은 quest_lint 검사 5가 전부 도달 가능으로 확인한다. 결함 없음 + +- [x] NpcDialogueUI 재감사 (P1:2, P2:2, 2026-08-23) — **P1**: 진행 표시("n/총")가 이름과 같은 `nameStyle`을 쓰는데, 스토리 모드에서 그 폰트가 26 → 38로 커진 뒤에도 상자는 **30px 그대로**라 세로로 깎였다(이름 쪽 `nameH`는 52로 함께 키웠는데 여기만 남았다). 폰트에서 파생 + `LabelFit` + 문자열 캐시(OnGUI는 프레임당 여러 번 돈다). **P1**: 저널 다시보기(`ShowStoryReplay`)가 **읽고 있는 진짜 비트를 밀어낼 수 있었다** — `ShowStory`의 `CloseModal`이 `CompleteBeat`를 부르므로 읽지 않은 대사가 보상까지 받고 열람 처리된다. `StoryDirector.FireBeat`에 건 가드는 발화 경로만 덮고 이 경로는 안 지난다. **P2**: 1×1 흰 텍스처를 직접 만들어 들고 있었다(파괴 없음) → `Texture2D.whiteTexture`. **P2**: 2막 간부 4인에게 포트레이트가 없어 8~12장 대치와 **최종 보스 관장이 얼굴 없이** 말했다(1막 하수 2인에게는 있다) → 하수의 검은 상의 계열을 이어받아 4인 추가 +- [x] FieldHudInput 등록 누락 전수 (**P0:1**, 2026-08-23) — 버튼을 그리는 25개 파일을 (버튼 수 / 등록 수 / 프리즈 / IModalUI) 표로 뽑아 훑었다. **`SubAreaWorldBuilder`의 진입·퇴장 버튼이 빠져 있었고, 그게 이 게임에서 가장 큰 필드 버튼이다**(620×100 하단 중앙) — 누르면 서브에리어로 들어가면서 동시에 **옛 월드 좌표를 향한 클릭-이동**이 걸려 새 월드의 엉뚱한 지점으로 걸어간다. 픽셀 좌표계라 `UIScale.Scale`로 나눠 등록(`WorldFieldMultiplayerUI.BlockFieldClicks`와 같은 형태). **네 번째 재발**이라 `rules/ui-layout.md`에 이력·전수 방법(표를 뽑는 셸 한 줄)·현재 등록 파일 목록을 남겼다. 검사기는 여전히 두지 않는다 — "IModalUI가 아닌데 버튼이 있고 등록이 없다"로 짜 보면 네 건 중 하나만 잡고(나머지 셋은 IModalUI를 함께 구현한다) 로그인·미니게임·오프닝이 거짓양성이 된다 +- [x] StoryObjectiveTracker 재감사 (P2:1, 2026-08-23) — 해석·자동 주행·지도 요청 경로는 clean. 다만 **리전 밖에 서 있으면 목표 행이 항상 지도를 열었다** — `RegionManager`는 어느 리전 원에도 안 들면 `CurrentRegion`을 null로 두는데(리전 사이 빈 땅을 지나는 동안), 그걸 "다른 리전"으로 읽었다. 모르는 것이지 다른 것이 아니다 → 그때는 목표 리전의 **해금 여부**로 가른다(잠긴 곳은 어차피 걸어 들어갈 수 없다) +- [x] MinimapUI 재감사 (P2:1, 2026-08-23) — 좌측 스택 기하(`LeftX`/`StackBelowY`/`LeftStackOccluded`)는 단일 출처가 지켜지고 있고 쐐기·점·캐시 모두 clean. 버튼은 없지만 **불투명 패널이라 탭이 뒤 월드로 새어** 캐릭터가 좌상단 방향으로 걸어갔다 → 퀵액세스 바가 자기 배경까지 등록하는 것과 같은 이유로 함께 막았다. `FindObjectsByType`가 0.3초마다 배열을 새로 만드는 것은 남겨 뒀다(프레임당이 아니고, 스포너 쪽에 활성 목록 API를 여는 별건이다) + +- [x] PlayerStatusHUD 재감사 (**P0:1**, P1:1, 2026-08-23) — **직전 라운드의 전수 점검이 이 파일을 놓쳤다.** 검색을 `GUI.Button`/`UISurface.Button` 문자열로 했는데 이 화면은 `Event.current` + `Rect.Contains`로 직접 히트테스트한다 — 그래서 **화면에서 가장 큰 HUD**(480×540 패널 + 닫힘 탭)가 `RegisterBlockingRect` 없이 남아 탭이 월드 클릭-이동으로 샜다. `evt.Use()`는 방어가 아니다: `PlayerMovement`는 IMGUI 밖에서 `Input.GetMouseButtonDown(0)`을 폴링한다. **P1**: 모달 검사가 없어 도감·상점·배틀 위에 패널이 얹히고, 더 나쁘게는 **모달의 좌상단 컨트롤을 누른 탭을 이쪽이 먼저 먹었다**(IMGUI는 z-order로 히트테스트를 가르지 않는다. 모바일 기본이 닫힘이라 그 자리에 탭이 서 있다) → 형제 HUD와 같은 `IsAnyOpen()` 가드 + 서브에리어 배너 수명도 모달 중에는 멈춘다. 규칙 문서의 전수 명령을 `EventType.MouseDown`까지 세도록 고쳤다 +- [x] StoryStageLibrary 저작 재감사 (**P0:1**, P1:2, P2:1, 검사기 1건, 2026-08-23) — 스테이지 14종의 좌표를 **방 기하와 대조**했다. 서브에리어는 벽으로 봉해진 축정렬 정사각 방이고 진입하면 플레이어가 남쪽 입구(로컬 z=-8)에 **고정 배치**되므로, 워프 지점의 방 로컬 좌표는 저작 시점에 계산된다 — 그렇게 재 보니 6개 대치 중 **셋이 방 밖이거나 벽에 껴 있었다**. `ch12_confront`(**P0**)는 관장이 남벽에서 6m 밖, 바닥 평면 밖에 떨어져 벽에 막힌 채 8초를 밀다 타임아웃으로 "완료"되고 **최종장 첫 대면이 화자 없는 대사만**으로 진행됐다(`SnapToFinalPose`는 지나간 이동 스텝을 소급하지 않는다). `ch7`은 남벽 밖 1m, `ch11`은 벽 안쪽 면에 캡슐이 끼면서 **카메라와 플레이어 사이**에서 불쑥 생겨났다. 원인은 하나다 — 1막 `Ch1RivalEnter`의 "뒤쪽 9m"는 **벽 없는 메인 필드**에서 나온 값인데 그 관용구를 방에 그대로 옮겼다. 셋 다 옆에서 들이도록 고쳤다. **P1**: `ch5_blocked`의 `MoveTo`가 목적지를 `플레이어 + 월드축 북쪽 1.6m`로 잡아, 플레이어가 북쪽에서 말을 걸었으면 그 점이 **플레이어 너머**가 되고 배우가 플레이어 콜라이더에 막혀 **8초 제자리걸음**(조작 잠긴 채) 뒤에야 대사가 떴다 → 목적지를 배우–플레이어 직선 위에서 잡는 `StageAction.ApproachPlayer`를 추가. **P1**: 테스트의 스테이지 목록이 손 유지라 새 시퀀스가 조용히 검사 밖에 남을 수 있었다 → `CutsceneTimelineTests`와 같은 리플렉션 수집으로 교체. `story_lint` 검사 21이 방별 벽 크기(`CreateBoundaryWalls`)와 입구 좌표(`FindSafeSpawnPosition`)를 읽어 이 조건을 강제한다 — 옛 좌표 3개를 되돌려 넣어 3건 FAIL을 확인하고 복원했다 +- [x] QuickAccessBarUI 재감사 (P1:2, P2:3, 2026-08-23) — 라이브 P0은 없다(AutoWire 6개 오버로드 전부 Bootstrap에서 불리고, 인덱스 표 4벌이 지금은 서로 맞다). 대신 **그 4벌이 인덱스로만 이어져 있는 것**이 결함이었다: `buttons[]` / `Update` 키 switch / `OnGUI` 키 switch / `IsActive`·`OnClick` case. 항목을 중간에 하나 끼우면 전부 한 칸씩 어긋나 **C가 훈련을 열고 배지가 지도에 붙는다** — 예외도 경고도 없다. 실제로 '캐릭터'[V] 항목을 뺀 적이 있다. → 키 표 2벌을 `buttons[].key` 파싱으로 **파생**시키고(파싱 실패는 경고), 배지 인덱스 상수를 `questBadge` 플래그로 옮기고, `OnClick`의 `default:`에 경고를 넣어 "버튼만 늘리고 배선 누락"이 조용히 지나가지 않게 했다. **P1**: `IModalUI` 구현이 죽어 있었다 — `mobileMenuOpen`을 true로 만드는 곳이 없고 `Register`도 안 부르므로 `IsOpen`은 영원히 false인데, 시그니처만 보면 이 바가 모달 스택에 참여하는 것처럼 읽혔다(모바일 "메뉴→팝업" 2탭 설계의 잔재). 제거. **P2 3건 보고**: ① 데스크톱 바는 모달 위에도 계속 그려지는데 퀵바 GameObject가 모달들보다 **나중에** 생성돼 하단 76px 띠의 클릭을 먼저 먹는다(`PlayerHintOverlay`가 같은 겹침으로 이미 한 번 당했다) ② `KeyGuideHUD`가 키 목록의 6번째 사본이고 Q/P/F4/F6/J가 빠져 있다(낡은 줄번호 참조는 이번에 심볼 참조로 교체) ③ `BarReservedHeight`는 하단 바 기준인데 모바일에서는 바가 우측 세로라 76px이 헛돈다 +- [x] WorldInteractionController 재감사 (P1:3, 자체발견 4, 2026-08-23) — 상호작용 우선순위(건물/주민/아이 최근접 단일 대상 + 곤충 거리 비교)와 조우 카메라(`FocusOn`↔`StoryBeatCompleted`의 `ReleaseFocus`)는 clean. **P1**: 원형 버튼 텍스처 2장(`MakeCircle`, 128×128 RGBA32)이 파기되지 않는다 — 런타임 `Texture2D`는 애셋이 아니라 씬이 내려가도 안 사라지고, `AccountSettingsUI`의 로그아웃·계정삭제가 씬을 통째로 재로드한다. **같은 누수를 `CaptureInputController`·`VirtualJoystickUI`에서도 자체 발견**(셋 다 OnDestroy가 없었다) → 셋 다 처리. **P1**: `TutorialQuestManager.Instance?.NotifyTalkToElder()` — null 조건 연산자는 **진짜 참조**만 보므로 파기된 MonoBehaviour가 static에 남아 있으면 그대로 호출로 들어간다(`UnityEngine.Object`의 오버로드된 `==`를 우회). 저장소에 `Instance?.`가 19곳인데 **싱글턴 10개 중 `Instance`를 비우는 것은 `WorldChannelManager` 하나뿐**이었다 → 호출부를 고치는 대신 뿌리에서 `TutorialQuestManager`·`SocialPvpManager`가 OnDestroy에서 static을 비우게 했다(기존 관례 적용). **P1**: `[E] 대화: {DisplayName}` 프롬프트가 길이를 데이터가 정하는데 640 고정 상자에 wordWrap 없이 그려져 가로로 잘릴 수 있었다 → `UIHelper.LabelFit`. `text_fit_lint`는 라벨 인자가 캐시 문자열이라 이 자리를 못 본다. **자체 발견 — 거짓 주석 2건**: `AuthManager.Logout`이 "CloudSaveManager는 DontDestroyOnLoad라 씬 리로드 후에도 in-flight 요청이 완료된다"고 적어 두었는데, DDOL은 `if (transform.parent == null)` 가드 뒤에 있고 부트스트랩이 `World/CloudSaveManager`로 만들어 **가드가 통과하지 않는다**. 유일한 호출부가 바로 다음 줄에서 씬을 재로드하므로 그 플러시는 실제로 끊긴다 → 주석을 사실로 고치고 수명 문제는 큐에 별건으로 올렸다 +- [x] 싱글턴 수명 · DontDestroyOnLoad 가드 (**P1:3**, 검사기 1건, 2026-08-23) — **DDOL 호출 3개가 전부 죽은 코드였다.** `if (transform.parent == null) DontDestroyOnLoad(gameObject)` 뒤에 있는데, 이 매니저들을 만드는 곳은 `PlaySceneBootstrap` 하나뿐이고 `EnsureObject("World/…")`가 경로대로 계층을 세워 **부모를 달아** 만든다 — 가드가 통과한 적이 없다(씬 파일에도 `GameBootstrap` 말고는 아무것도 없다). 그 죽은 줄을 근거로 **거짓 주석 두 개**가 자랐고(로그아웃 플러시·계정삭제 재시작) 하나는 실제 손해였다: `AuthManager.Logout`이 마지막 클라우드 플러시를 쏘는데 유일한 호출부가 **바로 다음 줄에서 씬을 재로드**해 코루틴이 오브젝트째 죽는다. → ① 죽은 DDOL 3줄 제거 + 수명을 클래스 주석에 명시 ② 로그아웃 재로드를 `IsSaving`이 내려갈 때까지(상한 3초) 미루는 코루틴 ③ 싱글턴 9종 전부 `OnDestroy`에서 `Instance`를 비운다 — 안 비우면 `Instance != null`(Unity 파괴 검사)과 `Instance?.`(진짜 null 검사)가 서로 다른 답을 내고 저장소에 `Instance?.`가 19곳이다(감사 시점 9종 중 1종만 비우고 있었다). **`singleton_lint.py`** 신설 — `ci_check` 등록, `Instance = null` 제거 주입으로 FAIL 확인 후 복원. 첫 파서는 파일의 첫 `public class`를 집어 `GachaBoxManager`를 `GachaResult`로 보고했다(Instance 선언 앞의 마지막 클래스를 집도록 교정) +- [x] SubAreaWorldBuilder 재감사 (**P1:3**, 2026-08-23) — **[E]를 세 시스템이 폴링하는데 사슬이 둘까지만 이어져 있었다.** 포획(`CaptureInputController`)과 상호작용(`WorldInteractionController`)은 `HasPriorityTarget`으로 서로 양보하는데 서브에리어 진입만 그 밖에 있었다. 진입 반경 12m와 포획 탐색 반경 8m가 **겹치는 자리가 흔해서**, 입구 근처에서 곤충을 잡으려고 [E]를 누르면 포획과 진입이 동시에 발화한다 — 어느 쪽이 먼저 도는지는 Update 실행 순서라 정해져 있지 않고, 진입이 먼저면 플레이어가 2000m 밖으로 순간이동한 뒤 포획이 시작된다. → `CaptureInputController.HasCatchTarget`을 열고 서브에리어가 양보하게 했다(여기만 620×100 전용 버튼이 따로 있어 [E]가 유일한 진입로가 아니다). **P1**: `OnDisable`에서 `SubAreaChanged`를 해지하는데 `OnEnable`이 없다 — 한 번이라도 꺼졌다 켜지면 진입·이탈이 영구히 죽는다. `subscription_lint`는 UI 루트 하위만 보므로 `World/` 아래인 이 파일은 검사 밖이었다. **P1**: 진입/퇴장 토스트가 픽셀 좌표계인데 y가 90 고정이라 노치 아래로 들어간다 → `Mathf.Max(90f, UISafeLayout.Px.ContentTop)`(인셋 0이면 그대로). 머티리얼 정리·Y 안전망·25m 이탈 단일 경로(`RequestExit`)·버튼 클릭-이동 등록은 clean +- [x] BlightVfx (P1:1, P2:5 처리 + 자체 2건, 2026-08-23) — 런타임 머티리얼 누수 · 붕괴 상태 누수 · 서브에리어 중 탈색 무음 실패 +- [x] RegionBlightManager + 오염 거점 라운드 회귀 (P1:2, P2:4 처리 + 자체 1건, 2026-08-23) — 완료 불가 퀘스트 · 보상 문구 소실 · 재배치 폴백 +- [x] InsectSpawner 재감사 (P0:1, P1:1, P2:1 처리, 2026-08-23) — 서브에리어 포인트가 필드로 끌려오던 전용종 유출 +- [x] AuthManager 재감사 (P1:3, P2:1 처리, 2026-08-23) — 로그아웃 뒤 세션 부활 · 네트워크 오류에 토큰 영구 삭제 +- [x] NpcDuelController 재감사 (P1:2, P2:2 처리, 2026-08-23) — 결과 토스트가 한 번도 안 뜨던 결함 +- [x] LoginUI 재감사 (P1:3, P2:1 처리, 2026-08-23) — 회원가입 폼 파괴 · 폰트 상속 잘림 · 구독 비대칭 +- [x] LedgerPressure (P0:0, P1:0, 2026-08-27) — clean. 공개 멤버 전부 19테스트로 고정, 소비 경로도 방어됨 +- [x] InsectBattleController 재감사 (P0:1, 2026-08-27) — Epic·Legendary는 1v1이 없어 `BattleWin` 종 지정이 도달 불가였다(레이드→BattleWin 배선 신설) +- [x] BlightPolicy (P1:1, 2026-08-27) — 상한이 낮으면 오염이 곤충을 **늘리고** 있었다(하한을 보장 수량으로 씀) +- [x] VillageBuilder 재감사 (P1:1 + 형제 2건, 2026-08-27) — 월드 빌더 셋이 런타임 머티리얼을 회수하지 않았다(씬 재로드마다 누수) +- [x] BattleScreenUI 재감사 (P0:0, P1:0, 2026-08-27) — clean. 과거 P0 둘(OnEnable 재구독·슬로우모션 복구) 정상, OnGUI 할당 0 +- [x] RegionManager 재감사 (P0:0, P1:0, 2026-08-27) — clean. 1막 리전 겹침은 테스트가 명시 제외한 수용 부채이고 진행 정지는 없다 +- [x] StoryDirector 재감사 (P0:0, P1:0, 2026-08-27) — 미뤄 둔 트리거는 견고 +- [x] 포획 트리거 미루기 (P2→처리 + 조용한 누락 1건, 2026-08-27) — 전투·레이드 포획이 보상 패널을 덮던 것 + 스토리 보상 곤충의 포획 비트가 버려지던 것 +- [x] 캐릭터 리워크 12파일 — ProcMeshLibrary / CharacterPalette / CharacterPresetLibrary / CharacterFaceAnimator / CharacterAppearanceConfig / StarterInsectCatalog / PlayerVisualBuilder 재감사 / LoginUI 재감사 / NpcVisualBuilder 재감사 / CharacterPortraitRenderer 재감사 / CloudSaveManager 재감사 / CaptureItemPickup 재감사 (P0:1, P1:5, 2026-08-29) — Disc 와인딩 뒤집힘(눈·동공이 정면에서 컬링), TaperedCapsule 캡 노멀·축퇴 삼각형, PlayerVisualBuilder 머티리얼 사실상 미회수, LoginUI 프리뷰 override 누출·드래그 굳음 + +- [x] StarterInsectProbe — 첫 파트너 지급 경로 검증 공백 (P0:0, P1:0, 2026-08-29) — 기존 수단 어느 것도 안 닿던 경로에 배치 프로브 신설. 4케이스 전부 정상 확인 + +- [x] OutfitRenderProbe — 의상 spawn/bind 파츠 렌더 검증 공백 (P0:0, P1:0, 2026-08-29) — 마네킹 3D 리그를 직접 촬영해 11종이 실제로 형태를 그리는 것 확인 +- [x] InventoryUI (P0:0, P1:0, 2026-09-09) — clean. P2 3건 처리(스프라이트 textureRect 그리기 · 행 문자열 빌드 시 1회 캐시 · 서로게이트 첫 글자) +- [x] QuestSaveMerge (P0:0, P1:0, 2026-09-09) — clean. P2 3건 처리(quest_lint 검사 11 questId 구분자 금지 · 테스트 +4 · 공백 activeQuest 가드) +- [x] TrainingManager 재감사 (P0:0, P1:2, 2026-09-09) — 디스크 소모가 무표시였다(TrainingUI 버튼 "디스크 사용 ×n"+피드백, TrainingManager.GetDiscCount) · TrainSkill 왕복 테스트 0건 → TrainingRoundTripTests 5건(레벨 게이트 직접 호출·회차별 차감·교체 실패 환불·디스크 1장 소모·디스크 없음 거부). P2 2건 처리(비용 0이면 차감 생략 · PruneTrainingProgress 로드 시 죽은 진척 정리) +- [x] TrainingUI 재감사 (P0:0, P1:2, 2026-09-09) — 진행바가 쿨다운 라벨을 관통(itemH↑·트랙 y 재배치) · 회차 표시 중복 제거. P2 3건 처리(FindDiscFor O(1) 캐시 · 닫기 시 pendingNewSkillId 정리 · 디스크 교체 헤더) +- [x] RaidBattleUI 재감사 (P0:0, P1:0, 2026-09-09) — clean. P2 3건 처리(미사용 BossSpawnPosition 제거 · StartRaid 스냅샷 테스트 2건 · 격파 판정을 RegionManager.TryDefeatGuardian으로 단일화) +- [x] PlayerInsectCollection 재감사 (P0:0, P1:3, 2026-09-09) — 자동 장착 루프가 "해제"를 되살렸다(장착 0개일 때만으로 축소) · 주석 정정 · GetEquippedSkills가 InsectUpdated 미발화. P2 3건 처리(ResolveSkill insect null 폴백 · 손상 세이브 .corrupt 보존 · 테스트) +- [x] BattleScreenUI 재감사 + InsectEntity 재감사 (P0:2, P1:1, 2026-09-09) — **수문장에게 한 번 지거나 도주하면 영구 재도전 불가**(Despawn 래치 + SetEngaged 미복구, 두 Explore가 독립 발견) → 수문장 Despawn no-op·ReleaseEnemyAfterBattle · 수문장 포획 백스톱을 StartMinigame으로 단일화 · 미사용 스냅샷 3종 제거 · GuardianLocationTests +2 +- [x] PlaySceneBootstrap 재감사 (P0:1, P1:2, 2026-09-09) — **생성 화면에서 고른 외형이 필드 캐릭터에 안 나왔다**(PlayerVisualBuilder.RebuildFromPrefs + PlayerMovement 캐시 무효화) · 하위 디스크 3종의 입수 경로가 화면에 없는 uGUI 상점뿐(CashShopManager에 추가) · HospitalUI 배선이 village try 안(밖으로). P2 3건 처리([I] 키 안내 · 디스크 캔디 0 · 단가 출처 주석) +- [x] StoryDirector 재감사 (P0:0, P1:3, 2026-09-09) — 드레인이 1회성 트리거(QuestComplete·GuardianDefeat·선택 결과)를 게이트 실패 시 버림(되돌리기 3회) · QuestComplete 큐 적재 중 종료 시 영구 유실(완료 퀘스트 재스윕) · QueueChoice가 Immediate 아닌 대상을 조용히 소비(경고). P2 처리(저널 인덱스 무효화 · 카메라 경로 60초 절대 상한) +- [x] NpcDialogueUI 재감사 (P0:0, P1:2, 2026-09-09) — 선택 버튼에 button 파생 스타일(회색 상자 겹침) → label 파생 choiceStyle · LabelFit 미적용으로 세로 화면 잘림 → DrawChoiceButton. P2 처리(한 줄 3개 캡 · 스토리 폰트 28 · storyDirector null 경고) +- [x] StarterInsectCatalog 재감사 (P0:0, P1:2, 2026-09-09) — DefaultId↔Story.json 실제 파싱 테스트 · 오타 시 종 데이터 없는 개체가 그대로 들어오던 폴백 보강. P2 처리(클라우드 복원 sanitize · 표시명 "첫 파트너"로 일반화) +- [x] StoryJournalUI 재감사 (P0:0, P1:1, 2026-09-09) — 인덱스 무효화가 StoryBeatCompleted 구독뿐이라 클라우드 반영(ReloadFromDisk)을 못 봄 → SeenCount 스냅샷 키로 교체(구독 제거). P2 처리(행 메타·헤더 문자열 캐시 · textW 하한) +- [x] PlayerMovement 재감사 (P0:0, P1:1, 2026-09-09) — 외형 재빌드가 CharacterFaceAnimator 노드 캐시를 안 비워 생성 직후부터 눈 깜빡임 정지 → InvalidateNodes. P2 처리(옛 파츠 한 프레임 원점 노출 차단 · 도구 없는 외형의 매 프레임 Find 고착) +- [x] TutorialQuestManager 재감사 (P0:0, P1:2, 2026-09-09) — 완료 퀘스트 재스윕이 Start·리전 이동에만 있어 로그인/계정 전환 뒤 리전을 옮기기 전까지 비트 미발화 → StoryDirector.ReloadFromDisk에서도 재스윕. P2 처리(IReadOnlyCollection + ToArray 스냅샷). 테스트 공백(재스윕 중복 억제)은 걸음 도구가 덮는 경로라 보류 + +## Uncovered (우선순위순) + +## Round Log + +보존 개수는 **파일 상단 안내(9줄)가 단일 출처**다 — 여기에 사본을 적지 않는다. +넘치는 항목은 `.claude/audit-archive/round-log-2026H2.md`로 옮긴다(그 이전은 `-2026H1`). + +> 이 로그는 **쓰기 전용**이다 — audit 스킬 Step 4가 추가하지만 어느 단계도 읽지 않는다. +> 그런데 Step 1이 이 파일을 통째로 Read하므로 쌓아두면 매 라운드 컨텍스트를 먹는다. +- 2026-09-09: InventoryUI — P0:0, P1:0 처리(clean). P2 3건 보고·보류. 자체 발견 회귀 0건 +- 2026-09-09: QuestSaveMerge — P0:0, P1:0 처리(clean). P2 3건 보고·보류. InventoryUI P2 3건은 이 라운드에서 처리(컴파일 0 에러). 자체 발견 회귀 0건 +- 2026-09-09: TrainingManager 재감사 — P0:0, P1:2 처리. 08-08 이후 디스크 방식·requiredLevel 게이트·누적 훈련이 들어왔는데 ①디스크가 습득 회차에 소모되는 사실을 UI가 어디에도 안 적었고(`FindDiscFor` 호출부 0) ②새 테스트 2파일이 `TrainSkill`을 한 번도 실행하지 않아 실제 방어선 `IsSkillAllowed`·환불·소모가 전부 공백이었다. 인벤토리 두 종을 실제로 붙이고 세이브를 백업/복원하는 왕복 테스트 5건 추가(878/878). QuestSaveMerge P2 3건도 이 라운드에서 처리. 자체 발견 회귀 0건 +- 2026-09-09: 잔여 9건 일괄 재감사 — P0:3, P1:15 처리. **수문장 패배 후 영구 재도전 불가**와 **생성 외형 미반영**은 각각 두 Explore가 독립적으로 짚었다(정체성 판정 도입·캐릭터 리워크의 반대편 결함). 신규 테스트 12건, 887/887(2차 실행). 자체 발견 회귀 1건(ResolveSkill 두 번째 null 참조 — 첫 실행 1 실패 → 수정) +- 2026-09-09: 2차 후보 3건(StoryJournalUI·PlayerMovement·TutorialQuestManager) — P0:0, P1:4 처리. 오늘 내가 넣은 코드의 반대편 결함이 셋(저널 무효화 경로 누락 · 얼굴 애니메이터 캐시 · 재스윕 시점). 자체 발견 회귀 0건 +> 영역별 처리 이력은 위 Covered 인덱스가 이미 갖고 있다. +> +> 개수를 두 곳에 적었다가 실제로 어긋났다 — 상단은 "최근 10건", 여기는 "최근 3건만 둔다"라고 +> 서로 다른 말을 하는 동안 23건이 쌓여 47KB가 됐다(2026-08-03 정리). diff --git a/.claude/hooks/__pycache__/check_namespace.cpython-313.pyc b/.claude/hooks/__pycache__/check_namespace.cpython-313.pyc deleted file mode 100644 index 02faf9f..0000000 Binary files a/.claude/hooks/__pycache__/check_namespace.cpython-313.pyc and /dev/null differ diff --git a/.claude/hooks/__pycache__/warn_monolith.cpython-313.pyc b/.claude/hooks/__pycache__/warn_monolith.cpython-313.pyc deleted file mode 100644 index 8bea8a9..0000000 Binary files a/.claude/hooks/__pycache__/warn_monolith.cpython-313.pyc and /dev/null differ diff --git a/.claude/hooks/audit_flow_inject.py b/.claude/hooks/audit_flow_inject.py new file mode 100644 index 0000000..253a6fc --- /dev/null +++ b/.claude/hooks/audit_flow_inject.py @@ -0,0 +1,96 @@ +"""UserPromptSubmit hook: 사용자 메시지 직전에 audit 진척 컨텍스트를 자동 주입. + +audit 자동 플로우(.claude/rules/audit-flow.md)를 매 응답에 reminder로 주입한다. +사용자가 매번 `/audit` 입력하지 않아도 작업 완료 후 자동 audit이 실행되도록 컨텍스트 강화. + +조건: +- audit-progress.md 존재 + Uncovered ≥ 1 +- 사용자 메시지에 audit 거부 의사 없음 ("audit 안 해", "그만" 등) + +출력: hookSpecificOutput.additionalContext로 1줄 룰 reminder + 다음 영역 명시. +""" + +import sys, os, io, json, re + +try: + sys.stdin = io.TextIOWrapper(sys.stdin.buffer, encoding='utf-8', errors='replace') + sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace') +except Exception: + pass + +raw = sys.stdin.read() +try: + d = json.loads(raw) +except Exception: + print(json.dumps({"suppressOutput": True})) + sys.exit(0) +# 최상위가 dict가 아니거나(null/배열) tool_input이 명시적 null이면 조용히 나간다. +# d.get("k", {})는 키가 **없을 때만** {}를 주지 실제 null에는 null을 준다. +if not isinstance(d, dict): + print(json.dumps({"suppressOutput": True})) + sys.exit(0) +if not isinstance(d.get("tool_input"), dict): + d["tool_input"] = {} +if not isinstance(d.get("tool_response"), dict): + d["tool_response"] = {} + + +user_prompt = d.get("prompt", "") or d.get("user_message", "") or "" + +# 사용자가 audit 거부 의사 표명 — 자동 플로우 차단 +deny_patterns = [ + r"audit\s*안\s*해", r"audit\s*하지\s*마", r"audit\s*그만", r"audit\s*skip", + r"skip\s*audit", r"audit\s*건너", r"감사\s*안\s*해" +] +for pat in deny_patterns: + if re.search(pat, user_prompt, re.IGNORECASE): + print(json.dumps({"suppressOutput": True})) + sys.exit(0) + +# 진척 파일 확인 +progress_path = os.path.join(".claude", "audit-progress.md") +if not os.path.exists(progress_path): + print(json.dumps({"suppressOutput": True})) + sys.exit(0) + +try: + with open(progress_path, "r", encoding="utf-8") as fh: + text = fh.read() +except Exception: + print(json.dumps({"suppressOutput": True})) + sys.exit(0) + +# 줄 시작(^) 앵커 필수 — 앵커가 없으면 본문이나 헤더가 `## Uncovered`를 인용만 해도 +# 그 지점이 첫 매칭이 되어 엉뚱한 구간을 세고 카운트 0으로 침묵한다. +m = re.search( + r"^##\s+Uncovered.*?\n(.*?)(?=\n^##\s|\Z)", text, re.DOTALL | re.MULTILINE +) +if not m: + print(json.dumps({"suppressOutput": True})) + sys.exit(0) + +section = m.group(1) +uncovered_count = len(re.findall(r"^\s*-\s*\[\s*\]\s+", section, re.MULTILINE)) +if uncovered_count == 0: + print(json.dumps({"suppressOutput": True})) + sys.exit(0) + +first_match = re.search(r"^\s*-\s*\[\s*\]\s+(.+?)$", section, re.MULTILINE) +next_target = first_match.group(1).strip() if first_match else "?" +if len(next_target) > 70: + next_target = next_target[:67] + "..." + +# 룰 reminder — 작업 완료 후 자동 audit 권장 +ctx = ( + f"[AUDIT-FLOW] audit 자동 플로우 활성. Uncovered {uncovered_count}개 — 다음 대상: {next_target}\n" + f"규칙(.claude/rules/audit-flow.md): 사용자 요청 작업 완료 후 라운드 결과 보고 직후 " + f"audit skill 자동 실행. 같은 응답 안에서 1회만, P0/P1 자동 처리, P2는 보고 후 결정 위임. " + f"사용자가 \"audit 안 해\" 등 거부 의사 표명 시 차단." +) + +print(json.dumps({ + "hookSpecificOutput": { + "hookEventName": "UserPromptSubmit", + "additionalContext": ctx + } +})) diff --git a/.claude/hooks/audit_reminder.py b/.claude/hooks/audit_reminder.py new file mode 100644 index 0000000..21a1b77 --- /dev/null +++ b/.claude/hooks/audit_reminder.py @@ -0,0 +1,108 @@ +"""Stop hook: 응답이 끝날 때 audit 미검토 영역을 알린다. + +사용자가 매번 "문제점 분석 및 개선해"를 입력하는 부담을 없애는 게 목적이다. +남은 Uncovered 카운트와 다음 대상을 두 경로로 낸다: +- additionalContext → 모델 (자동 실행 판단용) +- systemMessage → 사용자 (진척 표시용. 모델은 이걸 읽지 못한다) + +침묵 조건: +- stop_hook_active — 이미 Stop 훅에서 이어진 턴 (무한 루프 차단, 필수) +- Uncovered 섹션의 `- [ ]` 카운트가 0 +- 진척 파일 없음 + +주의: 이 훅은 사용자 프롬프트를 볼 수 없어 "audit 안 해" 같은 거부 의사를 +판정할 수 없다. 거부 존중은 UserPromptSubmit 쪽(audit_flow_inject)이 담당하며, +자동화의 주 동력도 그쪽이다. 이 훅은 보조 알림이다. +""" + +import sys, os, io, json, re + +try: + sys.stdin = io.TextIOWrapper(sys.stdin.buffer, encoding='utf-8', errors='replace') + sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace') +except Exception: + pass + +try: + d = json.loads(sys.stdin.read()) +except Exception: + # 입력을 못 읽으면 stop_hook_active를 판별할 수 없다. 그 상태로 발화하면 + # 발화 → 모델 작업 → Stop → 또 실패 → 또 발화로 루프가 성립하므로 침묵한다. + print(json.dumps({"suppressOutput": True})) + sys.exit(0) +# 최상위가 dict가 아니거나(null/배열) tool_input이 명시적 null이면 조용히 나간다. +# d.get("k", {})는 키가 **없을 때만** {}를 주지 실제 null에는 null을 준다. +if not isinstance(d, dict): + print(json.dumps({"suppressOutput": True})) + sys.exit(0) +if not isinstance(d.get("tool_input"), dict): + d["tool_input"] = {} +if not isinstance(d.get("tool_response"), dict): + d["tool_response"] = {} + + +# 무한 루프 차단 (필수): additionalContext를 주면 모델이 이어서 작업하고, 그 턴이 +# 끝나면 Stop이 또 발화한다. stop_hook_active는 "이미 Stop 훅에서 이어진 턴"을 +# 뜻하므로 여기서 침묵해야 사용자 턴당 자동 audit이 정확히 1회로 끝난다. +if d.get("stop_hook_active"): + print(json.dumps({"suppressOutput": True})) + sys.exit(0) + +# 진척 파일 경로 — settings.json이 프로젝트 루트에서 실행되므로 상대 경로. +progress_path = os.path.join(".claude", "audit-progress.md") + +if not os.path.exists(progress_path): + print(json.dumps({"suppressOutput": True})) + sys.exit(0) + +try: + with open(progress_path, "r", encoding="utf-8") as fh: + text = fh.read() +except Exception: + print(json.dumps({"suppressOutput": True})) + sys.exit(0) + +# `## Uncovered` 섹션 시작 ~ 다음 `##` 또는 EOF. +# 줄 시작(^) 앵커 필수 — 앵커가 없으면 본문이나 헤더가 `## Uncovered`를 인용만 해도 +# 그 지점이 첫 매칭이 되어 엉뚱한 구간을 세고 카운트 0으로 침묵한다. +m = re.search( + r"^##\s+Uncovered.*?\n(.*?)(?=\n^##\s|\Z)", text, re.DOTALL | re.MULTILINE +) +if not m: + print(json.dumps({"suppressOutput": True})) + sys.exit(0) + +section = m.group(1) +# `- [ ] ` 패턴 카운트 (체크 안 된 항목) +uncovered_count = len(re.findall(r"^\s*-\s*\[\s*\]\s+", section, re.MULTILINE)) + +if uncovered_count == 0: + print(json.dumps({"suppressOutput": True})) + sys.exit(0) + +# 첫 번째 미검토 항목 추출 (다음 audit 대상 미리 보여줌) +first_match = re.search(r"^\s*-\s*\[\s*\]\s+(.+?)$", section, re.MULTILINE) +next_target = first_match.group(1).strip() if first_match else "?" +# 너무 긴 라인 자르기 +if len(next_target) > 60: + next_target = next_target[:57] + "..." + +msg = ( + f"audit 미검토 {uncovered_count}개 — 다음: {next_target}. /audit 자동 실행 권장." +) + +# Stop 훅은 additionalContext를 지원한다(옛 주석의 "미지원"은 사실이 아니었다). +# systemMessage는 사용자에게만 보이고 모델은 읽지 못하므로, 그것만으로는 +# "/audit 자동 실행"이 모델에 전달된 적이 없었다. 둘 다 낸다 — +# additionalContext는 모델에게, systemMessage는 사용자에게. +print(json.dumps({ + "hookSpecificOutput": { + "hookEventName": "Stop", + "additionalContext": ( + f"{msg} 자동 플로우 규칙은 .claude/rules/audit-flow.md 참조. " + "사용자가 audit을 거부했거나 다른 작업을 즉시 요청한 경우에는 실행하지 않는다." + ) + }, + "systemMessage": msg, + "suppressOutput": True +})) diff --git a/.claude/hooks/auto_verify_trigger.py b/.claude/hooks/auto_verify_trigger.py new file mode 100644 index 0000000..3065b9c --- /dev/null +++ b/.claude/hooks/auto_verify_trigger.py @@ -0,0 +1,99 @@ +"""PostToolUse hook: 큰 변경 감지 시 verify 8항목 자동 점검 권유. + +memory rule (feedback_post_impl_verify)이 "구현 후 /verify 자동 점검"인데 트리거 메커니즘이 +없었다. 이 hook이 Edit/Write 변경 크기를 보고 임계값 초과 시 다음 응답에 점검 권유 컨텍스트를 +주입한다. 작은 fix는 노이즈 방지 위해 스킵.""" + +import sys, json, io, os + +try: + sys.stdin = io.TextIOWrapper(sys.stdin.buffer, encoding='utf-8', errors='replace') + sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace') +except Exception: + pass + +raw = sys.stdin.read() +try: + d = json.loads(raw) +except Exception: + print(json.dumps({"suppressOutput": True})) + sys.exit(0) +# 최상위가 dict가 아니거나(null/배열) tool_input이 명시적 null이면 조용히 나간다. +# d.get("k", {})는 키가 **없을 때만** {}를 주지 실제 null에는 null을 준다. +if not isinstance(d, dict): + print(json.dumps({"suppressOutput": True})) + sys.exit(0) +if not isinstance(d.get("tool_input"), dict): + d["tool_input"] = {} +if not isinstance(d.get("tool_response"), dict): + d["tool_response"] = {} + + +tool_name = d.get("tool_name", "") +tool_input = d.get("tool_input", {}) +file_path = tool_input.get("file_path", "") + +# .cs 파일만 대상 (게임 코드 변경만). .md/.json/.py 변경은 자체 검증 없음. +if not file_path or not file_path.endswith(".cs"): + print(json.dumps({"suppressOutput": True})) + sys.exit(0) + +# 변경 라인 수 계산 +trigger = False +reason = "" + +if tool_name == "Write": + # 신규 파일 또는 전체 덮어쓰기 — 무조건 권유 + content = tool_input.get("content", "") or "" + lines = content.count("\n") + if lines >= 20: + trigger = True + reason = f"신규/전체 작성 ({lines}줄)" +elif tool_name == "Edit": + old_s = tool_input.get("old_string", "") or "" + new_s = tool_input.get("new_string", "") or "" + # 추가/삭제된 총 라인 수의 근사치 + line_diff = abs(new_s.count("\n") - old_s.count("\n")) + max( + old_s.count("\n"), new_s.count("\n") + ) + if tool_input.get("replace_all"): + # replace_all은 영향 범위가 커서 임계값 낮춤 + threshold = 10 + else: + threshold = 50 + if line_diff >= threshold: + trigger = True + reason = f"{line_diff}줄 변경 (임계 {threshold})" + +if not trigger: + print(json.dumps({"suppressOutput": True})) + sys.exit(0) + +# 변경 파일 경로 short (가독성) +short = file_path +try: + root = os.environ.get("CLAUDE_PROJECT_DIR") or os.getcwd() + short = os.path.relpath(file_path, root).replace("\\", "/") +except Exception: + pass + +ctx = ( + f"AUTO-VERIFY: 큰 변경 감지 — {short} | {reason}\n" + "다음 8항목 중 변경에 해당하는 것만 점검 후 결과를 사용자에게 보고:\n" + "1. 이벤트 구독: OnEnable/AutoWire/OnDisable 짝, 람다 해제 누락\n" + "2. null 가드: FindFirstObjectByType/GetComponent/Singleton.Instance 즉시 사용\n" + "3. 성능: Update/OnGUI 안 new GUIStyle/배열, GameObject.Find 캐싱\n" + "4. Bootstrap: 새 MonoBehaviour 등록, AutoWire 누락\n" + "5. 세이브 호환: 새 필드 기본값, CloudSaveManager 동기화\n" + "6. 모놀리스: BattleScreenUI/RaidBattleUI/PlaySceneBootstrap 최소 변경 확인\n" + "7. 컨벤션: [SerializeField] private, InsectGame.* 네임스페이스\n" + "8. 데이터 매칭: ID contains 순서 (구체적 먼저), enum↔switch 1:1\n" + "거짓양성 보고 금지 — 직전 라운드 처리 영역 자동 제외." +) + +print(json.dumps({ + "hookSpecificOutput": { + "hookEventName": "PostToolUse", + "additionalContext": ctx + } +})) diff --git a/.claude/hooks/check_namespace.py b/.claude/hooks/check_namespace.py index 5b86c9f..ecda3c9 100644 --- a/.claude/hooks/check_namespace.py +++ b/.claude/hooks/check_namespace.py @@ -1,13 +1,41 @@ """PostToolUse hook: C# 네임스페이스 검증""" -import sys, json, re +import sys, json, re, os, io + +# Windows cp949 stdin/stdout이 한글 경로를 mojibake로 만들어 Failed to verify 경고가 매번 +# 출력되는 문제 차단. claude harness가 JSON을 UTF-8로 보내므로 강제. +try: + sys.stdin = io.TextIOWrapper(sys.stdin.buffer, encoding='utf-8', errors='replace') + sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace') +except Exception: + pass raw = sys.stdin.read() -# Windows 역슬래시 경로를 슬래시로 치환하여 JSON 파싱 오류 방지 -raw = raw.replace("\\", "/") -d = json.loads(raw) +# 주의: raw.replace("\\", "/")는 JSON escape (\n, \uXXXX, \\) 모두 망가뜨려 라인 카운트/문자열 +# 파싱 오류 유발. json.loads는 표준 escape를 정상 처리하므로 치환 불필요. +try: + d = json.loads(raw) +except Exception: + print(json.dumps({"suppressOutput": True})) + sys.exit(0) +# 최상위가 dict가 아니거나(null/배열) tool_input이 명시적 null이면 조용히 나간다. +# d.get("k", {})는 키가 **없을 때만** {}를 주지 실제 null에는 null을 준다. +if not isinstance(d, dict): + print(json.dumps({"suppressOutput": True})) + sys.exit(0) +if not isinstance(d.get("tool_input"), dict): + d["tool_input"] = {} +if not isinstance(d.get("tool_response"), dict): + d["tool_response"] = {} + + f = d.get("tool_response", {}).get("filePath", "") or d.get("tool_input", {}).get("file_path", "") -if not f.endswith(".cs"): +if not f or not f.endswith(".cs"): + print(json.dumps({"suppressOutput": True})) + sys.exit(0) + +# 파일 존재 검증: 경로가 mojibake로 깨졌으면 실제로 못 열리므로 침묵 (잘못된 경고 차단). +if not os.path.exists(f): print(json.dumps({"suppressOutput": True})) sys.exit(0) @@ -35,9 +63,5 @@ } })) except Exception as e: - print(json.dumps({ - "hookSpecificOutput": { - "hookEventName": "PostToolUse", - "additionalContext": f"WARNING: Failed to verify namespace in {f}: {e}" - } - })) + # 깨진 경로/권한 등 어떤 이유로든 실패하면 침묵 (mojibake 경고 노이즈 차단). + print(json.dumps({"suppressOutput": True})) diff --git a/.claude/hooks/quick_compile_check.py b/.claude/hooks/quick_compile_check.py new file mode 100644 index 0000000..1fab272 --- /dev/null +++ b/.claude/hooks/quick_compile_check.py @@ -0,0 +1,271 @@ +"""PostToolUse hook: 변경된 .cs 파일에 대한 regex 기반 빠른 컴파일 점검. + +Unity Editor 호출 없이 정적 패턴으로 흔한 컴파일 에러를 감지한다. 100% 정확하지 않지만 +빠른 피드백 제공. 직전 라운드 `subAreaRespawnTimer` 미선언 사용 같은 케이스를 잡는다. + +점검 항목: +1. 중괄호 매칭 (열림 vs 닫힘 카운트) +2. 미선언 식별자 사용 의심 (private/[SerializeField]/public 선언 없이 사용) +3. using 누락 (IEnumerator/List/StringBuilder 등 사용 시) + +의심이 명확하지 않으면 침묵. 노이즈 방지.""" + +import sys, json, re, os, io + +try: + sys.stdin = io.TextIOWrapper(sys.stdin.buffer, encoding='utf-8', errors='replace') + sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace') +except Exception: + pass + +# 공용 C# 전처리기. 이 파일은 .codex/hooks/로도 무변환 복사되므로 __file__ 기준 상대경로가 +# 양쪽에서 성립해야 한다 — .claude/hooks/ 와 .codex/hooks/ 둘 다 루트 2단계 아래다. +sys.path.insert( + 0, + os.path.join( + os.environ.get("CLAUDE_PROJECT_DIR") + or os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), + ".claude", "scripts", + ), +) +try: + from cs_strip import strip_cs +except ImportError: + # 전처리기를 못 찾으면 침묵한다. 원시 텍스트로 검사하면 주석·문자열 안의 중괄호까지 + # 세어 오탐이 쏟아진다 — 조용한 오탐보다 검사를 거르는 편이 낫다. + print(json.dumps({"suppressOutput": True})) + sys.exit(0) + +raw = sys.stdin.read() +try: + d = json.loads(raw) +except Exception: + print(json.dumps({"suppressOutput": True})) + sys.exit(0) +# 최상위가 dict가 아니거나(null/배열) tool_input이 명시적 null이면 조용히 나간다. +# d.get("k", {})는 키가 **없을 때만** {}를 주지 실제 null에는 null을 준다. +if not isinstance(d, dict): + print(json.dumps({"suppressOutput": True})) + sys.exit(0) +if not isinstance(d.get("tool_input"), dict): + d["tool_input"] = {} +if not isinstance(d.get("tool_response"), dict): + d["tool_response"] = {} + + +tool_input = d.get("tool_input", {}) +file_path = ( + d.get("tool_response", {}).get("filePath", "") + or tool_input.get("file_path", "") +) + +if not file_path or not file_path.endswith(".cs"): + print(json.dumps({"suppressOutput": True})) + sys.exit(0) + +if not os.path.exists(file_path): + # 깨진 경로/존재하지 않음 — 침묵 (mojibake 노이즈 방지) + print(json.dumps({"suppressOutput": True})) + sys.exit(0) + +warnings = [] + +# C# 타입 토큰: 내장 타입 또는 대문자로 시작하는 사용자 타입(제네릭·배열 포함). +TYPE_TOKEN = ( + r"\b(?:var|int|uint|long|ulong|short|ushort|byte|sbyte|float|double|decimal|" + r"bool|string|char|object|" + r"[A-Z]\w*(?:<[^<>()]*>)?(?:\[\])?)" +) + +try: + with open(file_path, "r", encoding="utf-8") as fh: + text = fh.read() +except Exception: + print(json.dumps({"suppressOutput": True})) + sys.exit(0) + +# 1. 중괄호 매칭 — 문자열/주석/char literal 제외 후 카운트 (false positive 차단) +# 예전엔 여기서 주석을 문자열보다 먼저 지웠다. "https://..."의 //가 주석으로 오인돼 +# 닫는 따옴표가 사라지고 뒤이은 문자열 규칙이 진짜 코드를 중괄호째 삼켰다. +# cs_strip이 단일 패스로 처리한다 — 먼저 열리는 쪽이 이긴다. +cleaned = strip_cs(text) + +opens = cleaned.count("{") +closes = cleaned.count("}") +diff = abs(opens - closes) +if diff > 2: + warnings.append( + f"중괄호 불균형: {{ {opens}개 vs }} {closes}개 (차이 {diff}, 주석/문자열 제외 후)" + ) + +# 2. 미선언 식별자 사용 의심 +# 클래스 본문 안의 필드 선언 패턴 수집 +declared = set() +# private/protected/public/internal + type + name 패턴 (배열, generic 포함) +field_re = re.compile( + r"^\s*(?:\[[^\]]+\]\s*)*" # attribute (e.g., [SerializeField]) + r"(?:public|private|protected|internal|static|readonly|const|" + r"new|virtual|override|abstract|sealed|partial|extern|unsafe|volatile|\s)+" + r"[\w<>\[\],\s\.\?]+?\s+" # type + r"(\w+)\s*[=;\(]", # name followed by =, ;, or ( (method) + re.MULTILINE +) +# 모든 매칭 — 메서드 이름도 포함되지만 무해 (사용 시 declared 안에 있으면 OK) +for m in field_re.finditer(text): + declared.add(m.group(1)) + +# 로컬 변수 선언도 포함 (var x, int y, string s 등) +local_re = re.compile( + r"\b(?:var|int|float|double|bool|string|long|short|byte|char|" + r"void|object|decimal|uint|ulong|ushort|sbyte|" + r"[A-Z]\w*(?:<[\w<>,\s\.\?]+>)?(?:\[\])?)\s+" + r"(\w+)\s*[=;)]" +) +for m in local_re.finditer(text): + declared.add(m.group(1)) + +# foreach 변수 +foreach_re = re.compile(r"\bforeach\s*\(\s*\S+\s+(\w+)\s+in\b") +for m in foreach_re.finditer(text): + declared.add(m.group(1)) + +# 파라미터 (대부분의 메서드 시그니처에서 잡힘) +param_re = re.compile(r"[,(]\s*(?:ref\s+|out\s+|in\s+|params\s+|this\s+)?\S+\s+(\w+)\s*[,)=]") +for m in param_re.finditer(text): + declared.add(m.group(1)) + +# C# 키워드/내장 식별자/매우 흔한 Unity API는 화이트리스트 (검증 면제) +WHITELIST = { + "true", "false", "null", "this", "base", "value", "var", + "Time", "Mathf", "Vector2", "Vector3", "Vector4", "Quaternion", "Color", + "GameObject", "Component", "Transform", "Camera", "Light", "Material", + "Debug", "Input", "Screen", "Application", "Resources", "Object", + "MonoBehaviour", "ScriptableObject", "PlayerPrefs", "Rect", "Random", + "Texture", "Texture2D", "TextureFormat", "Shader", "GUI", "GUIStyle", + "FontStyle", "TextAnchor", "Event", "EventType", "KeyCode", + "Coroutine", "WaitForSeconds", "WaitForEndOfFrame", "WaitUntil", + "Physics", "Collider", "Rigidbody", "MeshRenderer", "MeshFilter", + "AudioSource", "AudioClip", "Animator", "Animation", + "System", "UnityEngine", "Math", "Convert", "Activator", + "Mathf", "DateTime", "TimeSpan", "Guid", + "Encoding", "File", "Directory", "Path", "FileStream", "StreamReader", + "StreamWriter", "BinaryReader", "BinaryWriter", + # 흔한 namespace + "InsectGame", "Core", "Data", "Battle", "Capture", "Dex", "Spawning", "UI", +} + +# 식별자 사용 검사: 메서드 본문 안에서 increment/decrement 좌변 식별자 추출. +# `(?= 2)는 쓰지 않는다 — `timer += x`처럼 실제 미선언 + # 변수도 초기화·증감으로 2회 이상 등장하는 게 보통이라 탐지력이 0이 됐었다. + # (declared 패턴이 못 잡는 List/Dictionary 등은 아래 제네릭 + # 분기가 커버한다.) + if re.compile(TYPE_TOKEN + r"\s+" + re.escape(name) + r"\b\s*(?:=|;|,|\)|\bin\b)").search(cleaned): + continue + # 다중 선언자: `int discovered = 0, captured = 0;`의 둘째 이후는 앞에 타입이 아니라 + # 콤마가 온다. 위 패턴만으로는 captured를 미선언으로 오탐한다(DexScreenUI.cs, + # RegionMapUI.cs 등 8개 파일에서 실제 발화했다). 선언문 안에 있는지로 판정한다. + # `[^;\n]*`가 세미콜론·줄바꿈을 못 넘으므로 다른 문장까지 번지지 않고, + # name 뒤에 =/,/; 를 요구하므로 `Foo(captured)` 같은 단순 사용은 걸리지 않는다. + if re.compile( + r"^[^\S\n]*(?:\[[^\]]+\][^\S\n]*)*" + r"(?:(?:public|private|protected|internal|static|readonly|const)\s+)*" + + TYPE_TOKEN + r"\s+\w+[^;\n]*\b" + re.escape(name) + r"\b\s*(?:=|,|;)", + re.MULTILINE, + ).search(cleaned): + continue + suspicious.append(name) + +if suspicious: + # 최대 3개만 보고 (노이즈 방지) + sample = sorted(suspicious)[:3] + warnings.append( + f"미선언 의심 식별자 사용: {', '.join(sample)} " + f"(증감 연산자 좌변인데 클래스 필드 선언이 없는 듯 — 컴파일 에러 가능성)" + ) + +# 3. using 누락 의심 +needs_using = { + "IEnumerator": "System.Collections", + "IEnumerable": "System.Collections", + "List<": "System.Collections.Generic", + "Dictionary<": "System.Collections.Generic", + "HashSet<": "System.Collections.Generic", + "StringBuilder": "System.Text", + "Encoding": "System.Text", + "Stopwatch": "System.Diagnostics", + "File.": "System.IO", + "Path.": "System.IO", + "Directory.": "System.IO", + "Action<": "System", + "Action ": "System", + "Func<": "System", + "Task<": "System.Threading.Tasks", + "UnityWebRequest": "UnityEngine.Networking", + "Image ": "UnityEngine.UI", + "Button ": "UnityEngine.UI", + "Text ": "UnityEngine.UI", +} +# using 선언 추출 +using_re = re.compile(r"^\s*using\s+([\w.]+)\s*;", re.MULTILINE) +usings = set(using_re.findall(text)) + +for token, ns in needs_using.items(): + # 확실한 케이스만 본다. 나머지는 오탐 위험 대비 이득이 없다. + if token not in ("IEnumerator", "List<", "Dictionary<", "HashSet<"): + continue + # using이 부분 매치 (System.Collections.Generic ⊃ System.Collections) 허용 + if any(u.startswith(ns) for u in usings): + continue + # cleaned를 본다 — 원시 text를 보면 주석·문자열 안의 토큰까지 센다. + # 앞에 `.`이 붙은 완전 수식(System.Collections.Generic.List<>)은 using이 필요 없다. + # 그걸 무시해서 RegionTerrainBuilder.cs·CharacterOutfitUI.cs가 오탐됐었다. + if not re.search(r"(?()` / `GetComponent()` (캐싱 누락) +3. `EventName += Handler` 후 같은 클래스에 `EventName -= Handler` 짝 없음 +4. 4000줄+ 모놀리스의 편집 영역 — method 분해 후보 제안 + +quick_compile_check.py와 동일 패턴 (UTF-8, JSON I/O, false positive 가드). +""" + +import sys, json, re, os, io + +try: + sys.stdin = io.TextIOWrapper(sys.stdin.buffer, encoding='utf-8', errors='replace') + sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace') +except Exception: + pass + +# 공용 C# 전처리기. 이 파일은 .codex/hooks/로도 무변환 복사되므로 __file__ 기준 상대경로가 +# 양쪽에서 성립해야 한다 — .claude/hooks/ 와 .codex/hooks/ 둘 다 루트 2단계 아래다. +sys.path.insert( + 0, + os.path.join( + os.environ.get("CLAUDE_PROJECT_DIR") + or os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), + ".claude", "scripts", + ), +) +try: + from cs_strip import strip_cs +except ImportError: + # 전처리기를 못 찾으면 침묵. 원시 텍스트로 스캔하면 주석·문자열 안 패턴까지 잡는다. + print(json.dumps({"suppressOutput": True})) + sys.exit(0) + +raw = sys.stdin.read() +try: + d = json.loads(raw) +except Exception: + print(json.dumps({"suppressOutput": True})) + sys.exit(0) +# 최상위가 dict가 아니거나(null/배열) tool_input이 명시적 null이면 조용히 나간다. +# d.get("k", {})는 키가 **없을 때만** {}를 주지 실제 null에는 null을 준다. +if not isinstance(d, dict): + print(json.dumps({"suppressOutput": True})) + sys.exit(0) +if not isinstance(d.get("tool_input"), dict): + d["tool_input"] = {} +if not isinstance(d.get("tool_response"), dict): + d["tool_response"] = {} + + +tool_input = d.get("tool_input", {}) +file_path = ( + d.get("tool_response", {}).get("filePath", "") + or tool_input.get("file_path", "") +) + +if not file_path or not file_path.endswith(".cs"): + print(json.dumps({"suppressOutput": True})) + sys.exit(0) + +if not os.path.exists(file_path): + print(json.dumps({"suppressOutput": True})) + sys.exit(0) + +try: + with open(file_path, "r", encoding="utf-8") as fh: + text = fh.read() +except Exception: + print(json.dumps({"suppressOutput": True})) + sys.exit(0) + +# 변경 영역 (new_string)을 추출 — false positive 차단 (파일 다른 곳의 패턴은 보고 안 함) +changed_text = tool_input.get("new_string", "") or tool_input.get("content", "") + +warnings = [] + +# 주석/문자열 제거는 cs_strip이 소유한다. 예전엔 여기 사본이 있었고 주석을 문자열보다 +# 먼저 지웠다 — "https://..."의 //가 주석으로 오인돼 닫는 따옴표가 사라지고, 뒤이은 +# 문자열 규칙이 진짜 코드를 중괄호째 삼켰다. 그러면 extract_method_body의 중괄호 매칭이 +# 어긋나 엉뚱한 메서드까지 "OnGUI 본문"으로 잡힌다(CharacterOutfitUI에서 InitStyles의 +# new GUIStyle이 OnGUI 것으로 오보고됐다). +strip_comments_and_strings = strip_cs + +# 메서드 본문 영역 추출 — `private void OnGUI()` ~ 매칭 닫는 `}` +def extract_method_body(text_cleaned, method_name_pattern): + """method_name_pattern: 예 r'\\bOnGUI\\b' — 클린된 텍스트에서 매칭. 본문 문자열 반환.""" + matches = [] + for m in re.finditer(method_name_pattern + r"\s*\([^)]*\)", text_cleaned): + start = m.end() + # 다음 { 찾기 + brace_start = text_cleaned.find("{", start) + if brace_start < 0: + continue + # 중첩 brace 매칭 + depth = 1 + i = brace_start + 1 + while i < len(text_cleaned) and depth > 0: + c = text_cleaned[i] + if c == "{": + depth += 1 + elif c == "}": + depth -= 1 + i += 1 + if depth == 0: + matches.append(text_cleaned[brace_start + 1: i - 1]) + return matches + +cleaned_full = strip_comments_and_strings(text) +cleaned_changed = strip_comments_and_strings(changed_text) + +# ── 1. OnGUI/Update 안 new Color/new Rect/new GUIStyle 매 프레임 할당 ── +# changed_text 안에 OnGUI 또는 Update 메서드가 있으면 그 본문에서 패턴 검색. +# 또는 changed_text 자체가 OnGUI 본문의 일부일 수도 있으니 전체 파일의 OnGUI/Update 본문도 검사. + +# 매 프레임 **힙** 할당(= GC 압박)만 보고한다. +# +# new Color / new Rect / new Vector3 / new Quaternion 은 전부 struct라 스택에 잡히고 +# GC를 유발하지 않는다. 옛 목록엔 Color와 Rect가 있었는데, audit 라운드들이 그걸 +# 매번 "struct라 거짓양성"으로 기각해왔다(2026-07-17까지 6라운드 연속). 훅이 이미 +# 알려진 오탐을 반복 보고하면 진짜 경고까지 무시당하므로 목록에서 뺀다. +# +# 아래는 전부 class다 — 매 프레임 생성하면 실제로 GC가 돈다. +# 한계: 문자열 보간 $"..."도 진짜 힙 할당이지만 cleaned_full이 문자열 리터럴을 +# 통째로 지우므로 이 방식으로는 검출할 수 없다(라운드에서 사람이 잡아야 함). +perf_patterns = [ + (r"\bnew\s+GUIStyle\s*\(", "new GUIStyle"), + (r"\bnew\s+GUIContent\s*\(", "new GUIContent"), + (r"\bnew\s+Texture2D\s*\(", "new Texture2D"), + (r"\bnew\s+Material\s*\(", "new Material"), + (r"\bnew\s+(?:List|Dictionary|HashSet|Queue|Stack)\s*<", "new 컬렉션"), +] + +# changed_text가 OnGUI/Update 메서드 본문 안에 위치하는지 추정. +# 정확한 위치 매칭 어렵지만 changed_text 안에 OnGUI/Update 시그니처가 포함되면 +# 해당 method 본문이 새로 추가됐을 가능성 큼. +# 또는 changed_text가 method 시그니처 없이 본문 일부만 변경 → 전체 파일 method 본문에서 검사. + +frame_methods = ["OnGUI", "Update", "LateUpdate", "FixedUpdate"] + + +def null_guard_spans(body): + """매 프레임 실행되지 않는 구간 목록 — 여기 있는 할당/조회는 경고 대상이 아니다. + + 두 종류를 덮는다: + + 1. `if (x == null) { ... }` — lazy 캐싱. 이 훅이 **권장하는 바로 그 패턴**인데 + 정작 그 안의 `new GUIStyle`을 "매 프레임 할당"으로 경고하고 있었다. 프레임 메서드 + 안 매칭 30건 중 21건(70%)이 이 가드 뒤였다 — RegionManager는 주석에 "매 프레임 + GameObject.Find 회귀 차단"이라 써둔 자리에서 발화했다. 가드 안은 첫 호출 1회만 돈다. + + 2. `if (GUI.Button(...)) { ... }` — IMGUI 클릭 핸들러. OnGUI 안에 있지만 **클릭한 + 프레임에만** 실행된다. (지금은 삭제된) CharacterViewerUI가 버튼 콜백에서 + FindFirstObjectByType을 부르는 걸 매 프레임 조회로 오보고했다. + GUILayout.Button / RepeatButton도 같다. + + 3. `UIHelper.CachedStyle("key", () => { ... })` — 캐시 팩토리 람다. UIHelper.cs의 + CachedStyle은 캐시 미스일 때만 factory()를 부르므로 키당 1회 실행이다. 이 훅이 + 경고 문구에서 "UIHelper.CachedStyle 패턴 참고"라고 **권장하면서** 정작 그 사용을 + 매 프레임 할당으로 신고하고 있었다(CharacterOutfitUI). + + 4. `#if DEVELOPMENT_BUILD || UNITY_EDITOR` — 출시 빌드에서 스트립되는 진단 코드. + 이 훅은 **출시 성능 회귀**를 잡으려고 있으므로 배포되지 않는 코드는 대상이 아니다. + InsectSpawner의 OnGUI는 통째로 이 안에 있는 렌더 진단 오버레이다. + (`Assets/Editor/`의 에디터 전용 스크립트와는 별개 얘기다 — 여기선 런타임 스크립트 + 안에 조건부로 낀 진단 블록만 뺀다.) + """ + guard_re = re.compile( + r"==\s*null|(?:GUI|GUILayout)\.(?:Button|RepeatButton)\s*\(" + ) + spans = [] + for m in re.finditer(r"\bif\s*\(", body): + # 조건절은 중첩 괄호를 가질 수 있다: if (GUI.Button(new Rect(...), "x")). + # 깊이로 읽어야 닫는 괄호를 정확히 찾는다. + i, depth = m.end(), 1 + while i < len(body) and depth: + if body[i] == "(": + depth += 1 + elif body[i] == ")": + depth -= 1 + i += 1 + if depth: + continue + if not guard_re.search(body[m.end():i - 1]): + continue + j = i + while j < len(body) and body[j].isspace(): + j += 1 + if j < len(body) and body[j] == "{": + d, k = 0, j + while k < len(body): + if body[k] == "{": + d += 1 + elif body[k] == "}": + d -= 1 + if d == 0: + break + k += 1 + spans.append((m.start(), min(k + 1, len(body)))) + else: + k = body.find(";", j) + spans.append((m.start(), (k + 1) if k >= 0 else len(body))) + # `cache ??= new GUIStyle(...)` — lazy 캐싱의 축약형. 문장 끝까지 가드로 본다. + for m in re.finditer(r"\?\?=", body): + k = body.find(";", m.end()) + spans.append((m.start(), (k + 1) if k >= 0 else len(body))) + # `UIHelper.CachedStyle("key", () => { ... })` — 팩토리 람다는 캐시 미스 때만 돈다. + # `() =>` 부터 그 블록의 닫는 중괄호까지가 가드 구간. + for m in re.finditer(r"Cached\w*\s*\([^;{]*?\(\s*\)\s*=>\s*\{", body): + d, k = 0, body.rfind("{", m.start(), m.end()) + while k < len(body): + if body[k] == "{": + d += 1 + elif body[k] == "}": + d -= 1 + if d == 0: + break + k += 1 + spans.append((m.start(), min(k + 1, len(body)))) + # `#if DEVELOPMENT_BUILD || UNITY_EDITOR ... #endif` — 출시 빌드에서 스트립되는 진단 코드. + for m in re.finditer(r"^[^\S\n]*#if\b[^\n]*\b(?:DEVELOPMENT_BUILD|UNITY_EDITOR)\b", body, re.M): + e = re.search(r"^[^\S\n]*#endif\b", body[m.end():], re.M) + spans.append((m.start(), m.end() + e.end() if e else len(body))) + return spans + + +def unguarded_hit(pat, body): + """가드 밖에서 pat이 매칭되면 True. 가드 안에만 있으면 False.""" + spans = null_guard_spans(body) + return any( + not any(s <= m.start() < e for s, e in spans) for m in re.finditer(pat, body) + ) + + +# 전략: 전체 파일의 frame method 본문을 추출 후, 그 본문에 changed_text가 포함되거나 일부 겹치면 검사 +# 단순화: 전체 파일의 frame method 본문에서 패턴 검색하되, changed_text에도 같은 패턴이 있어야 보고 +perf_warnings_set = set() +for method in frame_methods: + bodies = extract_method_body(cleaned_full, r"\b" + method + r"\b") + for body in bodies: + for pat, label in perf_patterns: + if unguarded_hit(pat, body): + # changed_text에도 같은 패턴이 있는지 확인 (false positive 차단) + if unguarded_hit(pat, cleaned_changed): + perf_warnings_set.add((method, label)) + +for method, label in sorted(perf_warnings_set): + warnings.append( + f"{method}() 안 매 프레임 `{label}(...)` 할당 — 필드 캐싱 권장 " + f"(UIHelper.CachedStyle 또는 static readonly 패턴 참고)" + ) + +# ── 2. OnGUI/Update 안 FindFirstObjectByType/GetComponent ── +# GetComponent가 캐싱되지 않고 매 프레임 호출되면 비싼 작업. +# 단, GetComponent(...)는 Awake/Start에서 1회 호출이 정상 패턴. frame method 본문에서만 검사. + +lookup_patterns = [ + (r"\bFindFirstObjectByType\s*<", "FindFirstObjectByType"), + (r"\bFindObjectOfType\s*<", "FindObjectOfType"), + (r"\bGameObject\.Find\s*\(", "GameObject.Find"), + # GetComponent는 frame method에 진짜 캐싱 안 한 경우만 — 너무 많은 false positive 우려로 일단 제외 +] + +lookup_warnings_set = set() +for method in frame_methods: + bodies = extract_method_body(cleaned_full, r"\b" + method + r"\b") + for body in bodies: + for pat, label in lookup_patterns: + # 할당 패턴과 같은 이유로 null 가드 안은 제외 — 그게 권장하는 캐싱이다. + if unguarded_hit(pat, body) and unguarded_hit(pat, cleaned_changed): + lookup_warnings_set.add((method, label)) + +for method, label in sorted(lookup_warnings_set): + warnings.append( + f"{method}() 안 `{label}(...)` — 캐싱 권장 (AutoWire 또는 Awake/Start 1회 호출 패턴)" + ) + +# ── 3. 이벤트 += 후 -= 짝 누락 ── +# changed_text 안에 `EventName += HandlerName` 발견 시 전체 파일에서 `EventName -= HandlerName` 검색. +# 람다 식 `EventName += () =>` 또는 `EventName += (x) =>`는 해제 불가 — 별도 경고. + +subscribe_re = re.compile( + r"(\w+(?:\.\w+)*)\s*\+=\s*(?:\([^)]*\)\s*=>|\w+(?:\.\w+)*)" +) +lambda_subscribe_re = re.compile( + r"(\w+(?:\.\w+)*)\s*\+=\s*\([^)]*\)\s*=>" +) +method_subscribe_re = re.compile( + r"(\w+(?:\.\w+)*)\s*\+=\s*(\w+(?:\.\w+)*)\s*;" +) + +# changed_text 안 + 패턴 +for m in method_subscribe_re.finditer(cleaned_changed): + event_name = m.group(1) + handler_name = m.group(2) + # 매우 흔한 false positive 화이트리스트 (단일 문자 변수) + if event_name in ("a", "b", "x", "y", "i", "j", "n"): + continue + # 좌변 first segment가 camelCase 시작이면 산술 누적으로 간주 (event는 PascalCase 컨벤션). + # 예: `totalDmg += actual;`, `count += 1;`은 이벤트 아님. + # `obj.EventName += ...`처럼 점 접근이면 마지막 segment가 PascalCase여야 이벤트. + last_segment = event_name.split(".")[-1] + if not last_segment[0].isupper(): + continue + # 우변 handler가 PascalCase 메서드 이름이어야 이벤트 핸들러 패턴 + last_handler = handler_name.split(".")[-1] + if not last_handler[0].isupper(): + continue + # 짝 -= 검색 (전체 파일). + # 수신자까지 통째로 매칭하면 안 된다 — 구독과 해제의 수신자 표기가 다른 게 정상이다. + # 예: PlayerMovement는 `mgr.OutfitChanged += X`로 구독하고 + # `CharacterOutfitManager.Instance.OutfitChanged -= X`로 해제한다. + # 둘은 같은 이벤트인데 문자열이 달라 전수 3건이 전부 오탐이었다. + # 이벤트·핸들러 모두 마지막 세그먼트로 비교한다. + unsub_pat = ( + r"(?:^|[^\w.])(?:[\w\.\[\]\(\)]+\.)?" + + re.escape(last_segment) + + r"\s*-=\s*(?:[\w\.]+\.)?" + + re.escape(last_handler) + + r"\b" + ) + if not re.search(unsub_pat, cleaned_full, re.MULTILINE): + warnings.append( + f"이벤트 구독 `{event_name} += {handler_name}` 짝 `-=` 누락 — " + f"OnDisable/OnDestroy에서 해제 권장 (메모리 누수 + 죽은 ref 호출 방지)" + ) + break # 1건만 보고 + +# 람다 구독 (해제 불가) +for m in lambda_subscribe_re.finditer(cleaned_changed): + event_name = m.group(1) + if event_name in ("a", "b", "x", "y", "i", "j", "n"): + continue + warnings.append( + f"람다 이벤트 구독 `{event_name} += (...) => ...` — 해제 불가. " + f"메서드 참조로 변경 후 OnDisable에서 -=로 해제 권장" + ) + break + +# ── 4. 모놀리스 method 분해 제안 ── +# 2500줄+ 파일에서 편집된 method 본문이 200줄+이면 분해 후보로 제안. +# 임계값 2500은 모놀리스 3종(RaidBattleUI/BattleScreenUI/PlaySceneBootstrap)을 +# 모두 포함하고 4위 파일(~2000줄)은 제외하는 경계다. 옛 4000은 Bootstrap +# 하나만 걸러 warn_monolith가 규정한 3종과 어긋나 있었다. +line_count = text.count("\n") +if line_count > 2500: + # 시그니처 탐색과 중괄호 깊이 계산 모두 cleaned_full(주석·문자열 제거본)로 한다. + # raw text로 하면 주석이나 $"{value}" 보간 문자열 안의 { } 가 깊이를 깨뜨리고, + # 주석 속 유령 메서드 시그니처까지 매칭된다. + method_sig_re = re.compile( + r"(?:public|private|protected|internal|static|\s)+(?:void|IEnumerator|[\w<>]+)\s+" + r"(\w+)\s*\([^)]*\)\s*\{" + ) + long_methods = [] + for m in method_sig_re.finditer(cleaned_full): + method_name = m.group(1) + if method_name not in changed_text: + continue # 편집과 무관한 메서드는 비싼 중괄호 탐색 전에 거른다 + start = m.end() + depth = 1 + i = start + while i < len(cleaned_full) and depth > 0: + c = cleaned_full[i] + if c == "{": + depth += 1 + elif c == "}": + depth -= 1 + i += 1 + body_lines = cleaned_full[start:i].count("\n") + if body_lines > 200: + long_methods.append((method_name, body_lines)) + + if long_methods: + sample = sorted(long_methods, key=lambda x: -x[1])[:2] + names = ", ".join(f"{n}({l}줄)" for n, l in sample) + warnings.append( + f"모놀리스 {line_count}줄 — 편집한 메서드 {names}은 별도 파일/클래스로 추출 후보. " + f"Phase enum 또는 기능별 grouping 검토" + ) + +if not warnings: + print(json.dumps({"suppressOutput": True})) + sys.exit(0) + +short = file_path +try: + root = os.environ.get("CLAUDE_PROJECT_DIR") or os.getcwd() + short = os.path.relpath(file_path, root).replace("\\", "/") +except Exception: + pass + +ctx = ( + f"STATIC-SCAN: {short}\n" + + "\n".join(f" - {w}" for w in warnings) + + "\n(정적 정규식 점검 — 오탐 가능. 검토 후 진행.)" +) + +print(json.dumps({ + "hookSpecificOutput": { + "hookEventName": "PostToolUse", + "additionalContext": ctx + } +})) diff --git a/.claude/hooks/warn_monolith.py b/.claude/hooks/warn_monolith.py index f272aec..b2fb6f2 100644 --- a/.claude/hooks/warn_monolith.py +++ b/.claude/hooks/warn_monolith.py @@ -1,28 +1,93 @@ -"""PreToolUse hook: 모놀리스 파일 수정 경고""" -import sys, json +"""PreToolUse hook: 모놀리스 파일 수정 경고 (변경 크기 조건부)""" +import sys, json, io + +try: + sys.stdin = io.TextIOWrapper(sys.stdin.buffer, encoding='utf-8', errors='replace') + sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace') +except Exception: + pass raw = sys.stdin.read() -raw = raw.replace("\\", "/") -d = json.loads(raw) -f = d.get("tool_input", {}).get("file_path", "") +try: + d = json.loads(raw) +except Exception: + print(json.dumps({"suppressOutput": True})) + sys.exit(0) +# 최상위가 dict가 아니거나(null/배열) tool_input이 명시적 null이면 조용히 나간다. +# d.get("k", {})는 키가 **없을 때만** {}를 주지 실제 null에는 null을 준다. +if not isinstance(d, dict): + print(json.dumps({"suppressOutput": True})) + sys.exit(0) +if not isinstance(d.get("tool_input"), dict): + d["tool_input"] = {} +if not isinstance(d.get("tool_response"), dict): + d["tool_response"] = {} + + +tool_name = d.get("tool_name", "") +tool_input = d.get("tool_input", {}) +f = tool_input.get("file_path", "") +# file_path가 없으면 조용히 나간다. 7개 훅 중 여기만 가드가 없어 +# f.endswith()가 None/비문자열에서 터질 수 있었다(check_namespace엔 있다). +if not f or not isinstance(f, str): + print(json.dumps({"suppressOutput": True})) + sys.exit(0) + +# 줄 수는 하드코딩하지 않는다 — 파일이 자라면 반드시 stale해지고, 그 숫자가 +# 다시 문서로 복사돼 퍼진다. 실제 값은 아래에서 런타임에 센다. MONOLITHS = { - "PlaySceneBootstrap.cs": "4987줄 Bootstrap 모놀리스", - "BattleScreenUI.cs": "2950줄 배틀UI 모놀리스", - "RaidBattleUI.cs": "2875줄 레이드UI 모놀리스", + "PlaySceneBootstrap.cs": "Bootstrap 모놀리스", + "BattleScreenUI.cs": "배틀UI 모놀리스", + "RaidBattleUI.cs": "레이드UI 모놀리스", } +target_name = None +target_desc = None for name, desc in MONOLITHS.items(): if f.endswith(name): - print(json.dumps({ - "hookSpecificOutput": { - "hookEventName": "PreToolUse", - "additionalContext": ( - f"CAUTION: {name} ({desc}) 수정 중. " - "Phase별 영향 범위를 확인하고 최소한의 변경만 하세요." - ) - } - })) - sys.exit(0) - -print(json.dumps({"suppressOutput": True})) + target_name = name + target_desc = desc + break + +if not target_name: + print(json.dumps({"suppressOutput": True})) + sys.exit(0) + +# 변경 라인 수 계산: 작은 fix(<20줄)는 위험도 낮으므로 경고 스킵 +# Write tool: 전체 덮어쓰기라 항상 경고 +should_warn = True +if tool_name == "Edit": + old_s = tool_input.get("old_string", "") or "" + new_s = tool_input.get("new_string", "") or "" + line_diff = abs(new_s.count("\n") - old_s.count("\n")) + max( + old_s.count("\n"), new_s.count("\n") + ) + # replace_all 모드면 영향 더 크므로 임계값 낮춤 + if tool_input.get("replace_all"): + should_warn = line_diff >= 5 + else: + should_warn = line_diff >= 20 + +if not should_warn: + print(json.dumps({"suppressOutput": True})) + sys.exit(0) + +# 실제 줄 수를 여기서 센다. 이 훅이 모놀리스 줄 수의 단일 출처이며, +# CLAUDE.md/rules는 숫자를 갖지 않고 이 경고를 참조한다. +try: + with open(f, "r", encoding="utf-8", errors="replace") as fh: + loc = sum(1 for _ in fh) + target_desc = f"{loc}줄 {target_desc}" +except Exception: + pass + +print(json.dumps({ + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "additionalContext": ( + f"CAUTION: {target_name} ({target_desc}) 수정 중. " + "Phase별 영향 범위를 확인하고 최소한의 변경만 하세요." + ) + } +})) diff --git a/.claude/rules/agent-coordination.md b/.claude/rules/agent-coordination.md index ce61f93..30057f8 100644 --- a/.claude/rules/agent-coordination.md +++ b/.claude/rules/agent-coordination.md @@ -1,3 +1,7 @@ +--- +description: 공유 파일별 에이전트 수정 경계와 충돌 방지 절차 +--- + # 에이전트 간 조율 규칙 ## 공유 파일 수정 경계 @@ -10,13 +14,19 @@ | `BattleScreenUI.cs` | ui-dev | OnGUI 레이아웃, Rect 좌표, 색상, 화면 전환 | | | battle-dev | Phase 로직, 데미지 표시 계산, 턴 진행 | | | visual-dev | 쉐이크 효과, HP바 보간, 속성 이펙트 렌더링 | -| `RaidBattleUI.cs` | ui-dev | OnGUI 레이아웃, 팀 선택 패널, 결과 화면 | -| | battle-dev | 레이드 Phase 로직, 유나이트 게이지, 보스 턴 | -| | visual-dev | AOE 연출, 유나이트 이펙트, HP바 | +| `RaidBattleUI.cs` (상태기계 절반) | ui-dev | OnGUI 입력 디스패치, 페이즈별 그리기 분기 | +| | battle-dev | 레이드 Phase 전이, 유나이트 게이지, 보스 턴, 컨트롤러 이벤트 핸들러 | +| | visual-dev | **연출 타이밍 상수**(`UniteRushMinDuration`·`BossTelegraphDuration` 등) | +| `RaidBattleUI.Draw.cs` (렌더 절반) | ui-dev | 레이아웃, Rect 좌표, 팀 선택 패널, 결과 화면, GUIStyle 캐시 | +| | battle-dev | 데미지 표시 계산 | +| | visual-dev | AOE 연출, 유나이트 이펙트, HP바, 속성 임팩트 | | `BattleTeamUI.cs` | ui-dev | 슬롯 레이아웃, 드래그 상호작용 | | | battle-dev | 팀 유효성 검증, 전투력 표시 로직 | | `CaptureChoiceUI.cs` | ui-dev | 선택지 레이아웃, 키 안내 | | | capture-dev | 포획/배틀/레이드 분기 조건 로직 | +| `NpcDuelController.cs` | battle-dev | 듀얼 진입(StartDuel 호출), 승패 처리, 보상 표 | +| | capture-dev | 아이 상태·재도전 쿨다운, 상대 곤충 배정(EnsureDuelInsect) | +| | game-designer | 보상 아이템 종류·수량, 레벨 스프레드 | | `InsectEntity.cs` | capture-dev | 스폰/디스폰, 풀 관리, 월드 배치 | | | visual-dev | BuildModel() 프로시저럴 모델, 애니메이션, 샤이니 | | `BattleArenaController.cs` | battle-dev | 아레나 상태, 전투 환경 설정 | @@ -35,9 +45,57 @@ | | visual-dev | 색상값, 그라디언트 | | `RarityIconProvider.cs` | data-architect | 아이콘 매핑 데이터 | | | visual-dev | 아이콘 렌더링, 크기/위치 | +| `ItemData.cs` | data-architect | 필드 정의, SO 구조, 직렬화 | +| | game-designer | captureChanceBonus / expMultiplier / candyMultiplier 등 효과 매개변수 값 | +| `RegionData.cs` | data-architect | SO 필드 구조, 직렬화 | +| | game-designer | insectIds 풀, requiredLevel, guardianLevel 등 게임 디자인 수치 | +| `RegionManager.cs` | game-designer | GetNextRegionId/GetPreviousRegionId switch, 진행 로직, 가디언 격파 | +| | data-architect | unlockedRegions/defeatedGuardians PlayerPrefs 직렬화 | +| `RegionTerrainBuilder.cs` | visual-dev | BuildXxxTerrain() 프로시저럴 지형/오브젝트 디자인 | +| | game-designer | BuildAllRegions switch 분기 추가 (신규 리전 등록 트리거) | +| `CutsceneLibrary.cs` | game-designer | 어느 비트에 붙일지, 자막 문구 | +| | visual-dev | 카메라 좌표(camFrom/camTo/lookAt), 컷 길이·shake·dim | +| `CutsceneDirector.cs` | game-designer | 재생 시점(StoryBeatCompleted), 전투 지연 큐, 프리즈 복귀 | +| | visual-dev | 카메라 프레이밍 적용, 딤 강도 | +| | ui-dev | 자막 렌더(OnGUI), IModalUI 스택, 건너뛰기 버튼 | +| `NpcBossDuels.cs` | game-designer | 상대 곤충·레벨·보상 아이템·재도전 쿨다운 | +| | battle-dev | `isFinal`(보스 BGM 분기), 대결 진입 연동 | +| `PlayerVisualBuilder.cs` | visual-dev | BuildAll() 슬림 비례, BuildHair*, Accessory 노드 추가, Material/슈더 fallback | +| | data-architect | 8슬롯 ↔ 노드 매핑(Hat/Top/Outerwear 등), RefreshOutfitColors 흐름 | +| `SubAreaWorldBuilder.cs` | visual-dev | BuildCave/BuildDeepForest 등 프로시저럴 지형 + 조명 | +| | capture-dev | EnterSubArea/ExitSubArea/HideMainWorld 흐름, sticky 모드 연계, 25m 이탈 트리거 | +| `ModalUIRegistry.cs` | ui-dev | IModalUI 인터페이스, 스택 push/pop, HandleEscape 우선순위 | +| `RegionManager.cs` SubArea 처리 | capture-dev | subAreaSticky/쿨다운/RestoreLastSubArea 등 진입·이탈 동기화 | +| | game-designer | switch (region 진행 순서, 가디언) 분리 유지 | +| `CashShopManager.cs` | architect | gems 이중 관리 동기화(wallet AutoWire), pendingSave/즉시 클라우드 저장 트리거 | +| | game-designer | shopItems 가격/카테고리, 보석 패키지 구성 | +| `OutfitShapeLibrary.cs` | visual-dev | 파츠 좌표·스케일·회전·PrimitiveType, 색 역할 매핑, 레시피 추가 | +| | data-architect | OutfitRecipe/OutfitPart 스키마, OutfitAnchor enum 확장 | +| `CharacterOutfitManager.cs` | data-architect | 8슬롯 enum, allOutfits 정의, LoadOwnership/SaveEquipment 직렬화 | +| | game-designer | unlockedByDefault/price/gemPrice/statBonus 게임 디자인 수치 | +| | visual-dev | ApplyToCharacter / ApplyPartColor / ApplyToolShape 좌표·색상 적용 | ## 충돌 방지 절차 1. **단일 에이전트 원칙**: 하나의 공유 파일은 한 번에 하나의 에이전트만 수정 2. **경계 외 수정 필요 시**: 메인 모델이 해당 파일의 주담당 에이전트에게 위임 3. **교차 수정 감지**: 에이전트가 자신의 경계 밖 코드를 수정해야 할 때, 변경하지 말고 메인 모델에 보고하여 적절한 에이전트에 재위임 + +## 커버리지 점검 + +어느 에이전트도 담당하지 않는 `.cs`를 찾는다: + +``` +python -X utf8 .claude/scripts/verify_coverage.py +``` + +**이 문서와 `agents/*.md`가 매핑의 단일 출처다.** 스크립트는 둘을 파싱한다 — +위 표에서 (에이전트, 파일) 공유 경계를, `agents/*.md` 불릿에서 전체 소유권을 읽는다. +새 담당을 정했다면 **문서만** 고치면 된다. 스크립트에 사본은 없다. + +(예전엔 스크립트가 매핑을 144줄 dict로 하드코딩해 이 문서와 따로 놀았다. 표의 22개 +파일 중 8개가 어긋났고, `UIScale.cs`는 `ui-dev.md`가 담당으로 명시하는데도 "미할당"에 +거짓으로 올랐다.) + +파싱에 실패하면 스크립트는 exit 2로 죽는다. 빈 매핑으로 "전부 미할당"을 보고하지 +않기 위해서다 — 문서 구조를 바꿨다면 파서부터 확인할 것. diff --git a/.claude/rules/architecture.md b/.claude/rules/architecture.md index 8f3f2d5..bfcb4bf 100644 --- a/.claude/rules/architecture.md +++ b/.claude/rules/architecture.md @@ -1,11 +1,11 @@ --- -trigger: always +description: 모듈 의존성 방향, Bootstrap 등록 순서, 모놀리스 취급, 성능 가이드 --- # 아키텍처 규칙 ## Bootstrap 시스템 -- PlaySceneBootstrap가 65개 시스템을 순차 생성 +- PlaySceneBootstrap가 전체 시스템을 순차 생성 - 새 시스템 추가 시 반드시 Build() 메서드에 등록 - 초기화 순서 의존성 주의: Auth → Cloud → World → Data → Player → Battle → Capture → UI @@ -17,12 +17,13 @@ trigger: always - 모듈 간 통신은 이벤트(Action)로 ## 세이브 호환성 -- 기존 세이브 파일 구조 변경 시 마이그레이션 고려 -- 새 필드 추가는 기본값 설정 필수 (JsonUtility는 누락 필드를 기본값으로) -- 클라우드 세이브(Firestore) 필드도 동기화 + +`save-system.md`가 단일 출처. 세이브 구조를 건드린다면 그쪽을 따를 것. ## 모놀리스 주의 -- BattleScreenUI (2,950줄), RaidBattleUI (2,875줄), Bootstrap (4,987줄) +- `PlaySceneBootstrap`, `BattleScreenUI`, `RaidBattleUI` 3종 +- **줄 수는 여기 적지 않는다** — 편집 시 `warn_monolith` 훅이 실제 값을 세어 + 보고한다. 문서에 박아둔 숫자는 파일이 자라면 반드시 어긋난다. - 이 파일들 수정 시 Phase 단위로 영향 범위 확인 - 가능하면 새 메서드로 추출, 파일 분할은 신중히 diff --git a/.claude/rules/audit-flow.md b/.claude/rules/audit-flow.md new file mode 100644 index 0000000..29b9601 --- /dev/null +++ b/.claude/rules/audit-flow.md @@ -0,0 +1,74 @@ +--- +description: audit 자동 실행 트리거·금지 조건·거부 처리 +--- + +# Audit 자동 플로우 규칙 + +`.claude/audit-progress.md`의 Uncovered 영역을 매 작업 흐름에 자연스럽게 포함시키는 규칙. + +## 자동 트리거 조건 + +다음 모두 충족 시 사용자 작업이 끝나면 **자동으로 `/audit` 1회 실행**: + +1. 사용자 요청 작업이 완료됨 (코드 수정 / 기능 구현 / 버그 수정 등) +2. `.claude/audit-progress.md`의 Uncovered ≥ 1개 +3. 같은 응답 안에서 이미 `/audit`이 실행된 적 없음 (이중 실행 차단) +4. Auto Mode 활성 또는 사용자가 audit을 명시적으로 거부하지 않음 + +## 자동 실행 금지 조건 + +다음 중 하나라도 해당하면 자동 audit **금지**: + +1. 사용자가 다음 단어 포함: "audit 안 해", "그만", "건너뛰", "skip audit" +2. 사용자가 plan 모드에 있음 +3. 다음 사용자 메시지가 대기 중 (인터럽트된 작업) +4. 사용자가 명시적으로 다른 작업을 즉시 요청 ("다음", "계속" 등은 작업 신호이지 audit trigger 아님) +5. 직전 응답에서 audit 처리한 영역의 후속 검증 작업 중 + +## 실행 방식 + +1. 사용자 요청 작업 완료 후 라운드 결과 보고 +2. 라운드 결과 보고 직후 **자동으로 audit skill 실행** (별도 응답 아님, 같은 응답 연장) +3. audit이 처리한 영역을 Covered로 이동, Round Log 갱신 +4. Stop hook이 `audit_reminder.py`로 남은 Uncovered 카운트 알림 + +## 사용자 흐름 예 + +**Before** (audit 자동화 없음): +``` +사용자: "기능 X 구현해" +Claude: 구현 완료, 라운드 결과 보고 +사용자: "/audit" (수동 입력) +Claude: audit 처리 +사용자: "/audit" (또 수동 입력) +``` + +**After** (audit 자동 플로우): +``` +사용자: "기능 X 구현해" +Claude: 구현 완료, 라운드 결과 보고 + ↓ (자동 연장) + audit-progress.md Uncovered ≥ 1 확인 → /audit 자동 실행 + RaidBattleUI 처리 완료, 진척 갱신 + Stop hook: "audit 미검토 N-1개 남음" +``` + +사용자 입력 부담: 0회. 매 작업 완료마다 audit 1영역씩 자동 진행 → 누적 회귀 자동 검출/처리. + +## 거부 의사 표명 후 동작 + +사용자가 한 번 "audit 안 해" 라고 말하면: +- 같은 세션 동안 자동 audit 비활성 +- 명시적 `/audit` 호출은 여전히 동작 +- Stop hook reminder는 유지 (사용자가 다시 원할 때 알림) + +세션 종료 후 재진입 시 다시 활성. + +## 룰 위반 방지 + +이 규칙은 다음과 충돌하지 않음: +- `agent-coordination.md`: audit이 다른 에이전트 영역 침범 금지 — Explore가 거부 또는 main이 재위임 +- `architecture.md`: audit이 모놀리스 깊은 변경 금지 — 표면 회귀만 자동 처리 +- `unity-csharp.md`: 컨벤션 위반 자동 수정 금지 — 보고만 + +audit이 처리하는 범위는 P0/P1만. P2는 보고 후 사용자 결정 위임. diff --git a/.claude/rules/balance.md b/.claude/rules/balance.md index 4d5a83c..fe87511 100644 --- a/.claude/rules/balance.md +++ b/.claude/rules/balance.md @@ -9,15 +9,60 @@ description: 밸런스 수치 변경 시 체크리스트 및 참조 기준점 아래는 빠른 참조용 기준점이며, 코드와 불일치 시 코드가 우선합니다. ## 배틀 기준점 -- 데미지: (basePower + Lv×2) × atkMult × defRatio -- atkMult 범위: 0.3~3.0, defRatio 범위: 0.5~2.5 +- 데미지: (skill.power + Lv×`LevelDamageScale`) × atkMult × 상성 × 자속 × defRatio +- atkMult 범위: 0.3~3.0, defRatio 범위: `MinAtkDefRatio`~`MaxAtkDefRatio` +- HP: baseHp + ivHp×2 + Lv×`HpPerLevel` - 기본공격: ATK × 0.7 - 도주: 50% ± 5%/레벨차 (10%~90%) +### 전투 길이 3종은 함께 움직인다 + +`GameConstants.Battle`의 `LevelDamageScale` · `MaxAtkDefRatio` · `HpPerLevel` 세 값이 +전투가 몇 턴인지를 정한다. **하나만 바꾸면 다른 쪽이 곧바로 지배한다** — 여기 숫자를 적지 +않는 이유다(코드가 단일 출처). + +옛 조합(레벨항 ×2 · 비율 상한 2.5 · HP +3/Lv)은 데미지가 HP보다 빨리 자라서 +**양쪽이 서로를 한두 턴에 지웠다.** 실측(Uncommon 플레이어 vs 동레벨 Epic): Lv22·Lv28·Lv42 +전부 내가 2턴에 잡고 **1턴에 죽었고**, 수문장전도 같았다. 스킬 위력만 낮추는 시도는 +**실패한다** — 지배 항이 위력이 아니라 비율 상한과 레벨항이기 때문이다(2026-08-30에 +위력표를 절반으로 낮춰 봤더니 1턴 킬이 그대로였다). + +`RaidBossHpMultiplier`는 **이 세 값에서 파생된 값**이다. 셋 중 하나라도 건드리면 +보스 HP 배율을 다시 계산할 것 — 안 그러면 레이드 길이가 두 배로 늘거나 반토막 난다 +(8.5 → 4.5 재산출이 그 예다). + +### 범용 훈련기 위력 상한 + +훈련·기술 디스크로 아무 곤충이나 배우는 범용기(`tr_*`)의 위력은 **종족 storm(42) 아래**로 둔다. +전용기(60~78)와 최상위 종족기의 자리를 범용기가 빼앗으면 성장 곡선이 무너진다. +요구 레벨은 `TrainingMethod`가 아니라 **`InsectSkill.requiredLevel`**이 정한다 — 방식 단위로만 +잠그던 시절 "극한 훈련"이 곤충 Lv6에 위력 55·65·75를 한꺼번에 열었다. + ## 포획 기준점 - 기본 60%, 레어도당 -8%, 난이도×0.4 감소 -- 레벨 보정: 높으면 +2%/lv, 낮으면 -3%/lv +- 레벨 보정: 높으면 +2%/lv, 낮으면 -3%/lv. **레벨 차는 ±5로 clamp** + (미니게임·전투 두 경로가 같은 상한을 쓴다 — `MaximumLevelDelta`) - 퍼펙트 타이밍: +15% +- 등급별 최저 보장(floor): C .30 / U .22 / R .14 / E .08 / L .04 + +### base를 내리려면 floor를 함께 봐야 한다 + +기본값을 낮추는 변경은 **단독으로는 무조건 틀린다.** floor가 base 60% 전제로 잡혀 있어서, +난이도 0.5 동레벨 기준 현재는 전 등급이 floor보다 위라(C .40 … L .08 vs floor .30 … .04) +공식이 지배하지만, base를 0.35로 내리면 **전 등급이 floor에 닿아** 난이도·레벨·base 항이 +전부 죽고 포획률이 등급별 상수가 된다. + +실제로 그런 너프가 2026-07-17에 있었고(`CaptureController` + `GameplayTuningProfile`을 +같은 값으로 함께 .35/.45/.10), 08-03 리팩터가 상수를 추출하며 통째로 원복했다. 그 커밋이 +floor를 같이 도입했기 때문에 **단순 복원은 되돌리기가 아니라 다른 결함**이 된다 — +2026-08-17에 확인 후 현행(.60/.40/.15) 유지로 결정했다. + +`CaptureChanceCalculatorTests`의 `GameplayTuningProfile_NewAssetDefaults_MatchCaptureFormulaDefaults`는 +프로파일과 공식의 **일치**만 본다. 두 값이 같다고 옳은 값인 건 아니다(위 원복을 이 테스트가 가렸다). + +상한이 필요한 이유: 메인 필드는 리전이 아무리 높아도 **항상 Lv.1부터** 스폰한다 +(`InsectSpawner` 지수 분포). 상한이 없으면 고레벨 플레이어가 저레벨 전설을 만났을 때 +레벨 보정만으로 등급·난이도 페널티가 전부 상쇄돼 포획이 사실상 보장된다. ## 보상 기준점 - 배율: C1.0/U1.2/R1.5/E2.0/L2.8 diff --git a/.claude/rules/quest-system.md b/.claude/rules/quest-system.md new file mode 100644 index 0000000..233269a --- /dev/null +++ b/.claude/rules/quest-system.md @@ -0,0 +1,63 @@ +--- +description: 퀘스트 데이터 정의 위치·추가 절차·Notify 배선 규칙 (퀘스트 수정 시 필독) +--- + +# 퀘스트 시스템 규칙 + +## 정의 위치 — 코드 하드코딩 (SO/JSON 아님) + +퀘스트는 `TutorialQuestManager.Initialize()`의 `allQuests = new TutorialQuest[] { … }` +배열에 C# 코드로 정의된다. `.asset`도 `.json`도 아니다. 데이터 모델은 +`TutorialQuestData.cs`의 `TutorialQuest` 클래스 + `QuestType` enum. + +퀘스트 진행은 PlayerPrefs에 저장된다 — `save-system.md`의 "퀘스트 세이브" 참조. + +## 추가 절차 — 다지점 등록 (누락 시 퀘스트 영구 정지) + +새 퀘스트의 목표가 **기존 QuestType**로 표현되면 배열 1곳만 수정한다(단 선형 체인이라 +`prerequisiteQuestId` 재배선 주의). + +**새 목표 타입**이면 5곳을 모두 건드려야 한다. 하나라도 빠지면 그 퀘스트는 +`IncrementProgress`에 영영 도달 못 해 **영구 정지**한다: + +1. `QuestType` enum 추가 — `TutorialQuestData.cs` +2. 퀘스트 배열 항목 + prerequisite 체인 배선 — `TutorialQuestManager.cs` +3. `Notify___()` 메서드 + **실제 게임플레이 호출부 삽입**. 진행 트리거가 배틀/포획/UI 등 + 어느 시스템에서 발생하는지 찾아 그곳에서 `TutorialQuestManager.Instance.Notify___()`를 부른다. +4. **이벤트 기반이면** `SubscribeEvents`/`UnsubscribeEvents`에 핸들러 등록 + + 핸들러가 `NotifyAction(QuestType.X)` 호출 +5. 캔디/EXP/아이템/곤충 외 **새 보상 종류**면 데이터 모델 필드 + `CompleteQuest` 로직 + +### q_team 회귀 — 실제로 겪은 사고 + +`SetTeam` 퀘스트가 `OnTeamChanged` 핸들러는 있었으나 `SubscribeEvents`에 +`battleTeamManager.TeamChanged += OnTeamChanged`가 없어 **영구 정지**했다 +(`TutorialQuestManager.cs:322` 주석이 방어 흔적). 이벤트 기반 QuestType은 3번(호출부)이 +아니라 4번(구독 등록)이 누락 지점이다. + +## 검증 — 반드시 quest_lint 실행 + +퀘스트를 수정하면 반드시: + +``` +python -X utf8 .claude/scripts/quest_lint.py +``` + +11검사: questId 중복 / prerequisite 무결성(끊김·순환) / 보상 곤충 ID 존재 / 보상 아이템 ID 존재 / +보스 대결 보상 아이템 ID 존재 / **QuestType↔진행 배선**(q_team류 정지 검출) / 대화 리전키 정합성 / +서브 퀘스트 정합(반복은 Side 전용) / 팀 자동 편성 경로 / **prereq 방향**(배열 앞을 가리켜야 +소급 완료가 안전 — 뒤를 가리키면 아직 할 차례인 퀘스트를 보상 없이 삼킨다) / +**questId 구분자 금지**(`,`·`:`·공백 — `QuestSaveMerge`의 세이브 병합 포맷이 그 문자를 구분자로 +쓴다. 든 ID는 병합에서 조용히 버려지고 write-back이 손실을 영구화한다). +`ci_check`에도 포함돼 세션 밖 편집(Codex CLI 등)의 결함도 CI가 잡는다. + +보상 ID(`rewardInsectId`/`rewardItemId`)는 존재하지 않는 값을 물어도 런타임엔 `LogWarning`만 +찍고 조용히 실패한다 — quest_lint가 그 오타를 배포 전에 잡는다. + +## 에이전트 위임 + +| 영역 | 주담당 | 부수 | +|---|---|---| +| `TutorialQuestManager`/`TutorialQuestData` 퀘스트 로직·목표·보상 | game-designer | data-architect | +| `TutorialQuestUI` 렌더링 | ui-dev | — | +| 새 QuestType의 게임플레이 호출부 삽입 | 해당 시스템 담당(battle-dev/capture-dev 등) | game-designer | diff --git a/.claude/rules/save-system.md b/.claude/rules/save-system.md index 6429ece..0cb84d9 100644 --- a/.claude/rules/save-system.md +++ b/.claude/rules/save-system.md @@ -1,12 +1,5 @@ --- -trigger: glob -globs: - - "Assets/Scripts/Core/PlayerProgress*.cs" - - "Assets/Scripts/Core/CloudSaveManager.cs" - - "Assets/Scripts/Core/Player*Inventory.cs" - - "Assets/Scripts/Core/PlayerCurrencyWallet.cs" - - "Assets/Scripts/Dex/DexSave*.cs" - - "Assets/Scripts/Core/BattleTeamManager.cs" +description: 로컬 7개 JSON·Firestore 세이브 구조와 필드 추가·마이그레이션 규칙 (세이브 관련 파일 수정 시 필독) --- # 세이브 시스템 규칙 @@ -18,13 +11,32 @@ globs: ## 파일 목록 - player_progress.json (level, xp) -- player_insects.json (보유 곤충 전체) +- player_insects.json (보유 곤충 전체 — currentHp/isPoisoned/isParalyzed 포함, 아래 지속 HP 참조) - player_candies.json (캔디) - player_currency.json (코인, 젬) - player_items.json (아이템) - battle_team.json (5슬롯 팀) - dex_save.json (도감 기록) +## 퀘스트 세이브 (PlayerPrefs — JSON 아님) + +퀘스트 진행은 위 7개 JSON에 **없다.** `TutorialQuestManager`가 **PlayerPrefs 4키**로 저장한다 +(`GameConstants.cs`의 ProgressKey/CompletedKey/ActiveKey/UnseenKey). 계정 스코핑은 +`AuthManager.ScopedKey`. + +| PlayerPrefs 키 | 내용 | 클라우드 동기 | +|---|---|---| +| QuestProgress | 퀘스트별 진행 카운트 | O | +| QuestCompleted | 완료된 questId 집합 | O | +| ActiveQuest | 현재 활성 questId | O | +| QuestSideProgress | 서브 퀘스트별 진행 카운트 | O | +| QuestSideRepeat | 서브 퀘스트별 반복 완료 횟수(목표 상승 티어) | O | +| QuestUnseen | 미확인 완료 알림 | **X (로컬 전용)** | + +**주의:** QuestUnseen은 클라우드에 안 올라간다 — 기기 간 알림 상태가 다를 수 있다. +퀘스트 세이브 필드를 늘리면 `CloudSaveManager` DTO(questProgress/questCompleted/activeQuest/ +questSideProgress/questSideRepeat)와 직렬화/파싱/업로드/복원 4곳을 함께 고쳐야 클라우드에 반영된다. + ## 클라우드 세이브 - Firestore REST API (PATCH /users/{userId}) - 자동저장: 120초 간격 @@ -34,4 +46,31 @@ globs: ## 수정 규칙 - 새 세이브 필드 추가 시 기본값 필수 - GameSaveData에 클라우드 필드 추가 시 Firestore 포맷도 수정 -- 기존 데이터 호환성 유지 (JsonUtility는 누락 필드 무시) +- 기존 데이터 호환성 유지: **JsonUtility는 JSON에 없는 필드를 건드리지 않으므로 + C# 필드의 초기값이 그대로 남는다.** 따라서 새 필드는 반드시 의미 있는 기본값을 + 갖도록 선언할 것. (옛 문서에 "누락 필드 무시" / "기본값으로 채움" 두 표현이 + 갈라져 있었으나 동작은 이 한 가지다.) +- 기존 세이브 구조를 바꾼다면 마이그레이션 경로를 먼저 설계할 것 + +## 곤충 지속 HP·상태 (player_insects.json) + +`PlayerInsectData`는 전투 간 유지되는 `currentHp`/`isPoisoned`/`isParalyzed`를 저장한다. +- **`currentHp` 기본값 `-1`(미초기화 센티넬)** — 구세이브엔 이 필드가 없어 로드 시 -1로 남는다. + `PlayerInsectCollection` 로드 루프(EnsureInstanceId 인근)의 `EnsureHp`가 -1이면 실제 MaxHp로 채워 + **구세이브 곤충이 0 HP 기절로 뜨는 것을 방지**한다. `IsFainted`는 `currentHp == 0`(초기화 후에만 유효). +- `isPoisoned`/`isParalyzed` 기본 `false`(무상태) — 마이그레이션 무해. +- **클라우드는 자동**: `CloudSaveManager`가 player_insects.json 블롭 전체를 저장하므로 PlayerInsectData + 필드 추가에 DTO 4점 변경 불필요(퀘스트 세이브와 다름). + +## 곤충 크기·포획시각 (player_insects.json) + +- **`sizeRoll` 기본값 `-1`(미초기화 센티넬)** — `currentHp`와 같은 방식이다. 로드 루프의 + `EnsureSize`가 `-1`이면 **`instanceId` 해시**로 채운다(`InsectSizeCalculator.RollFromInstanceId`). + 0으로 채우면 구세이브 곤충이 전부 최소 크기가 되고, 매번 새로 굴리면 볼 때마다 크기가 바뀐다. +- **`capturedUnix` 기본 `0`(미상)** — 주간 크기 대결 집계에서 "이번 주 아님"으로 자연히 걸러지므로 + 마이그레이션이 필요 없다. +- **주간 대결 기록은 저장하지 않는다.** 보유 곤충 중 대상 종이면서 `capturedUnix`가 이번 주에 든 + 개체의 최대 크기를 매번 파생한다 — 블롭이 이미 클라우드 동기라 기록도 기기 간에 따라온다. + PlayerPrefs에 저장하는 건 보상 수령 상태(`WeeklyContestClaimed`, "주차:등급") 하나뿐이다. +- 전투 종료/교체 시 `PlayerInsectCollection.SetAfterBattle`이 남은 HP·감염을 기록(무료 전체치료 없음). + 치료는 병원/치료 아이템의 `HealInsect`/`FullHeal`/`CurePoison`/`CureParalysis`로만. diff --git a/.claude/rules/scriptable-objects.md b/.claude/rules/scriptable-objects.md index d3af29b..b80aa35 100644 --- a/.claude/rules/scriptable-objects.md +++ b/.claude/rules/scriptable-objects.md @@ -1,8 +1,5 @@ --- -trigger: glob -globs: - - "Assets/Scripts/Data/**/*.cs" - - "Assets/Resources/**" +description: ScriptableObject 생성 규칙, Database 패턴, 세이브 직렬화 분리 (Data/ 및 Resources/ 수정 시 필독) --- # ScriptableObject 규칙 diff --git a/.claude/rules/testing.md b/.claude/rules/testing.md index 6b5fbf3..f0ab5e6 100644 --- a/.claude/rules/testing.md +++ b/.claude/rules/testing.md @@ -6,7 +6,47 @@ description: 테스트 프레임워크, 컨벤션, 필수 기준 ## 프레임워크 - NUnit (`using NUnit.Framework`) -- EditMode 테스트 위치: `Assets/Tests/EditMode/` +- 테스트 파일 위치: `Assets/Tests/EditMode/` + +## 러너는 PlayMode다 (폴더 이름에 속지 말 것) + +폴더 이름은 `EditMode`지만 **EditMode 러너로는 0건이 잡힌다.** 이 프로젝트엔 +`.asmdef`가 하나도 없어서 테스트가 별도 에디터 테스트 어셈블리가 아니라 +런타임 어셈블리(`Assembly-CSharp`)로 컴파일되고, EditMode 러너는 그걸 보지 못한다. + +``` +-testPlatform PlayMode -testFilter InsectGame.Tests +``` + +`-testPlatform EditMode`를 쓰면 **0건을 실행하고 "성공"이라 보고한다.** 실행 개수를 +반드시 확인할 것 — 2026-08-23 기준 `[Test]` 메서드 **592개**, 러너가 실제 실행하는 케이스 **704개**다 +(`[TestCase]` 파라미터화가 여러 케이스로 펼쳐진다). 단일 출처는 코드다 — +`grep -c "\[Test\]" Assets/Tests/EditMode/*.cs`의 합과 TestResults.xml의 `total`. +문서에 박아둔 숫자는 늘 낡는다(실제로 62로 적혀 있다가 147까지 벌어져 있었고, 547/628도 곧 낡았다). +0건 보고는 통과가 아니라 실패다. + +**`-runTests`에 `-quit`를 같이 붙이지 말 것.** 붙이면 Unity가 테스트를 시작하기 전에 종료하는데 +**exit 0에 `Exiting batchmode successfully now!`까지 찍어** 성공처럼 보이고, `TestResults.xml`은 +아예 쓰이지 않는다. 이전 실행의 파일이 남아 있으면 그 낡은 `total`을 이번 결과로 착각하기 딱 좋다 +(2026-08-03에 실제로 그렇게 254/254를 잘못 읽었다). 테스트 러너가 스스로 종료하므로 `-quit`은 불필요하다. + +그래서 결과는 **두 가지를 함께** 봐야 한다 — `total`뿐 아니라 `TestResults.xml`의 **mtime이 +이번 실행 시각인지**. 확실히 하려면 실행 전에 기존 파일을 치워 없는 상태에서 시작한다. + +EditMode 러너를 되살리려면 `Assets/Scripts`·`Assets/Editor`·`Assets/Tests`에 asmdef를 +도입해야 한다(asmdef는 `Assembly-CSharp`를 참조할 수 없어 게임 코드 쪽도 함께 필요). +출시 후 별건. + +## 테스트 파일은 반드시 `#if UNITY_EDITOR`로 감쌀 것 (안 그러면 APK/AAB 빌드가 깨진다) + +`.asmdef`가 없어 테스트가 `Assembly-CSharp`(런타임 어셈블리)로 컴파일되므로, 가드 없이 두면 +테스트의 `nunit.framework` 참조가 IL2CPP 플레이어(APK/AAB) 빌드로 **새어 나가 링크가 실패한다** +(`Mono.Cecil.AssemblyResolutionException: Failed to resolve assembly: 'nunit.framework'`). + +그래서 모든 EditMode 테스트 `.cs`는 **첫 줄 `#if UNITY_EDITOR`, 마지막 줄 `#endif`로 파일 +전체를 감싼다.** 에디터 PlayMode 러너에선 `UNITY_EDITOR`가 정의돼 전부 그대로 돌고, 기기 빌드에선 +통째로 제외된다. 기존 파일엔 이미 이 가드가 있으니 **새 테스트 추가 시 빠뜨리지 말 것** — +2026-07 실제로 새 테스트 4개가 가드 누락으로 APK 빌드 링크를 멈췄다(그 4개만으로 전체 빌드 실패). ## 컨벤션 - 클래스: `[TestFixture]` 어트리뷰트 @@ -28,6 +68,92 @@ description: 테스트 프레임워크, 컨벤션, 필수 기준 - **새 시스템 추가**: 핵심 로직에 대한 단위 테스트 (UI 제외) ## 테스트 제외 대상 -- OnGUI 렌더링 코드 (IMGUI는 EditMode 테스트 불가) -- MonoBehaviour 생명주기 의존 로직 (PlayMode 테스트 필요) +- OnGUI 렌더링 코드 (IMGUI는 렌더 루프 없이 검증 불가) +- MonoBehaviour 생명주기 의존 로직 (`[UnityTest]` + `yield`가 필요. 현재 테스트는 전부 + 씬 없이 도는 순수 로직 `[Test]`다) - 외부 서비스 호출 (Firebase, Firestore) + +## 3D 화면은 눈으로 확인한다 — `LiveSceneCapture` + +위 제외 대상 중 **월드에 보이는 것**(모델·애니메이션·지형·배치·연출)은 테스트 대신 +실제 화면을 찍어서 본다. `Assets/Editor/LiveSceneCapture.cs`가 배치모드로 PlayScene을 +띄우고 카메라를 렌더해 PNG로 남긴다. + +``` +"$UNITY_EDITOR_PATH" -batchmode -projectPath "C:/Project/곤충게임" \ + -logFile .claude/cache/capture.log \ + -executeMethod InsectGame.EditorTools.LiveSceneCapture.Run \ + -captureOut .claude/cache/capture -captureTimes 2.0,4.0 \ + -captureSize 900x700 -captureOffset 0,1.2,-2.6 -captureLook 0,0.85,0 +``` + +인자는 전부 선택이다(`-captureTarget`은 따라갈 오브젝트 이름, 기본 `Player`, +`none`이면 게임 카메라 구도 그대로). 결과는 로그의 `[CAPTURE]` 줄로 확인하고, +종료 코드는 요청한 장수를 다 찍었을 때만 0이다. + +**정지 화면으로는 애니메이션을 못 본다** — 여러 시각을 찍어 픽셀 차분을 낸다. +플레이어 idle 호흡을 이 방법으로 확인했다(2초 간격 3장, 차이가 플레이어 영역에만 몰림). + +### 대사가 실제로 뜨는지는 `StoryBeatWalkthrough`로 본다 + +캡처 도구의 첫 번째 한계(IMGUI 미포착)가 정확히 스토리 대사를 덮는다 — 비트가 발화하면 +`NpcDialogueUI`가 `OnGUI`로 그리므로 **화면으로는 확인할 방법이 없다.** 그런데 +`story_lint`도 못 본다: 그쪽은 Story.json을 **정적으로** 읽어 게이트·참조 무결성만 본다. +발화는 트리거 이벤트·prereq 열람·리전 게이트·우선순위 비교·`pendingBeatId` 잠금· +미뤄 둔 트리거 큐가 **런타임에** 맞물린 결과라, 하나만 어긋나도 **대사가 그냥 안 뜬다** +(예외도 경고도 없다). + +그래서 대사창 대신 **발화 자체**를 본다. `Assets/Editor/StoryBeatWalkthrough.cs`가 +`StoryBeatTriggered`를 구독한 채 게임의 실제 진입점(`OnNpcTalked`·`AddCapturedInsect`· +`BattleEnded`·`CleanseByBoss`)을 순서대로 두드리고, 뜬 대사는 `NpcDialogueUI.CloseModal`로 +닫는다(닫지 않으면 `DrainPendingTriggers`가 모달 가드에 막혀 **다음 비트가 영영 안 온다**). + +``` +"$UNITY_EDITOR_PATH" -batchmode -projectPath "C:/Project/곤충게임" \ + -logFile .claude/cache/story-walk.log \ + -executeMethod InsectGame.EditorTools.StoryBeatWalkthrough.Run \ + -walkOut .claude/cache/story-walk.md [-walkRegion mountain] [-walkMode campaign] [-walkChoice last] +``` + +거점 목록을 박아두지 않는다 — `RegionData.HasBlightSite`를 런타임에 훑으므로 거점을 +늘리면 걸음도 저절로 는다. 종료 코드는 모든 걸음이 통과했을 때만 0이고, 보고서에 +**실제 발화 순서**가 남는다. 선행 비트만 `CompleteBeat`로 채우고(검증 대상이 아니다) +무엇을 채웠는지 보고서에 적는다. + +**모드가 둘이다 — 기본값만 돌리면 본편은 한 걸음도 안 걷는다.** + +| `-walkMode` | 걷는 것 | 두드리는 트리거 | +|---|---|---| +| `blight` (기본) | 오염 거점 아크 `bl_*` | `NpcTalk` · `CaptureInsect` · `BattleWin` · `RegionCleansed` | +| `campaign` | 1막 본편 + 꽃밭 | 위 + **`SubAreaEnter`** · **`GuardianDefeat`** | + +**선택지가 뜨면 도구가 고른다** — 기본은 첫 항목, `-walkChoice last`면 마지막 항목. 선택 결과는 +`Immediate` leaf라 고르는 순간 큐 맨 앞에서 뜬다(`StoryBible.md` 6장 「선택지 규칙」). 최종장 +`fin_unnamed`의 거절·수락 양쪽이 실제로 뜨는지는 `campaign`을 **두 번** 돌려야 본다. + +`SubAreaEnter`와 `GuardianDefeat`는 **본편에서 가장 많이 쓰는 두 트리거인데 오래 사각지대였다** — +거점 아크가 둘 다 안 쓰는 탓에 구동부 자체가 없었다. 2026-08-26에 붙였다. + +- **서브에리어는 세 걸음이다** — 근접 → `[E]` 진입 → 이탈. `RequestEnterSubArea`는 + `nearbySubArea`가 차 있어야 하고 그건 `RegionManager.Update`가 **위치로만** 채운다. + **이탈을 빠뜨리면 그 뒤가 전부 죽는다**: 진입이 sticky를 켜고 플레이어를 (2000,0,2000)으로 + 옮기는데, sticky 동안 `Update`가 위치 판정을 건너뛰어 다음 리전 이동이 영영 성립하지 않는다. +- **`GuardianDefeat`는 리전당 일생 1회다**(`DefeatGuardian`의 idempotent 가드). 두 번째 + 실행에서 `gd_*`가 "발화 없음"으로 잡히면 결함이 아니라 격파 기록이 남아서다 — + 그래서 `ResetProgress`가 `InsectGame.DefeatedGuardians`도 함께 지운다. +- **걷는 순서가 곧 여운 체인 순서다.** 여운은 같은 NPC의 직전 여운을 prereq로 물기 때문에 + 연못(ch2) → 숲(ch3) → 습지(ch4) → 산(ch5) → 유적(ch6) 차례로 걸어야 하나씩 열린다. + +읽을 때 헷갈리는 것: **한 걸음에 시도가 2회로 찍히는 게 정상일 수 있다.** 같은 트리거에 +본편 비트가 함께 자격을 가지면 챕터 우선순위가 이겨 그쪽이 먼저 나간다(산에서 포획하면 +`ch5_thesis`가 `bl_mountain_sign`보다 먼저, 유적에서 이기면 `ch6_approach`가 먼저다). +플레이어도 실제로 두 번 해야 한다 — 결함이 아니라 저작 순서다. + +### 한계 셋 (전부 실측) + +- **IMGUI는 안 잡힌다.** `OnGUI`는 카메라를 거치지 않는다 — 상점·대화창·배틀 UI·HUD는 + 이 도구로 검증할 수 없다. 필요하면 스탠드얼론 빌드에서 `ScreenCapture`를 써야 한다. +- **`ScreenCapture.CaptureScreenshot`은 배치모드에서 조용히 실패한다**(게임뷰가 없다). + 그래서 이 도구는 카메라 → `RenderTexture` → `ReadPixels` 경로를 쓴다. +- **Unity 에디터를 열어두면 이 도구가 못 돈다.** 프로젝트가 `Temp/UnityLockfile`로 잠겨 + 두 번째 인스턴스가 뜨지 않는다. 배치모드 검증 중에는 에디터를 닫아 둔다. diff --git a/.claude/rules/ui-layout.md b/.claude/rules/ui-layout.md new file mode 100644 index 0000000..93bb2ce --- /dev/null +++ b/.claude/rules/ui-layout.md @@ -0,0 +1,332 @@ +# UI 레이아웃 규칙 — 세로 마진 하네스 + 공통 서피스 + +두 하네스가 역할을 나눈다. **어디에 놓을지는 `UISafeLayout`, 어떤 질감으로 칠할지는 `UISurface`.** +둘 다 우회하지 않는다. + +## 표면은 `UISurface`다 — 각진 사각형을 직접 그리지 않는다 + +패널·카드·행 배경을 `GUI.DrawTexture(rect, Texture2D.whiteTexture)`로 직접 칠하지 않는다. +`Assets/Scripts/UI/UISurface.cs`가 그림자 + 둥근 모서리 + 테두리를 한 번에 준다. + +```csharp +// Before — 각진 사각형 +GUI.color = SomeBgCol; +GUI.DrawTexture(new Rect(px, py, pw, ph), Texture2D.whiteTexture); +GUI.color = Color.white; + +// After +UISurface.Card(new Rect(px, py, pw, ph), SomeBgCol, UITheme.Instance.surfaceBorder); +``` + +| 호출 | 쓰는 자리 | +|---|---| +| `Card(rect, bg, border)` | 패널·카드·목록 행 | +| `Card(rect)` | 테마 기본색 카드 | +| `Rounded(rect, color[, radius])` | 헤더 바, 배지 바탕 | +| `Flat(rect, color)` | **얇은 것** — 구분선·진행바·액센트 스트라이프 | +| `Button(rect, label, bg, style[, selected])` | 호버 반응이 필요한 버튼 | +| `Chip(rect, text, bg, textColor)` | 등급·속성·보상 배지 | +| `StatRow(rect, label, value, lc, vc)` | 라벨/값 한 줄 | +| `Header(rect, title, subtitle[, closeLabel])` | 액센트 헤더 + 닫기 (닫힘 여부 반환) | +| `ScrollAffordance(viewport, scroll, contentH, accent)` | 스크롤 위치 표시 | +| `Dim(alpha)` | 모달 뒤 딤 (**가상 좌표계 전용**, 픽셀이면 `UIHelper.DrawDimOverlay`) | + +**헤더 바를 패널 위에 겹칠 때는 3px 안쪽으로 물린다** — 그러지 않으면 각진 헤더가 둥근 +패널 모서리를 뚫고 나온다: `Rounded(new Rect(px + 3f, py + 3f, pw - 6f, headerH), headerBg)`. + +**얇은 것(짧은 변이 대략 8px 이하)은 `Flat`으로 각지게 둔다.** 둥근 배경은 9-slice라 +테두리 폭이 `반경 + 4`인데, 짧은 변이 그보다 작으면 슬라이스가 겹쳐 뭉개진다. +높이만이 아니라 **폭**도 해당한다 — 세로로 긴 5~7px 등급 레일이 같은 경우다. + +둥근 패널 위에 얇은 바를 얹을 때는 **긴 축을 반경만큼 물린다** — 각진 바는 둥근 모서리를 +그대로 뚫고 나온다: `Flat(new Rect(px + UITheme.Radius.Card, py + 3f, pw - UITheme.Radius.Card * 2f, 8f), col)` +(`HospitalUI`의 적십자 스트라이프가 그 형태다). + +`Rounded`는 **알파만 호출부 것을 살리고 RGB는 흰색으로 고정한다.** 텍스처가 이미 색을 굽고 +있어 RGB까지 곱하면 이중 착색이 되고, 흰색을 통째로 대입하면 패널 페이드에서 배경만 빠진다. +(`UIHelper.DrawTinted`가 전면 곱셈인 건 그쪽이 **흰** 텍스처를 그리기 때문이다 — 규칙이 다르다.) + +## 색은 `UITheme` 토큰에서만 받는다 + +화면 파일에 `static readonly Color`를 새로 늘리지 않는다. `surfaceBase`/`surfaceCard`/ +`surfaceRaised`/`surfaceBorder`/`surfaceShadow`, `accentCoral`/`accentAmber`/`accentMint`, +`textPrimary`/`textSecondary`/`textMuted`가 있다. 간격은 `UITheme.Space.*`, 반경은 `UITheme.Radius.*`. + +(도감은 한때 밝은 크림/코랄 팔레트 47색을 자기 파일에 박아두어 혼자 다른 앱처럼 보였다. +지금은 전부 토큰 파생이다 — `DexScreenUI`의 색 블록이 그 형태의 본보기다.) + +## 배치의 단일 출처는 `UISafeLayout`이다 + +패널의 **y와 height를 직접 계산하지 않는다.** `Assets/Scripts/UI/UISafeLayout.cs`가 주는 +`Rect`를 받아 쓴다. 세이프에어리어(노치·제스처바)와 세로 마진이 이미 빠진 좌표다. + +```csharp +Rect panel = UISafeLayout.CenteredPanel(960f, 940f); // 높이는 안전 영역 안으로 자동 clamp +float px = panel.x, py = panel.y, pw = panel.width, ph = panel.height; +``` + +세로 마진 = **화면 높이 × 3% (24~64px clamp)**. 세이프에어리어 위에 추가로 얹는다. +인셋이 0인 데스크톱에서도 가장자리에 붙지 않고, 인셋이 있는 기기에서는 그만큼 더 안쪽으로 들어간다. + +가로 마진은 24px 고정이다 — 세로만 늘렸다. 잘림은 세로에서 났고 가로 폭은 기존 레이아웃을 유지한다. + +## API + +| 호출 | 쓰는 자리 | +|---|---| +| `CenteredPanel(w, h)` | 화면 중앙 모달 | +| `AnchoredPanel(w, h, HAlign)` | 좌/우 정렬 패널 (`CollectionUI`처럼 우측에 붙는 것) | +| `TopPanel(w, h[, HAlign])` | 상단 앵커 배너·HUD | +| `BottomPanel(w, h[, HAlign])` | 하단 앵커 바·버튼 | +| `ContentTop` / `ContentBottom` / `ContentHeight` | y만 필요할 때(폭은 호출부가 이미 정함) | +| `CenteredY(h)` / `BottomY(h)` | 위와 같음 — 세로 좌표 하나만 | +| `ClampHeight(desired)` / `ClampWidth(desired)` | 내용 길이에 따라 자라는 크기의 상한 | +| `Overflows(desired)` | 스크롤이 필요한지 판단 | +| `Content` | 전체화면 UI의 콘텐츠 영역(`DexScreenUI`) | +| `ContentLeft` / `ContentWidth` | x만 필요할 때(가로 마진 24 고정판) | +| `MarginY` | 현재 화면의 세로 마진값 자체가 필요할 때 | +| `UISafeLayout.Px.*` | `UIScale.Begin()`을 쓰지 않는 픽셀 좌표계 UI | + +`UIScale.Begin()` 안이면 그냥 `UISafeLayout.*`, 픽셀 좌표로 그리면 `UISafeLayout.Px.*`. +두 파사드는 같은 순수 계산부(`Compute`/`ClampSize`/`CenterStart`)를 공유한다. + +**`UISafeLayout.ContentWidth`와 `UIScale.ContentWidth(margin)`은 중복이 아니다.** +앞쪽은 가로 마진 `MarginX`(24)로 **고정된 판**이고, 뒤쪽은 마진을 인자로 받는 **일반판**이다 +(`CaptureMinigameController`는 28, `AccountSettingsUI`는 0을 넘긴다). 24가 맞으면 하네스 쪽을, +다른 마진이 필요하면 `UIScale` 쪽을 쓴다. 이 구분을 안 적어둬서 한동안 "같은 질문에 답이 둘"로 +보였다 — 표에 없던 `ContentLeft`/`ContentWidth`/`MarginY`/`ClampWidth`를 여기 올린 이유다. + +## 금지 관용구 — `ui_layout_lint.py`가 잡는다 + +| 금지 | 대신 | +|---|---| +| `(VirtualScreenHeight - panelH) * 0.5f` | `CenteredPanel` / `CenteredY` | +| `float y = VirtualScreenHeight - h - 16f` | `BottomY(h)` / `BottomPanel` | +| `VirtualSafeTop + 20f` | `ContentTop` | +| `VirtualScreenHeight - VirtualSafeTop - VirtualSafeBottom - 24f` | `ContentHeight` / `ClampHeight` | +| `float panelH = 1000f;` (안전 영역 확인 없이) | `ClampHeight(1000f)` 또는 하네스 Rect | + +`Screen.height`/`SafeArea.*` 픽셀 버전도 동일하게 금지. 라인에 `UISafeLayout`이 있으면 검사에서 면제된다. + +**비율 배치(`VirtualScreenHeight * 0.08f`)는 금지가 아니다** — 아레나·연출 좌표가 그렇게 잡혀 있고 +잘림의 원인이 아니다. 다만 그 자리에 고정 높이 패널을 놓는다면 `Mathf.Clamp(비율값, ContentTop, ContentBottom - h)`로 가둔다. + +## `GUILayout.BeginArea`는 중첩하지 않는다 — 한 화면에 하나 + +Unity는 Area 중첩을 지원하지 않는다("Areas cannot be nested"). 그런데 이 저장소의 화면은 +`OnGUI`가 패널 영역을 열고(`GUILayout.BeginArea(contentArea)`) 그리기 헬퍼가 그 안에서 +또 여는 형태로 어긋나기 쉽다 — **호출이 서로 다른 메서드에 흩어져 있어 눈에 안 띈다.** + +```csharp +// Before — 스크롤 콘텐츠 좌표계를 직접 리셋하려다 Area를 중첩했다 +Rect viewport = GUILayoutUtility.GetRect(1f, 1f, GUILayout.ExpandWidth(true), GUILayout.ExpandHeight(true)); +position = GUI.BeginScrollView(viewport, position, new Rect(0f, 0f, contentWidth, contentHeight)); +GUILayout.BeginArea(new Rect(0f, 0f, contentWidth, contentHeight)); // ← 바깥에 이미 Area가 열려 있다 +DrawTabContent(); +GUILayout.EndArea(); +GUI.EndScrollView(); + +// After — 레이아웃 스크롤뷰가 좌표계·콘텐츠 높이·뷰포트를 스스로 관리한다 +position = GUILayout.BeginScrollView(position, GUILayout.ExpandWidth(true), GUILayout.ExpandHeight(true)); +GUILayout.BeginVertical(); +DrawTabContent(); +GUILayout.EndVertical(); +GUILayout.EndScrollView(); +``` + +**증상이 조용하다**: 컴파일도 되고 예외도 없고 바깥 위젯(탭 버튼·헤더)은 멀쩡히 그려진다. +안쪽 내용만 통째로 사라진다. 2026-08-08에 한 커밋이 두 화면에 같은 구조를 심어 +`CashShopUI`(상점 3탭 전부 빈칸)와 `SocialPvpUI`(친구·랭크전·배틀 3탭)가 함께 죽었다. + +터치 드래그(`UIDirectScroll`)가 화면 좌표 뷰포트를 요구하는 게 원래 `GUI.BeginScrollView`를 +쓴 이유였다. 레이아웃 스크롤뷰는 자기 `Rect`를 돌려주지 않으므로 `EndScrollView` 직후 +`GUILayoutUtility.GetLastRect()`로 재서 **다음 프레임에 쓴다** — 한 프레임 늦지만 패널 크기는 +매 프레임 바뀌지 않는다. 콘텐츠 높이도 같은 방법으로 안쪽 `BeginVertical`/`EndVertical`에서 잰다. + +`ui_layout_lint.py`가 **파일당 `GUILayout.BeginArea` 2회 이상**을 중첩 의심으로 잡는다. +순차로 두 번 여는 합법 코드가 생기면 스크립트의 `NESTED_AREA_EXEMPT`에 근거와 함께 올린다. + +## 면제 파일 (`EXEMPT_FILES`) + +- `UISafeLayout.cs` / `UIScale.cs` / `SafeArea.cs` / `SafeAreaPanel.cs` — 하네스 자신 +- `FieldHudInput.cs` — 터치 좌표 변환이지 배치가 아님 +- `VirtualJoystickUI.cs` — 조이스틱 시작 가능 영역(입력 데드존)이다. 마진을 주면 조작 영역이 좁아진다 +- `OpeningSceneController.cs` — shortSide 비율(16~28px) 자체 마진 체계가 있고 `CalculateSkipButtonRect`가 + `OpeningSequenceTests`로 고정돼 있다 + +면제를 늘리려면 이 목록과 이 문서를 함께 고친다. 스크립트에만 추가하지 않는다. + +## 래핑 텍스트를 고정 높이 상자에 그리지 않는다 — `UIHelper.LabelFit` + +`wordWrap = true` 스타일을 **고정 높이** Rect에 `GUI.Label`로 그리면, 줄바꿈이 일어나는 +순간 넘치는 줄이 통째로 잘린다. 상자는 그대로 두고 **글자를 줄여 맞춘다**: + +```csharp +// Before — 두 줄이 되면 아랫줄이 사라진다 +GUI.Label(new Rect(x, y, w, 84f), data.description, descStyle); + +// After +UIHelper.LabelFit(new Rect(x, y, w, 84f), data.description, descStyle); +``` + +`LabelFit`은 들어갈 때까지 폰트를 한 단계씩 줄이고 `UIHelper.MinReadableFontSize`(18)에서 +멈춘다. 결과는 (텍스트, 폭, 높이, 기준 폰트)로 캐시되므로 적중 시 측정이 없다. +호출부의 공유 스타일은 그려진 뒤 폰트 크기가 원복된다. + +**`wordWrap`을 끈 라벨은 세로가 아니라 가로로 잘린다.** 줄바꿈이 없으면 높이는 폰트 크기와 +무관하게 늘 한 줄이라 세로 검사만으로는 축소가 절대 발동하지 않는다. `LabelFit`은 이 경우 +`CalcSize().x`도 함께 보므로 그대로 쓰면 된다 — 두 방향 모두 이 헬퍼 하나로 덮인다. +(가운데 정렬이면 앞뒤가 같이 잘려 더 나쁘다. `RegionMapUI`의 `Label()` 헬퍼가 지도 핀을 +1줄로 고정하려고 wordWrap을 전역으로 꺼서 지역 설명까지 이 경로를 탄다.) + +**상자를 키우는 쪽이 맞는 자리라면** `UIHelper.MeasureWrappedHeight(style, text, width)`로 +필요한 높이를 받아 레이아웃을 늘린다(`TutorialQuestUI`의 퀘스트 설명이 그 형태다). +스크롤 목록처럼 아래 요소가 밀려도 되는 곳에서만 쓴다 — 고정 패널에서 상자를 키우면 +그 아래가 전부 밀려 회귀 범위가 커진다. + +한국어는 같은 뜻을 더 긴 글자수로 쓰고 모바일에선 기준 폰트가 커져서, 데스크톱 Game View에서 +멀쩡하던 라벨이 기기에서 잘리는 일이 반복됐다(도감 설명·아이템 설명·보유 곤충 설명·NPC 대사· +팀 슬롯 이름·가이드 배너). + +### 리터럴 문구는 `literal_fit_lint.py`가 따로 잡는다 + +`text_fit_lint`는 **길이를 데이터가 정하는** 자리만 본다. 그래서 +`GUI.Label(rect, "고정 문구", style)`처럼 리터럴을 쓰는 자리는 아무도 검사하지 않았고, +**36pt를 28px 상자에** 그리던 곳이 실제로 있었다(2026-08-08에 손으로 둘 찾아 고쳤다). + +``` +python -X utf8 .claude/scripts/literal_fit_lint.py +``` + +스타일의 `fontSize`와 Rect 높이를 소스에서 뽑아 대조한다. 한글 줄높이 ≈ `fontSize × 1.35` +(`DexScreenUI.LineH` / `TutorialQuestUI.RowH`와 같은 계산)보다 상자가 **10% 이상** 낮으면 FAIL이다. +그보다 작은 차이는 폰트 패딩에 묻혀 실제로 안 잘릴 수 있어 정보로만 센다 — 전부를 기준으로 +삼으면 `text_fit_lint`가 247건에서 31건으로 좁혀졌던 실수를 되풀이한다. + +**고치는 방법은 `UIHelper.LabelFit`이다.** 상자를 키우지 않는다 — 아래 요소가 전부 밀려 +회귀 범위가 커지고, IMGUI는 배치모드로 캡처할 수 없어 결과를 눈으로 확인할 수도 없다 +(`rules/testing.md`). 상자를 키우는 건 아래가 밀려도 되는 자리(스크롤 목록 등)에서 +이웃 y를 검산한 뒤에만. 2026-08-17에 심각 31건을 전부 `LabelFit`으로 바꿨다. + +### `text_fit_lint.py`가 잡는다 + +``` +python -X utf8 .claude/scripts/text_fit_lint.py +``` + +**텍스트 길이를 데이터가 정하는데 상자가 고정**인 자리만 잡는다(`.description`/`.displayName`/ +`lines[…]` 등). 처음엔 "래핑인데 두 줄이 안 들어감"으로 잡아 봤더니 **247건**이 나왔다 — +한 줄 라벨은 원래 높이가 fontSize의 1.2배쯤이라 정상 라벨을 전부 잡는다. 텍스트 출처를 +좁히고 나서야 31건이 됐고, 그 31곳이 전부 진짜였다. `ci_check.py`의 `REPO_CHECKS`에 있다. + +## 필드 위에 그리는 버튼은 `FieldHudInput.RegisterBlockingRect`에 등록한다 + +모달이 아닌 상태에서 월드 위에 겹쳐 그리는 버튼(퀵액세스 바, 잡기 버튼, 멀티플레이 패널 등)은 +**매 `OnGUI`마다 자기 Rect를 등록해야 한다.** 안 하면 그 버튼을 누른 탭이 **월드 클릭-이동으로 +새어** 캐릭터가 버튼 아래 지점으로 걸어간다. + +왜 IMGUI만 이 문제를 겪나: `PlayerMovement`는 `Input.GetMouseButtonDown(0)`을 `Update`에서 +**따로 폴링**한다. 그 시점에 ①탭한 프레임엔 아직 모달이 안 열려 `ModalUIRegistry.IsAnyOpen()`이 +false고 ②IMGUI는 EventSystem을 안 거쳐 `pointerOverUI`도 false다. 남은 방어선이 이 등록 하나뿐이다. + +```csharp +Rect barRect = new Rect(startX - 14, y - 8, totalW + 28, btnH + 16); +FieldHudInput.RegisterBlockingRect(barRect); // 그리기 직전 +GUI.DrawTexture(barRect, Texture2D.whiteTexture); +``` + +**픽셀 좌표계라면 `UIScale.Scale`로 나눠서 넘긴다.** `RegisterBlockingRect`는 가상 좌표를 받고 +`IsScreenPointOverHud`가 화면 좌표를 `Scale`로 나눠 비교한다. `UIScale.Begin()`을 쓰지 않는 +화면(`UISafeLayout.Px` 사용)이 그대로 넘기면 스케일이 1이 아닌 기기에서 엉뚱한 영역이 막힌다 +(`WorldFieldMultiplayerUI.BlockFieldClicks`가 그 변환의 본보기다). + +**네 번 났다.** 2026-08-17에 `QuickAccessBarUI`(메뉴를 열 때마다)와 `WorldFieldMultiplayerUI` +("3:3 대전"을 누르면 도전과 동시에 캐릭터가 상대 뒤로 걸어감)가 P0이었고, 2026-08-23에 +`TutorialQuestUI`(퀘스트 칩·✕·목표 행)와 `SubAreaWorldBuilder`(진입/퇴장 버튼)가 또 나왔다. +마지막 것이 **이 게임에서 가장 큰 필드 버튼**이다(620×100, 하단 중앙) — 누르면 서브에리어로 +들어가면서 동시에 **옛 월드 좌표를 향한 클릭-이동**이 걸린다. + +`TutorialQuestUI`의 목표 행은 그중 새 코드였다. **규칙이 여기 적혀 있는데도 같은 함정을 +그대로 밟았다** — 필드에 버튼을 새로 그릴 때 이 절을 다시 읽을 것. + +검사기를 두지 않은 이유: "필드 위 비모달 버튼"을 정적으로 판정하려면 모달 여부·그리기 조건을 +따라가야 해서 오탐이 크다. 전체 화면 모달은 등록하면 **안 되고**(모달 중에는 애초에 클릭-이동이 +막힌다), 그 구분이 소스 패턴으로는 안 드러난다. 실제로 "IModalUI가 아닌데 버튼이 있고 등록이 +없다"로 규칙을 짜 보면 네 건 중 **하나만** 잡고(나머지 셋은 IModalUI를 함께 구현한다) 로그인· +미니게임·오프닝이 거짓양성으로 걸린다. + +### 대신 전수는 손으로 한다 — 이 표를 뽑아서 본다 + +**`GUI.Button`만 세면 안 된다.** 이 저장소의 HUD 절반은 버튼 위젯을 쓰지 않고 +`Event.current` + `Rect.Contains`로 직접 히트테스트한다(`PlayerStatusHUD`의 닫힘 탭, +`TutorialQuestUI`의 퀘스트 칩). 버튼 문자열로만 훑다가 **가장 큰 HUD인 상태 패널을 +통째로 놓쳤다**(2026-08-23). 두 관용구를 함께 센다: + +``` +for f in $(grep -rl "GUI.Button(\|UISurface.Button(\|EventType.MouseDown" --include=*.cs Assets/Scripts); do + printf "%-50s 입력%-3s 등록%-3s 프리즈%-3s IModal%s\n" "${f#Assets/Scripts/}" \ + "$(grep -c "GUI.Button(\|UISurface.Button(\|EventType.MouseDown" "$f")" \ + "$(grep -c "RegisterBlockingRect" "$f")" \ + "$(grep -c "SetFrozen(true)" "$f")" \ + "$(grep -c ", IModalUI" "$f")" +done +``` + +읽는 법: **프리즈를 걸고 그리는 화면과 전체화면 모달은 안전하다**(그 상태에서는 클릭-이동이 +이미 막힌다). 위험한 것은 **플레이어가 자유롭게 움직이는 동안 그려지는 것**뿐이다 — +로그인·오프닝(월드 없음), 미니게임·포획 선택(프리즈)은 제외하고 남는 것을 본다. + +`evt.Use()`는 방어가 **아니다.** 그건 IMGUI 안에서만 유효한데 `PlayerMovement`는 +`Input.GetMouseButtonDown(0)`을 Update에서 따로 폴링한다 — IMGUI 밖이라 소비 여부를 모른다. + +2026-08-23 기준 등록된 파일: `CaptureInputController`, `WorldInteractionController`, +`QuickAccessBarUI`, `WorldFieldMultiplayerUI`, `TutorialQuestUI`, `SubAreaWorldBuilder`, +`MinimapUI`·`PlayerStatusHUD`(버튼 위젯은 없지만 불투명 패널이라 같이 막는다). + +## 구독은 `OnEnable`에서 되살린다 — `subscription_lint.py`가 잡는다 + +UI 컴포넌트가 `OnDisable`에서 `-=`로 해지한 이벤트는 **반드시 `OnEnable`에서 다시 `+=`** 한다. +`AutoWire`는 Bootstrap에서 **한 번만** 불리므로, 거기서만 구독하면 되살아나지 못한다. + +이게 왜 UI 규칙이냐면, `OpeningReplayCoordinator`가 오프닝 다시보기 중 +`playUiRoot.SetActive(false/true)`로 **UI 루트를 통째로 껐다 켜기 때문**이다. 그 한 번에 +UI 루트 아래 41개 컴포넌트의 `OnDisable`이 발화한다. + +같은 계열로 네 번 났고 그중 하나는 P0였다: + +| 파일 | 증상 | +|---|---| +| `HospitalUI` | `InsectUpdated` 해지만 있고 재구독 없음 (2026-07-19) | +| `BattleScreenUI` | `OnEnable`이 빈 메서드 → **다시보기 후 배틀 화면이 영구히 안 열림** (P0, 2026-08-03) | +| `RaidBattleUI` | 같은 형태 | +| `RegionMapUI` | `OnEnable` 자체가 없어 레이드 보스 마커 소실 | + +고치는 형태는 하나다 — 구독을 `Subscribe___()`로 빼고 `AutoWire`와 `OnEnable`이 함께 부른다. +`-=` 뒤 `+=`라 중복 구독이 되지 않는다. + +**`ApplyReplayBlock`의 줄 순서도 의미를 갖는다**: `BattleScreenUI`/`RaidBattleUI`의 `OnDisable`에 +`if (Time.timeScale < 0.99f) Time.timeScale = 1f` 슬로우모션 복구가 있어서, `SetActive(false)`보다 +`Time.timeScale = 0f`를 먼저 두면 오프닝 뒤에서 월드가 계속 돈다. + +## 검증 + +``` +python -X utf8 .claude/scripts/ui_layout_lint.py +python -X utf8 .claude/scripts/subscription_lint.py +``` + +`ci_check.py`의 `REPO_CHECKS`에도 들어 있어 세션 밖 편집(Codex CLI 등)도 CI가 잡는다. + +순수 계산부는 `Assets/Tests/EditMode/UISafeLayoutTests.cs`가 검증한다 — 마진 clamp 경계, +비대칭 인셋 중앙 정렬, 하단 앵커, 그리고 인셋이 없을 때 옛 `HospitalUI` 3줄 관용구와 같은 값이 나오는지(회귀 방지). + +## 내용이 넘칠 때 + +`ClampHeight`는 패널을 줄일 뿐 내용을 줄이지 않는다. 넘치는 화면은 둘 중 하나로 처리한다. + +1. **목록·그리드**: `UIDirectScroll` + `GUI.BeginScrollView`. 뷰포트를 패널 높이에서 파생시키면 + 패널이 줄어들 때 자동으로 스크롤로 넘어간다 (`DexScreenUI.cs:841-854`가 모범). +2. **고정 개수 행**(팀 5슬롯 등): 남은 높이에 맞춰 행 높이를 줄인다. 하한은 `UIScale.MinTouchHeight`. + 5줄짜리에 스크롤을 붙이는 것보다 낫다 (`BattleTeamUI.DrawTeamPanel`). + +패널 높이에서 파생되는 뷰포트는 `Mathf.Max(1f, ...)`로 감싼다 — 안전 영역이 극단적으로 좁으면 음수가 된다. diff --git a/.claude/rules/unity-csharp.md b/.claude/rules/unity-csharp.md index 284dfa6..78a3b4a 100644 --- a/.claude/rules/unity-csharp.md +++ b/.claude/rules/unity-csharp.md @@ -1,5 +1,5 @@ --- -trigger: always +description: 네임스페이스·네이밍·필드 선언, AutoWire/이벤트/싱글턴/풀 패턴, 금지 사항 --- # Unity C# 코딩 규칙 diff --git a/.claude/scripts/audit_agents.py b/.claude/scripts/audit_agents.py deleted file mode 100644 index b166c68..0000000 --- a/.claude/scripts/audit_agents.py +++ /dev/null @@ -1,134 +0,0 @@ -"""에이전트 커버리지 감사 스크립트""" -import subprocess, os - -agents = { - "battle-dev": [ - "Assets/Scripts/Battle/InsectBattleController.cs", - "Assets/Scripts/Battle/InsectBattleStats.cs", - "Assets/Scripts/Battle/InsectBattleUIController.cs", - "Assets/Scripts/Battle/RaidBattleController.cs", - "Assets/Scripts/UI/BattleScreenUI.cs", - "Assets/Scripts/UI/RaidBattleUI.cs", - "Assets/Scripts/UI/BattleTeamUI.cs", - "Assets/Scripts/Core/BattleTeamManager.cs", - "Assets/Scripts/Core/PlayerInsectCombatPower.cs", - "Assets/Scripts/Data/InsectSkill.cs", - "Assets/Scripts/Data/InsectLearnableSkill.cs", - "Assets/Scripts/Data/InsectElement.cs", - ], - "capture-dev": [ - "Assets/Scripts/Capture/CaptureController.cs", - "Assets/Scripts/Capture/CaptureMinigameController.cs", - "Assets/Scripts/Capture/CaptureInputController.cs", - "Assets/Scripts/Capture/CaptureProximityTrigger.cs", - "Assets/Scripts/Capture/CaptureRaycastTrigger.cs", - "Assets/Scripts/Capture/CaptureTriggerModeController.cs", - "Assets/Scripts/Capture/CaptureFeedbackController.cs", - "Assets/Scripts/Capture/CaptureTriggerOptionsUI.cs", - "Assets/Scripts/Spawning/InsectSpawner.cs", - "Assets/Scripts/Spawning/SpawnPoint.cs", - "Assets/Scripts/Spawning/InsectEntity.cs", - "Assets/Scripts/Spawning/SimpleObjectPool.cs", - "Assets/Scripts/Spawning/DistanceCulling.cs", - "Assets/Scripts/Spawning/CaptureItemSpawner.cs", - "Assets/Scripts/Spawning/CaptureItemPickup.cs", - "Assets/Scripts/Data/InsectSpawnCondition.cs", - "Assets/Scripts/Data/CaptureItemData.cs", - "Assets/Scripts/Core/WeatherSystem.cs", - "Assets/Scripts/Core/GameClock.cs", - "Assets/Scripts/Core/WorldStateProvider.cs", - "Assets/Scripts/UI/CaptureChoiceUI.cs", - ], - "ui-dev": [ - "Assets/Scripts/UI/MainMenuManager.cs", - "Assets/Scripts/UI/BattleScreenUI.cs", - "Assets/Scripts/UI/RaidBattleUI.cs", - "Assets/Scripts/UI/CaptureChoiceUI.cs", - "Assets/Scripts/UI/PlayUIConfig.cs", - "Assets/Scripts/UI/PlayUIRefs.cs", - "Assets/Scripts/UI/PlayerStatusHUD.cs", - "Assets/Scripts/UI/KeyGuideHUD.cs", - "Assets/Scripts/UI/TrainingUI.cs", - "Assets/Scripts/UI/CollectionUI.cs", - "Assets/Scripts/UI/BattleTeamUI.cs", - "Assets/Scripts/UI/RegionMapUI.cs", - "Assets/Scripts/UI/SettingsPanel.cs", - ], - "data-architect": [ - "Assets/Scripts/Data/InsectData.cs", - "Assets/Scripts/Data/InsectDatabase.cs", - "Assets/Scripts/Data/InsectSkill.cs", - "Assets/Scripts/Data/InsectLearnableSkill.cs", - "Assets/Scripts/Data/InsectElement.cs", - "Assets/Scripts/Data/InsectRarity.cs", - "Assets/Scripts/Data/InsectSpawnCondition.cs", - "Assets/Scripts/Data/InsectLevelCurve.cs", - "Assets/Scripts/Data/InsectRewardCalculator.cs", - "Assets/Scripts/Data/InsectLoreEntry.cs", - "Assets/Scripts/Data/InsectLoreService.cs", - "Assets/Scripts/Data/InsectLoreBootstrapper.cs", - "Assets/Scripts/Data/ItemData.cs", - "Assets/Scripts/Data/ItemDatabase.cs", - "Assets/Scripts/Data/CaptureItemData.cs", - "Assets/Scripts/Data/ItemRarityPalette.cs", - "Assets/Scripts/Data/RegionData.cs", - "Assets/Scripts/Data/SubAreaData.cs", - "Assets/Scripts/Data/TrainingMethod.cs", - "Assets/Scripts/Core/PlayerProgressSaveService.cs", - "Assets/Scripts/Core/CloudSaveManager.cs", - "Assets/Scripts/Core/PlayerProgressData.cs", - "Assets/Scripts/Core/PlayerCandyInventory.cs", - "Assets/Scripts/Core/PlayerCurrencyWallet.cs", - "Assets/Scripts/Core/PlayerItemInventory.cs", - "Assets/Scripts/Core/GameConstants.cs", - "Assets/Scripts/Dex/DexSaveData.cs", - "Assets/Scripts/Dex/DexSaveService.cs", - "Assets/Scripts/Dex/DexRecord.cs", - ], - "visual-dev": [ - "Assets/Scripts/Spawning/InsectEntity.cs", - "Assets/Scripts/Battle/BattleArenaController.cs", - "Assets/Scripts/UI/BattleScreenUI.cs", - "Assets/Scripts/UI/RaidBattleUI.cs", - "Assets/Scripts/Data/ItemRarityPalette.cs", - "Assets/Scripts/Core/ProceduralAudioGenerator.cs", - "Assets/Scripts/Dex/RarityIconProvider.cs", - ], -} - -def norm(p): - return p.replace(chr(92), "/") - -result = subprocess.run(["find", "Assets/Scripts", "-name", "*.cs"], capture_output=True, text=True) -actual_files = set(norm(l.strip()) for l in result.stdout.strip().split("\n") if l.strip()) - -covered = set() -for name, files in agents.items(): - covered.update(norm(f) for f in files) - -print("=== 1. 에이전트에 명시됐지만 실제 없는 파일 ===") -missing = covered - actual_files -if missing: - for f in sorted(missing): - print(f" MISSING: {f}") -else: - print(" 없음 (모두 존재)") - -print() -print("=== 2. 어떤 에이전트도 담당하지 않는 파일 ===") -uncovered = actual_files - covered -for f in sorted(uncovered): - print(f" {f}") -print(f" 총 {len(uncovered)}개 / {len(actual_files)}개") - -print() -print("=== 3. 여러 에이전트가 동시 담당하는 파일 ===") -file_owners = {} -for name, files in agents.items(): - for f in files: - nf = norm(f) - file_owners.setdefault(nf, []).append(name) -for f, owners in sorted(file_owners.items()): - if len(owners) > 1: - fname = f.split("/")[-1] - print(f" {fname:40s} <- {', '.join(owners)}") diff --git a/.claude/scripts/audit_candidates.py b/.claude/scripts/audit_candidates.py new file mode 100644 index 0000000..0da8e21 --- /dev/null +++ b/.claude/scripts/audit_candidates.py @@ -0,0 +1,499 @@ +"""audit 후보 자동 발굴 — Uncovered 큐가 비면 여기서 다시 채운다. + +배경: Uncovered는 사람이 손으로 유지하는 큐였다. 2026-05-27 라운드 이후 +Social/PvP/NPC/Village/IAP/Minimap 기능이 대량 유입됐는데 아무도 큐를 채우지 +않았고, 큐가 0이 되자 audit_flow_inject/audit_reminder 두 훅이 함께 침묵해 +자동화 전체가 멈췄다. 완료 항목을 옮기는 것만으로는 다음 소진 때 같은 일이 +반복되므로, 큐 보충 자체를 자동화한다. + +기처리 영역은 audit-progress.md 본문에서 읽는다 — 하드코딩 목록이 없으므로 +stale해질 수 없다. + +후보는 두 종류다: + 신규 = 진척 문서에 이름이 한 번도 안 나온 .cs + 재감사 = 나왔지만 **그 감사 이후 크게 바뀐** .cs + +재감사가 왜 필요한가 +-------------------- +예전엔 `stem in reviewed`(원문 substring)로 이름이 한 번이라도 스치면 **영구 제외**했다. +"처리했는데 그 뒤로 바뀐 파일"이라는 개념이 없어서, 2026-08-03에 실측했을 때 감사 이후 +수정된 파일이 76개인데도 "후보 0건"을 냈다. 그 사각지대에 있던 InsectBattleController +(2026-05-20 감사 후 StartDuel·버프 상한·DuelEnded 유입)에서 곧바로 P1이 나왔다 — +의상·아이템 보너스가 전투 첫 턴에 안 붙는 결함이었다. + +재감사 우선순위는 **점수가 아니라 감사 이후 변경량(git numstat)**이다. 점수는 파일의 +성격(프레임 할당·미캐싱 조회 등)을 말할 뿐 "감사 이후 무엇이 달라졌는가"를 말하지 않는다. +MIN_RECHURN 미만으로 바뀐 파일은 다시 올리지 않는다 — 오타 한 줄에 전 파일이 큐로 +되돌아오면 큐가 의미를 잃는다. + +git을 못 읽으면 재감사 판정을 건너뛰고 신규 후보만 낸다(그 사실을 출력에 적는다). +파일 mtime은 체크아웃마다 리셋돼 폴백으로 쓰지 않는다. + +채점은 audit 스킬이 실제로 잡아온 회귀 클래스를 그대로 쓴다: + hot = OnGUI/Update 안의 new GUIStyle/Color/Rect (매 프레임 GC 압박) + find = FindFirstObjectByType / GameObject.Find (미캐싱 조회) + leak = 이벤트 += 대비 -= 부족분 (구독 해제 누락) + inst = 싱글턴 .Instance 직접 사용 (null 가드 필요) + score = hot*3 + find*2 + leak*2 + inst + +사용법: + python -X utf8 .claude/scripts/audit_candidates.py # 표로 확인 + python -X utf8 .claude/scripts/audit_candidates.py --emit-md # 큐에 붙일 - [ ] 줄 + python -X utf8 .claude/scripts/audit_candidates.py --emit-md --top 15 +""" + +import datetime +import io +import os +import re +import subprocess +import sys +from glob import glob + +try: + sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace") +except Exception: + pass + +ROOT = os.environ.get("CLAUDE_PROJECT_DIR") or os.getcwd() +PROGRESS = os.path.join(ROOT, ".claude", "audit-progress.md") +ARCHIVE_DIR = os.path.join(ROOT, ".claude", "audit-archive") +SCRIPTS_DIR = os.path.join(ROOT, "Assets", "Scripts") + +MIN_LOC = 80 # 이보다 작은 파일은 audit 라운드를 열 만한 표면이 없다 + +# 재감사 하한 — 감사 이후 이만큼(추가+삭제 줄)은 바뀌어야 다시 볼 값어치가 있다. +# 0으로 두면 오타 수정 한 줄에도 전 파일이 큐로 되돌아와 큐가 의미를 잃는다. +MIN_RECHURN = 40 + + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from cs_strip import strip_cs # noqa: E402,F401 — 주석/문자열 제거는 cs_strip이 소유 + + + +def read_reviewed_text(): + """이미 다룬 영역을 판별할 근거 텍스트(본문 + 아카이브).""" + chunks = [] + for path in [PROGRESS] + sorted(glob(os.path.join(ARCHIVE_DIR, "*.md"))): + try: + with open(path, "r", encoding="utf-8", errors="replace") as fh: + chunks.append(fh.read()) + except Exception: + pass + return "\n".join(chunks) + + +# ── 재감사(re-audit) ── +# +# 기존엔 이름이 진척 문서에 한 번이라도 스치면 `stem in reviewed`(substring)로 **영구 제외**됐다. +# "처리했는데 그 뒤로 크게 바뀐 파일"이라는 개념이 없어서, 2026-08-03에 실측했을 때 +# **감사 이후 수정된 파일이 76개**인데 스크립트는 "후보 0건"을 냈다. 그 사각지대에 있던 +# `InsectBattleController`(2026-05-20 감사 후 StartDuel·버프 상한·DuelEnded 유입)에서 +# 곧바로 P1이 나왔다 — 의상·아이템 보너스가 전투 첫 턴에 안 붙는 결함이었다. +# +# 그래서 "이름이 있으면 제외"는 그대로 두되(신규 후보 집합을 넓히지 않는다), +# **마지막 감사일 < 마지막 수정일**이면 재감사 후보로 되살린다. + +DATE_RE = re.compile(r"(\d{4}-\d{2}-\d{2})") +# Covered 인덱스: - [x] Foo + Bar (P1:1, 2026-05-21) — 서술 +COVERED_RE = re.compile(r"^-\s*\[x\]\s*([^(\n]+?)\s*\(([^)\n]*)\)") +# Round Log: - 2026-08-03: Foo (score 0 …) — 서술 +ROUNDLOG_RE = re.compile(r"^-\s*(\d{4}-\d{2}-\d{2}):\s*([^(\n—-]+)") +NAME_RE = re.compile(r"\b([A-Z][A-Za-z0-9_]*)\b") + + +def read_reviewed_dates(text): + """{클래스명: 가장 늦은 감사일}. 서술 본문이 아니라 **항목 머리**에서만 읽는다. + + 프로즈에 스친 이름까지 세면 "다른 라운드에서 언급됨 = 최근 감사됨"이 되어 + 정작 오래된 파일의 재감사를 막는다. + """ + dates = {} + + def put(names_blob, day): + for name in NAME_RE.findall(names_blob): + if len(name) < 3: + continue + if name not in dates or day > dates[name]: + dates[name] = day + + for line in text.splitlines(): + m = COVERED_RE.match(line) + if m: + found = DATE_RE.findall(m.group(2)) + if found: + put(m.group(1), max(found)) + continue + m = ROUNDLOG_RE.match(line) + if m: + put(m.group(2), m.group(1)) + return dates + + +def git_history(): + """{저장소 상대경로: [(날짜, 변경줄수), …]} — 최신순. git 이력 + 워킹트리 변경. + + 날짜만으로 재감사를 정하면 "5월 감사 후 3줄만 고친 파일"이 "통째로 다시 쓴 파일"과 + 같은 무게가 된다. 그래서 커밋별 변경량(numstat)까지 들고 와, 감사일 이후의 변경량을 + 합쳐 우선순위를 매긴다. + + git을 못 쓰면 빈 dict를 돌려주고 호출부는 재감사 없이 기존 동작으로 되돌아간다 + (파일 mtime은 체크아웃마다 리셋돼 신뢰할 수 없으므로 폴백으로 쓰지 않는다). + """ + out = {} + try: + log = subprocess.run( + ["git", "log", "--format=%x01%ad", "--date=short", "--numstat", "--", "Assets/Scripts"], + cwd=ROOT, capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=180, + ) + if log.returncode != 0: + return {} + day = None + for line in log.stdout.splitlines(): + if line.startswith("\x01"): + day = line[1:].strip() + continue + parts = line.split("\t") + if len(parts) != 3 or not day: + continue + add, dele, rel = parts + if not rel.endswith(".cs"): + continue + n = (int(add) if add.isdigit() else 0) + (int(dele) if dele.isdigit() else 0) + out.setdefault(rel.replace("\\", "/"), []).append((day, n)) + + # 아직 커밋 안 된 변경은 "오늘" 자로 맨 앞에 얹는다. + today = datetime.date.today().isoformat() + diff = subprocess.run( + ["git", "diff", "--numstat", "HEAD", "--", "Assets/Scripts"], + cwd=ROOT, capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=120, + ) + if diff.returncode == 0: + for line in diff.stdout.splitlines(): + parts = line.split("\t") + if len(parts) != 3: + continue + add, dele, rel = parts + if not rel.endswith(".cs"): + continue + n = (int(add) if add.isdigit() else 0) + (int(dele) if dele.isdigit() else 0) + out.setdefault(rel.replace("\\", "/"), []).insert(0, (today, n)) + except Exception: + return {} + return out + + +def churn_since(records, since_day): + """감사일 **이후** 커밋들의 변경 줄 수 합. 같은 날 감사·수정은 세지 않는다.""" + return sum(n for day, n in records if day > since_day) + + +def has_frame_method(cleaned): + """OnGUI/Update/LateUpdate/FixedUpdate 보유 여부 = 매 프레임 실행 파일인가.""" + return bool( + re.search( + r"\bvoid\s+(?:OnGUI|Update|LateUpdate|FixedUpdate)\s*\([^)]*\)", + cleaned, + ) + ) + + +INIT_SIG = re.compile(r"\bvoid\s+(?:Awake|Start|OnEnable|AutoWire)\s*\([^)]*\)\s*\{") + + +def strip_init_bodies(cleaned): + """Awake/Start/OnEnable/AutoWire 본문을 제거한다. + + 부트스트랩 시점의 조회는 이 프로젝트의 정상 패턴이다(AutoWire 캐싱). 그걸 빼지 않으면 + 초기화 전용 파일이 '미캐싱 조회'로 큐 최상위에 올라온다 — SceneAutoWire가 실제로 그렇게 + 올라왔고(106줄 전체가 Awake, 조회 40건) 라운드를 통째로 낭비시켰다. + """ + while True: + m = INIT_SIG.search(cleaned) + if not m: + return cleaned + depth = 1 + i = m.end() + while i < len(cleaned) and depth > 0: + c = cleaned[i] + if c == "{": + depth += 1 + elif c == "}": + depth -= 1 + i += 1 + cleaned = cleaned[:m.start()] + cleaned[i:] + + +def scene_script_guids(root): + """씬에 실제로 배치된 스크립트의 GUID 집합.""" + guids = set() + for scene in glob(os.path.join(root, "Assets", "**", "*.unity"), recursive=True): + try: + with open(scene, "r", encoding="utf-8", errors="replace") as fh: + guids.update(re.findall(r"m_Script:.*?guid:\s*([0-9a-f]{32})", fh.read())) + except Exception: + pass + return guids + + +def file_guid(cs_path): + try: + with open(cs_path + ".meta", "r", encoding="utf-8", errors="replace") as fh: + m = re.search(r"guid:\s*([0-9a-f]{32})", fh.read()) + return m.group(1) if m else None + except Exception: + return None + + +_ALL_CODE = None + + +def code_mentions(stem, self_path): + """자기 자신 말고 다른 .cs가 이 클래스명을 **실제 코드에서** 언급하는가. + + 주석은 제거하고 본다. 주석 처리로 꺼둔 배선도 '언급'으로 세면 dead code가 살아있는 + 것으로 잡힌다 — CaptureItemSpawner가 실제로 그랬다(PlaySceneBootstrap이 + `// 필드 아이템 스폰 비활성화` 주석으로 꺼놨는데 그 주석 때문에 살아있는 것으로 판정). + + 한계: 참조를 1단계만 본다. A가 dead인데 A만 B를 부르면 B는 여전히 살아있는 것으로 + 잡힌다(CaptureItemPickup ← CaptureItemSpawner). 전이 폐쇄까지 하려면 반복 계산이 + 필요한데, 후보 발굴 용도에는 과하다 — Explore가 파일을 열면 금방 드러난다. + """ + global _ALL_CODE + if _ALL_CODE is None: + _ALL_CODE = {} + for dirpath, _d, filenames in os.walk(SCRIPTS_DIR): + for fn in filenames: + if not fn.endswith(".cs"): + continue + p = os.path.join(dirpath, fn) + try: + with open(p, "r", encoding="utf-8", errors="replace") as fh: + _ALL_CODE[p] = strip_cs(fh.read()) + except Exception: + pass + # 경로는 반드시 정규화해서 비교한다. os.walk는 OS 구분자(Windows면 백슬래시)를 주는데 + # 호출부가 슬래시 경로를 넘기면 자기 자신이 "다른 파일"로 잡혀 항상 True가 된다. + me = os.path.normcase(os.path.abspath(self_path)) + needle = re.compile(r"\b" + re.escape(stem) + r"\b") + for p, body in _ALL_CODE.items(): + if os.path.normcase(os.path.abspath(p)) == me: + continue + if needle.search(body): + return True + return False + + +def score_file(path, scene_guids): + try: + with open(path, "r", encoding="utf-8", errors="replace") as fh: + text = fh.read() + except Exception: + return None + + loc = text.count("\n") + 1 + if loc < MIN_LOC: + return None + + cleaned = strip_cs(text) + + # dead code 제외: MonoBehaviour인데 씬에 배치되지도, 다른 코드에서 언급되지도 않으면 + # 실행 자체가 되지 않으므로 점검할 가치가 없다. SceneAutoWire가 이 필터가 없어서 + # 큐 상위를 차지했다(조회 40건이 전부 실행되지 않는 Awake 안에 있었다). + if "MonoBehaviour" in cleaned: + guid = file_guid(path) + stem = os.path.splitext(os.path.basename(path))[0] + in_scene = guid is not None and guid in scene_guids + if not in_scene and not code_mentions(stem, path): + return None + + # hot: 프레임 메서드를 가진 파일의 **힙** 할당 총량. + # OnGUI 본문만 보면 안 된다 — 이 코드베이스의 OnGUI는 대개 DrawPanel() 같은 + # 하위 메서드로 위임하고, 과거 라운드가 실제로 잡아온 핫스팟도 그 하위 + # 메서드들이었다(DrawInsectItem 등). + # + # new Color/Rect/Vector3는 **세지 않는다** — struct라 스택 할당이고 GC가 없다. + # 옛 채점은 이걸 세서 6라운드 연속 1위 근거가 전부 거짓양성이었다 + # (WorldFieldMultiplayerUI 51, SubAreaEnvironment 46, AccountSettingsUI 37 …). + hot = 0 + if has_frame_method(cleaned): + hot += len(re.findall(r"\bnew\s+(?:GUIStyle|GUIContent|Texture2D|Material)\b", cleaned)) * 2 + hot += len(re.findall(r"\bnew\s+(?:List|Dictionary|HashSet)\s*<", cleaned)) + + # find: 초기화 본문(Awake/Start/OnEnable/AutoWire)을 뺀 나머지의 조회만 센다. + runtime = strip_init_bodies(cleaned) + find = len(re.findall(r"\bFindFirstObjectByType\b|\bFindObjectOfType\b|GameObject\.Find\b", runtime)) + + subs = len(re.findall(r"\+=\s*(?:new\s+\w+\s*\()?\s*(?:On|Handle)\w+", cleaned)) + unsubs = len(re.findall(r"-=\s*(?:new\s+\w+\s*\()?\s*(?:On|Handle)\w+", cleaned)) + leak = max(0, subs - unsubs) + + inst = len(re.findall(r"\b\w+\.Instance\b", cleaned)) + + score = hot * 3 + find * 2 + leak * 2 + inst + return { + "path": path, + "loc": loc, + "hot": hot, + "find": find, + "leak": leak, + "inst": inst, + "score": score, + } + + +def reason_of(r): + bits = [] + if r["hot"]: + bits.append(f"프레임 할당 {r['hot']}") + if r["find"]: + bits.append(f"미캐싱 조회 {r['find']}") + if r["leak"]: + bits.append(f"구독 해제 누락 의심 {r['leak']}") + if r["inst"]: + bits.append(f"싱글턴 참조 {r['inst']}") + return ", ".join(bits) if bits else "표면 점검" + + +def _write_uncovered(lines): + """`audit-progress.md`의 `## Uncovered` 절을 후보 목록으로 갈아 끼운다. + + **그 절만 바꾼다** — Covered·Round Log는 손대지 않는다(라운드 서술이 거기 쌓인다). + 안내 문단(`>` 인용)은 큐가 비어 있을 때의 설명이라 함께 걷어낸다. + """ + path = PROGRESS + try: + with open(path, encoding="utf-8") as fh: + src = fh.read() + except OSError as exc: + print(f"\n[!] audit-progress.md를 못 읽었다: {exc}") + return + + # **줄 시작에 앵커한다.** 파일 상단 안내문이 `` `## Uncovered`의 `- [ ]` 개수가 ``처럼 + # 그 문자열을 **산문 안에** 들고 있어서, 앵커가 없으면 거기에 먼저 걸린다 — + # 그러면 `.*?`가 다음 `## `(= Covered)까지 먹어 **안내문 5줄을 통째로 날린다**(실측). + m = re.search(r"^(## Uncovered[^\n]*\n)(.*?)(?=^## )", src, re.S | re.M) + if not m: + print("\n[!] '## Uncovered' 절을 못 찾았다 — 구조가 바뀌었다면 파서부터 확인할 것.") + return + + body = "\n" + "\n".join(lines) + "\n" + out = src[:m.start(2)] + body + src[m.end(2):] + try: + with open(path, "w", encoding="utf-8", newline="\n") as fh: + fh.write(out) + except OSError as exc: + print(f"\n[!] audit-progress.md를 못 썼다: {exc}") + return + print(f"\n→ audit-progress.md의 Uncovered에 {len(lines)}건 기록했다.") + + +def main(): + emit_md = "--emit-md" in sys.argv + # `--emit-md`는 stdout에만 찍는다. 그걸 모르고 출력만 보고 넘어가면 큐가 빈 채로 남고, + # `- [ ]`가 0이면 audit_flow_inject·audit_reminder 훅이 **함께 침묵해** 자동 플로우가 멈춘다 + # (2026-05-27 ~ 07-17에 실제로 그렇게 멈춰 있었다). --write가 그 손옮김을 없앤다. + write_md = "--write" in sys.argv + if write_md: + emit_md = True + top = 15 + if "--top" in sys.argv: + try: + top = int(sys.argv[sys.argv.index("--top") + 1]) + except Exception: + pass + + reviewed = read_reviewed_text() + audited_on = read_reviewed_dates(reviewed) + history = git_history() + scene_guids = scene_script_guids(ROOT) + + files = [] + for dirpath, _dirnames, filenames in os.walk(SCRIPTS_DIR): + for fn in filenames: + if fn.endswith(".cs"): + files.append(os.path.join(dirpath, fn)) + + results = [] + for path in sorted(files): + stem = os.path.splitext(os.path.basename(path))[0] + rel_repo = os.path.relpath(path, ROOT).replace("\\", "/") + restale = None + if stem in reviewed: + # 이미 다룬 영역이지만, 감사 이후 **의미 있게** 수정됐으면 재감사 후보로 되살린다. + a = audited_on.get(stem) + recs = history.get(rel_repo) + if not (a and recs): + continue + m = recs[0][0] + churn = churn_since(recs, a) + if m <= a or churn < MIN_RECHURN: + continue + restale = (a, m, churn) + # score가 0이어도 미검토 파일은 후보다. 채점은 **우선순위**를 매기는 도구이지 + # clean 판정 도구가 아니다 — 실제로 2026-07-17 라운드들에서 채점 근거(struct 할당)는 + # 매번 틀렸는데도 지목된 파일마다 진짜 P0/P1이 나왔다. 점수로 거르면 그런 파일을 + # 통째로 놓친다. 제외는 dead code(score_file이 None 반환)만 한다. + r = score_file(path, scene_guids) + if r: + r["stem"] = stem + r["rel"] = os.path.relpath(path, SCRIPTS_DIR).replace("\\", "/") + r["restale"] = restale + results.append(r) + + # 미검토(신규)를 먼저 — 아예 본 적이 없어 미지의 폭이 더 크다. 신규끼리는 점수순. + # 재감사는 **감사 이후 변경량**이 1순위다. 점수(hot/find/inst)는 파일의 성격을 말할 뿐 + # "감사 이후 무엇이 달라졌는가"를 말해주지 않는다 — 5월 감사 후 3줄 고친 모놀리스가 + # 통째로 다시 쓴 파일보다 위에 오는 걸 막는다. 변경량이 같으면 점수로 가른다. + results.sort(key=lambda x: ( + x["restale"] is not None, + -(x["restale"][2] if x["restale"] else 0), + -x["score"], + )) + picked = results[:top] + + if not picked: + print(f"후보 0건 — 미검토 .cs가 없고, 감사 이후 {MIN_RECHURN}줄 이상 바뀐 .cs도 없습니다.") + if not history: + print("(git 이력을 못 읽어 재감사 판정을 건너뛰었습니다 — 신규 파일만 본 결과입니다.)") + return 0 + + if emit_md: + lines = [] + for r in picked: + if r["restale"]: + a, m, churn = r["restale"] + lines.append( + f"- [ ] {r['stem']} 재감사 ({r['rel']}, {r['loc']}줄, score {r['score']}) " + f"— {a} 감사 이후 {m}까지 {churn}줄 변경" + ) + else: + lines.append( + f"- [ ] {r['stem']} ({r['rel']}, {r['loc']}줄, score {r['score']}) " + f"— {reason_of(r)}" + ) + for line in lines: + print(line) + if write_md: + _write_uncovered(lines) + else: + print("\n(stdout 전용 — 파일에 쓰려면 --write)") + else: + fresh = [r for r in results if not r["restale"]] + stale = [r for r in results if r["restale"]] + print(f"후보 {len(results)}개 — 미검토 {len(fresh)} / 재감사 {len(stale)} (상위 {len(picked)}개 표시)\n") + print(f"{'score':>5} {'LOC':>5} {'hot':>4} {'find':>4} {'leak':>4} {'inst':>4} file") + for r in picked: + mark = (f" [재감사 {r['restale'][0]}→{r['restale'][1]}, {r['restale'][2]}줄]" + if r["restale"] else "") + print( + f"{r['score']:>5} {r['loc']:>5} {r['hot']:>4} {r['find']:>4} " + f"{r['leak']:>4} {r['inst']:>4} {r['rel']}{mark}" + ) + print("\n큐에 넣을 마크다운: --emit-md") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.claude/scripts/blight_lint.py b/.claude/scripts/blight_lint.py new file mode 100644 index 0000000..a7401c5 --- /dev/null +++ b/.claude/scripts/blight_lint.py @@ -0,0 +1,329 @@ +"""blight-lint — 명부회 오염 거점이 **조용히** 깨지는 지점만 검사한다. + +이 기능의 실패는 전부 무증상이다. 예외도 경고도 안 나고, 거점이 그냥 영원히 안 무너지거나 +정화해도 아무것도 안 돌아온다. 기존 검사기가 못 보는 것만 여기서 본다 — +비트 무결성은 story_lint(22검사), 퀘스트 배선은 quest_lint(10검사)가 이미 본다. + +가장 큰 급소 둘: + +**1. 재도전 예외가 사라지면 기존 세이브에 기능이 안 보인다.** +`CanBossDuel`은 이미 이긴 간부에게 무조건 false를 돌려준다. 두 하수를 이미 이긴 세이브 +(2막 진행자 대부분)는 그 예외가 없으면 산·유적의 거점을 영영 부수지 못한다. + +**2. 스폰 상한이 0으로 내려가면 캠페인이 멈춘다.** +오염 리전에서의 포획·전투를 조건으로 건 비트가 넷이고, 같은 리전의 1막 비트도 특정 종 +포획을 요구한다(산의 아폴로나비, 유적의 유물풍뎅이). 곤충이 안 뜨면 전부 도달 불가다. + +exit 0 = 통과, 1 = 위반, 2 = 추출 실패(구조가 바뀌었으니 파서부터 확인할 것). +""" +import io +import os +import re +import sys + +ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +import game_facts as gf # noqa: E402 + +BEAT_ROLES = ("arrive", "sign", "confront", "clash", "restore") + + +def read(rel): + path = os.path.join(ROOT, rel) + if not os.path.exists(path): + raise gf.ExtractorBroken(rel + ": 파일 없음 — 경로가 바뀌었으면 이 검사기도 고칠 것") + with io.open(path, encoding="utf-8", errors="replace") as f: + return f.read() + + +def code_only(src): + """주석을 걷어낸 소스. 검사기는 코드를 봐야지 설명을 보면 안 된다 — + "이건 쓰지 않는다"고 적어 둔 주석 때문에 FAIL이 났다(실제로 겪음).""" + src = re.sub(r"/\*.*?\*/", "", src, flags=re.S) + return re.sub(r"//.*", "", src) + + +def _method_body(src, signature): + """선언 줄부터 같은 들여쓰기의 다음 멤버 선언 전까지. 메서드 **안**에 있는지를 + 묻는 검사가 있어서 필요하다 — 파일 전체 grep은 옆 메서드의 코드도 같이 본다.""" + idx = src.find(signature) + if idx < 0: + raise gf.ExtractorBroken( + "InsectBattleController에서 '" + signature + "'를 못 찾았다 — 이름이 바뀌었으면 검사기부터 고칠 것") + tail = src[idx + len(signature):] + nxt = re.search(r"\n (?:private|public|internal|protected)\s", tail) + return tail[:nxt.start()] if nxt else tail + + +def _boss_duels(): + src = read("Assets/Scripts/NPC/NpcBossDuels.cs") + out = [] + for block in re.findall(r"new\s+BossDuel\s*\{(.*?)\}", src, re.S): + npc = re.search(r'storyNpcId\s*=\s*"([^"]+)"', block) + if npc: + out.append(npc.group(1)) + if not out: + raise gf.ExtractorBroken("NpcBossDuels에서 대결을 하나도 못 읽었다 — 표 구조가 바뀌었는가?") + return set(out) + + +def _village_anchors(): + """{(storyNpcId, regionId)} — VillageBuilder가 세우는 스토리 NPC 앵커.""" + src = read("Assets/Scripts/Core/VillageBuilder.cs") + src = re.sub(r"//[^\n]*", "", src) # 주석에 적힌 ID는 배치가 아니다 + out = set() + for m in re.finditer(r'regionId\s*=\s*"(\w+)"\s*,\s*\n\s*storyNpcId\s*=\s*"(\w+)"', src): + out.add((m.group(2), m.group(1))) + if not out: + raise gf.ExtractorBroken( + "VillageBuilder에서 스토리 NPC 앵커를 못 읽었다 — 배치 형태가 바뀌었는가?") + return out + + +def main(): + signals = [] + + def add(name, expect, got, ok): + signals.append((name, expect, got, "PASS" if ok else "FAIL")) + + sites = gf.blight_sites() + site_regions = [r for r, _b, _n, _i in sites] + + # 1. 거점 보스가 대결 표에 있는가 — 없으면 도전 자체가 안 열려 영구 오염이다. + duel_bosses = _boss_duels() + missing = [r + ":" + b for r, b, _n, _i in sites if b not in duel_bosses] + add("거점 보스 ↔ NpcBossDuels 표", "0건 미등재", + str(len(missing)) + "건 " + str(missing) if missing else "0건 (거점 %d개)" % len(sites), + not missing) + + # 2. 거점 보스가 그 리전에 실제로 서 있는가. + anchors = _village_anchors() + absent = [r + ":" + b for r, b, _n, _i in sites if (b, r) not in anchors] + add("거점 보스가 그 리전에 배치됨", "0건 부재", + str(len(absent)) + "건 " + str(absent) if absent else "0건 (앵커 %d개 대조)" % len(anchors), + not absent) + + # 3. 한 보스가 거점 둘을 맡으면 "그자를 꺾으면 그 거점이 닫힌다"가 성립하지 않는다. + seen, dup = set(), [] + for r, b, _n, _i in sites: + if b in seen: + dup.append(b + "(" + r + ")") + seen.add(b) + add("한 보스 = 한 거점", "0건 중복", + str(len(dup)) + "건 " + str(dup) if dup else "0건", not dup) + + # 4. 귀환종이 곤충 DB + 그 리전 풀에 있는가 — 없으면 정화해도 아무것도 안 돌아온다. + all_ids = gf.all_insect_ids() + pools = {rid: ids for rid, _lv, ids in gf.region_pools()} + bad = [] + for r, _b, _n, ins in sites: + if not ins: + bad.append(r + ": 귀환종 없음") + elif ins not in all_ids: + bad.append(r + ": '" + ins + "' DB에 없음") + elif ins not in pools.get(r, []): + bad.append(r + ": '" + ins + "' 그 리전 풀에 없음") + add("귀환종 ↔ 곤충 DB · 리전 풀", "0건", + str(len(bad)) + "건 " + str(bad) if bad else "0건 (곤충 %d종 대조)" % len(all_ids), + not bad) + + # 5. 거점마다 bl_{리전}_* 5비트 전부 — 빠지면 그 거점만 서사 없이 침묵한다. + beats = {b["beatId"]: b for b in gf.story_beats()} + lack = [] + for r in site_regions: + for role in BEAT_ROLES: + bid = "bl_" + r + "_" + role + if bid not in beats: + lack.append(bid) + add("거점당 bl_* 5비트", "0건 누락", + str(len(lack)) + "건 " + str(lack) if lack else "0건 (거점 %d개 × 5)" % len(sites), + not lack) + + # 6. 도착 비트의 진행 게이트. + # story_lint 검사 17(챕터 도달 순서)은 chapterId가 ch{N}/fin이 아니면 건너뛴다. + # 'bl'이 정확히 그 사각지대라, 게이트가 없으면 시작 지역에서 뒷 이야기가 새어도 아무도 안 잡는다. + ungated = [] + for r in site_regions: + bid = "bl_" + r + "_arrive" + b = beats.get(bid) + if b is not None and not (b.get("requiredBeatId") or b.get("requiredQuestId")): + ungated.append(bid) + add("도착 비트 진행 게이트 (story_lint 17의 사각지대)", "0건 무게이트", + str(len(ungated)) + "건 " + str(ungated) if ungated else "0건", not ungated) + + # 7. RegionCleansed param이 실제 거점 리전인가. + # story_lint 검사 3은 트리거별 if/elif 체인이라 이 타입 분기가 없어 **무조건 통과**한다. + stray = [] + for bid, b in beats.items(): + t = b.get("trigger") or {} + if t.get("type") != "RegionCleansed": + continue + if t.get("param") not in site_regions: + stray.append(bid + ":" + str(t.get("param"))) + add("RegionCleansed param ↔ 거점 리전", "0건 불일치", + str(len(stray)) + "건 " + str(stray) if stray else "0건 (거점 리전 %s)" % site_regions, + not stray) + + # 8. 정화 발화가 DeferTrigger 경유인가 — 직접 쏘면 보상 패널을 덮고 모달 중엔 유실된다. + director = code_only(read("Assets/Scripts/Story/StoryDirector.cs")) + deferred = "DeferTrigger(TriggerRegionCleansed" in director + add("정화 발화 = DeferTrigger 경유", "직접 EvaluateTriggers 금지", + "DeferTrigger 사용" if deferred else "**직접 발화 — 보상 패널을 덮고 모달 중엔 유실**", + deferred) + + # 9. 재진입 재발화 경로. + # 정화 순간에 그 비트가 자격 미달이면(대치 비트 미열람) 트리거가 소비돼 사라진다. + # 리전에 다시 들어올 때 다시 쏘는 것이 유일한 회복 경로다. + # RouteTrigger도 인정한다 — 대화·전투 화면 중이면 큐로 보내고 끝나면 흘리는 관문이라 + # "다시 쏜다"는 성질이 같다(직접 EvaluateTriggers는 검사 8이 따로 막는다). + refire = re.search(r"IsCleansed\(.{0,60}?(?:Route|Evaluate)Trigger[s]?\(TriggerRegionCleansed", + director, re.S) is not None + add("정화 리전 재진입 시 재발화", "존재", + "있음" if refire else "**없음 — 놓친 세이브가 정화 비트를 영영 못 본다**", refire) + + # 10. 오염 리전 재도전 예외 — 이게 없으면 기존 세이브에 기능 자체가 안 보인다. + duel = code_only(read("Assets/Scripts/NPC/NpcDuelController.cs")) + rematch = "IsBlightBossHere" in duel and "IsBossDefeated" in duel + add("오염 리전 보스 재도전 예외", "존재", + "있음" if rematch else "**없음 — 이미 이긴 세이브는 거점을 영영 못 부순다**", rematch) + + # 11. 재도전 보상 재지급 가드. + guard = (re.search(r"bool\s+firstWin\s*=\s*defeatedBosses\.Add", duel) is not None + and re.search(r"if\s*\(\s*firstWin\s*&&", duel) is not None) + add("재도전 승리 보상 재지급 가드", "존재", + "있음(firstWin)" if guard else "**없음 — 재도전마다 보상이 다시 지급된다**", guard) + + # 12. 정화 호출부. + cleanse = "CleanseByBoss" in duel + add("정화 호출부(NpcDuelController)", "존재", + "있음" if cleanse else "**없음 — 이겨도 정화되지 않는다**", cleanse) + + # 13. 스폰 상한이 전부 BlightPolicy 경유인가. + # CountActiveInRegion(...) 비교가 곧 "이 리전에 더 띄울까"다. 하나라도 날것의 + # maxActivePerRegion을 쓰면 그 경로만 오염을 무시해 스폰 편향이 생긴다. + spawner = code_only(read("Assets/Scripts/Spawning/InsectSpawner.cs")) + raw_cmp = re.findall(r"CountActiveInRegion\([^)]*\)\s*[<>]=?\s*maxActivePerRegion", spawner) + add("리전 상한 비교 = BlightPolicy 경유", "0건 날것", + str(len(raw_cmp)) + "건 " + str(raw_cmp) if raw_cmp else "0건 (전부 RegionCap 경유)", + not raw_cmp) + + # 14. 스폰 하한 — 0이면 그 리전의 포획·전투 비트가 전부 도달 불가가 된다. + policy = code_only(read("Assets/Scripts/Core/BlightPolicy.cs")) + m = re.search(r"MinActive\s*=\s*(\d+)", policy) + min_active = int(m.group(1)) if m else -1 + add("스폰 하한 MinActive", ">= 1", + str(min_active) if min_active >= 1 else "**%d — 캠페인이 영구 정지한다**" % min_active, + min_active >= 1) + + # 15. BlightPolicy에 리전 ID 리터럴 0건. + # "어느 리전이 오염 대상인가"는 RegionData가 답한다. 하드코딩 목록은 이 저장소에서 + # 세 번 조용히 어긋났다(RegionDefinitions.cs 상단 주석). + leaked = sorted([r for r in pools if '"' + r + '"' in policy]) + add("BlightPolicy에 리전 ID 리터럴", "0건", + str(len(leaked)) + "건 " + str(leaked) if leaked else "0건", not leaked) + + # 16. 「지워진 개체」가 1막으로 새지 않는가. + # 오염 표현에 erasedChance를 재사용하면 1막 리전이 2막 연출을 쓰게 된다. 그러면 + # 특정 종 포획 비트(ch5_apollo/ch6_relic)가 "???"에 묻혀 사실상 진행 불가가 된다. + act2_gate = "IsAct2Region" in spawner + add("GetErasedChance의 2막 게이트 유지", "유지", + "유지" if act2_gate else "**해제됨 — 1막에 지워진 개체가 샌다**", act2_gate) + + # 17. 세이브 배선. + scope = read("Assets/Scripts/Core/SaveScope.cs") + cloud = read("Assets/Scripts/Core/CloudSaveManager.cs") + save_bad = [] + if "BlightCleansed" not in scope: + save_bad.append("SaveScope 스코핑 목록 누락(계정 삭제 후 재로그인 시 부활)") + if "DefeatedLedgerBosses" not in scope: + save_bad.append("DefeatedLedgerBosses 스코핑 목록 누락") + n = cloud.count("blightCleansed") + if n < 5: + save_bad.append("클라우드 DTO 지점 %d/5 (선언·수집·적용·직렬화·파싱)" % n) + add("정화 세이브 배선", "SaveScope 2키 + 클라우드 5지점", + str(len(save_bad)) + "건 " + str(save_bad) if save_bad else "정상 (클라우드 %d/5)" % n, + not save_bad) + + # 18. 정화 퀘스트가 실제로 완료 가능한가. + # + # 스토리 퀘스트는 **활성인 것 하나만** 진행한다(TutorialQuestManager.NotifyAction — + # `ActiveQuest.type == type`일 때만 IncrementProgress). 그래서 체인으로 엮인 + # CleanseBlight 퀘스트들의 목표 **합계**가 거점 수를 넘으면 뒤쪽 퀘스트가 영영 + # 완료되지 않는다 — 앞 퀘스트가 정화를 이미 써 버렸기 때문이다. + # 실제로 겪었다: 거점 2곳에 목표 1 + 2를 두어 뒤 퀘스트가 죽어 있었다. + # quest_lint는 ID·배선·순서만 보고 "달성 가능한가"는 안 본다. + chain_target = sum(q["target"] for q in gf.quest_defs() + if q["type"] == "CleanseBlight" and q["category"] == "Story") + add("정화 스토리 퀘스트 달성 가능성", "목표 합계 <= 거점 수", + "합계 %d / 거점 %d%s" % (chain_target, len(sites), + "" if chain_target <= len(sites) else " — **뒤쪽 퀘스트가 영영 완료 안 된다**"), + chain_target <= len(sites)) + + # 19. VFX가 전역 렌더 설정을 만지지 않는가. + # SubAreaEnvironment가 Start에 RenderSettings 기본값을 1회 스냅샷하고 매 프레임 덮는다. + # 오염 fog를 켜면 서브에리어에 한 번 들어갔다 나오는 순간 조용히 지워진다. + vfx_rel = "Assets/Scripts/Core/BlightVfx.cs" + if os.path.exists(os.path.join(ROOT, vfx_rel)): + vfx = code_only(read(vfx_rel)) + touches = "RenderSettings." in vfx + add("BlightVfx가 RenderSettings 미사용", "0건", + "**사용함 — 서브에리어 왕복에 조용히 지워진다**" if touches else "0건", not touches) + + # 20. 「장부」 정독이 **실제로 때린 턴에만** 소모되는가. + # + # 이것도 무증상이다 — 게이지는 멀쩡히 차고 "장부에 올랐다!"도 뜨는데 피해만 + # 안 곱해진다. 옛 판은 적 스킬의 SkillEffectType을 **열거해** "때리는 턴인가"를 + # ApplySkill **전에** 예측했고, 그래서 빗나감을 못 봤다: 산·유적 거점 보스 + # (mantis_green·hornet_asian)의 주력기 storm은 명중 0.9라 정독 열 번에 한 번이 + # 조용히 증발했다. 지금은 GetDamage가 배율을 곱한 사실을 남기고 UseEnemyTurn이 + # **결과를 보고** 장부를 비운다. 두 급소를 소스로 고정한다. + bc = code_only(read("Assets/Scripts/Battle/InsectBattleController.cs")) + ledger_bad = [] + + # (a) 배율을 곱하는 자리는 GetDamage 하나뿐이어야 한다. + # ApplySkill의 피해 분기가 여럿이라 분기마다 곱하면 하나를 빠뜨린다. + mult_total = bc.count("ReadDamageMultiplier") + mult_in_funnel = _method_body(bc, "private int GetDamage(").count("ReadDamageMultiplier") + if mult_total != 1 or mult_in_funnel != 1: + ledger_bad.append("배율 곱셈 %d곳(GetDamage 안 %d곳) — 단일 지점이 아니다" + % (mult_total, mult_in_funnel)) + + # (b) 장부를 비우는 자리가 ApplySkill **뒤**여야 한다. 앞에 있으면 못 때린 턴에도 비워진다. + enemy_turn = _method_body(bc, "private void UseEnemyTurn(") + apply_at = enemy_turn.find("ApplySkill(") + spend_at = enemy_turn.find("ledgerReadCount++") + if apply_at < 0 or spend_at < 0: + ledger_bad.append("UseEnemyTurn에서 ApplySkill/소모 지점을 못 찾았다") + elif spend_at < apply_at: + ledger_bad.append("소모가 ApplySkill **앞**에 있다 — 빗나가거나 못 때린 턴에도 장부가 비워진다") + + add("장부 정독 소모 지점", "GetDamage 단일 배율 + ApplySkill 뒤 소모", + str(len(ledger_bad)) + "건 " + str(ledger_bad) if ledger_bad else "정상", + not ledger_bad) + + fails = [s for s in signals if s[3] == "FAIL"] + print("# blight-lint — 명부회 오염 거점 정합성\n") + print("| 검사 | 기대 | 측정 | 판정 |") + print("|---|---|---|---|") + for name, expect, got, verdict in signals: + print("| %s | %s | %s | **%s** |" % (name, expect, got, verdict)) + print("\n요약: **%d FAIL** / %d PASS" % (len(fails), len(signals) - len(fails))) + print("\n## 가정 / 한계") + print("- 거점 정의는 RegionData 필드에서 읽는다(별도 표 아님). 0건은 정상 상태로 본다 —") + print(" 필드 선언 자체가 사라지면 game_facts가 ExtractorBroken으로 죽는다.") + print("- 비트 무결성·연출/컷신 배선은 story_lint(22검사), 퀘스트는 quest_lint(10검사) 담당.") + print("- 검사 6·7은 story_lint의 사각지대를 메운다: 검사 17이 'bl' 챕터를 건너뛰고,") + print(" 검사 3은 RegionCleansed 분기가 없어 param을 안 본다.") + print("- 검사 20은 거점이 아니라 **장부 압박**을 본다 — 같은 조직의 무증상 결함이라 여기 둔다.") + print("- 소스 grep 기반 검사(8~13, 16~18, 20)는 '경로가 존재하는가'만 본다. 그 경로가 실제로") + print(" 불리는지는 기기 확인 대상이다.") + return 1 if fails else 0 + + +if __name__ == "__main__": + try: + sys.exit(main()) + except gf.ExtractorBroken as e: + print("[blight-lint] 추출 실패: " + str(e), file=sys.stderr) + sys.exit(2) diff --git a/.claude/scripts/ci_check.py b/.claude/scripts/ci_check.py new file mode 100644 index 0000000..e6ad262 --- /dev/null +++ b/.claude/scripts/ci_check.py @@ -0,0 +1,149 @@ +"""CI에서 하네스 검사기를 그대로 돌린다. CI는 검사를 재구현하지 않는다. + +왜 있나 +------- +`.github/workflows/ci.yml`의 lint 잡은 네임스페이스 규칙과 금지 패턴 검사를 **bash로 +재구현**하고 있었다. 같은 규칙의 다섯 번째 사본이었다: + ① `.claude/hooks/check_namespace.py` ② `.codex/hooks/check_namespace.py` + ③ `.claude/rules/unity-csharp.md` ④ CLAUDE.md ⑤ ci.yml의 bash + +사본은 썩는다. 그리고 CI의 사본은 아무도 안 본다 — 이 CI는 2026-04-15에 한 번 돌고 +14초 만에 실패한 뒤 3개월간 방치됐다. + +훅은 전부 stdin-JSON 인터페이스라(argv 미지원) 합성 JSON만 먹이면 코드 변경 없이 +CI에서 재사용된다. 훅은 조언용이라 항상 exit 0이므로, 출력을 판정해 실패시키는 게 +이 드라이버의 일이다. + +무엇을 CI가 잡나 +---------------- +세션 안 편집은 훅이 실시간으로 잡는다. CI의 몫은 **세션 밖 편집**이다 — +Codex CLI 병행 사용, 손편집, 다른 기계에서 온 커밋. + +종료 코드 +--------- + 0 이상 없음 + 1 위반 발견 + 2 검사기 자신의 고장 (data_lint/verify_coverage의 exit 2 전파) +""" +import argparse +import glob +import io +import json +import os +import subprocess +import sys + +sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace") + +ROOT = os.environ.get("CLAUDE_PROJECT_DIR") or os.getcwd() +HOOKS = os.path.join(ROOT, ".claude", "hooks") +SCRIPTS = os.path.join(ROOT, ".claude", "scripts") + +# 파일 단위로 돌릴 훅. (파일, 훅출력) → 위반 여부는 훅이 무언가 말하면 위반으로 본다. +# check_namespace만 CI 차단 대상이다 — 나머지 훅(성능·컴파일 의심)은 조언이라 +# 사람이 판단할 문제이고, CI가 막으면 오탐 하나로 push가 멈춘다. +BLOCKING_HOOKS = ["check_namespace.py"] + +# 저장소 전체를 보는 스크립트. 종료 코드가 곧 판정이다. +REPO_CHECKS = [ + (["data_lint.py"], "데이터 정합성"), + (["quest_lint.py"], "퀘스트 정합성"), + (["story_lint.py"], "스토리 정합성"), + (["ui_layout_lint.py"], "UI 레이아웃 마진"), + (["subscription_lint.py"], "UI 구독 소실"), + (["text_fit_lint.py"], "라벨 잘림"), + (["literal_fit_lint.py"], "리터럴 문구 잘림"), + (["dex_grant_lint.py"], "곤충 지급↔도감 등록"), + (["blight_lint.py"], "오염 거점 정합성"), + (["singleton_lint.py"], "싱글턴 static 해제"), + (["sync_codex.py", "--check"], "Codex 미러 동기"), +] + + +def cs_files(): + out = [] + for pat in ("Assets/Scripts/**/*.cs", "Assets/Editor/**/*.cs", "Assets/Tests/**/*.cs"): + out += glob.glob(os.path.join(ROOT, pat), recursive=True) + return sorted(out) + + +def run_hook(hook, path): + """훅에 합성 PostToolUse 입력을 먹이고 사람이 읽을 메시지를 돌려준다(없으면 None).""" + r = subprocess.run( + [sys.executable, "-X", "utf8", os.path.join(HOOKS, hook)], + input=json.dumps({"tool_input": {"file_path": path}}), + capture_output=True, text=True, encoding="utf-8", errors="replace", cwd=ROOT, + ) + out = (r.stdout or "").strip() + if not out: + return None + try: + d = json.loads(out) + except ValueError: + return f"[훅이 JSON이 아닌 출력을 냈다] {out[:200]}" + if d.get("suppressOutput"): + return None + return (d.get("hookSpecificOutput", {}) or {}).get("additionalContext") or json.dumps( + d, ensure_ascii=False + ) + + +def main(): + ap = argparse.ArgumentParser(description="CI에서 하네스 검사기 실행") + ap.add_argument("--files", nargs="*", help="검사할 .cs (생략 시 전체)") + args = ap.parse_args() + + targets = args.files or cs_files() + violations, broken = [], [] + + print(f"# ci-check — 하네스 검사기 재사용 ({len(targets)}개 .cs)\n") + + print("## 파일 단위 훅") + for hook in BLOCKING_HOOKS: + hits = [] + for f in targets: + msg = run_hook(hook, f) + if msg: + rel = os.path.relpath(f, ROOT).replace(os.sep, "/") + hits.append((rel, " ".join(msg.split()))) + print(f"- {hook}: {'위반 ' + str(len(hits)) + '건' if hits else f'통과 ({len(targets)}개 검사)'}") + for rel, msg in hits: + print(f" {rel}\n {msg[:200]}") + violations += hits + + print("\n## 저장소 단위 검사") + for cmd, label in REPO_CHECKS: + r = subprocess.run( + [sys.executable, "-X", "utf8", os.path.join(SCRIPTS, cmd[0])] + cmd[1:], + capture_output=True, text=True, encoding="utf-8", errors="replace", cwd=ROOT, + ) + if r.returncode == 0: + print(f"- {label}: 통과") + elif r.returncode == 2: + print(f"- {label}: **검사기 고장 (exit 2)**") + broken.append((label, (r.stdout or "") + (r.stderr or ""))) + else: + print(f"- {label}: **위반 (exit {r.returncode})**") + tail = [l for l in (r.stdout or "").splitlines() if "FAIL" in l or "요약" in l] + for l in tail[:8]: + print(f" {l.strip()[:200]}") + violations.append((label, f"exit {r.returncode}")) + + print() + if broken: + print("## 검사기 고장") + for label, out in broken: + print(f"- {label}") + for l in out.strip().splitlines()[-4:]: + print(f" {l.strip()[:200]}") + print("\n검사기가 코드를 못 따라갔다. 결과를 신뢰할 수 없으니 검사기부터 고칠 것.") + return 2 + if violations: + print(f"## 결과: 위반 {len(violations)}건 — 실패") + return 1 + print("## 결과: 통과") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.claude/scripts/cs_strip.py b/.claude/scripts/cs_strip.py new file mode 100644 index 0000000..0eec4b7 --- /dev/null +++ b/.claude/scripts/cs_strip.py @@ -0,0 +1,83 @@ +"""C# 소스에서 주석·문자열·문자 리터럴을 지운다. 하네스 훅들의 공용 전처리. + +왜 있나 — 순서를 뒤집으면 코드를 삼킨다 +---------------------------------------- +훅 3개(quick_compile_check, scan_static_patterns, audit_candidates)가 같은 코드를 +각자 복사해 갖고 있었고, 셋 다 **주석을 문자열보다 먼저** 지웠다: + + s = re.sub(r"//[^\n]*", "", s) # ← 먼저 + ... + s = re.sub(r'"(?:\\.|[^"\\])*"', "", s) # ← 나중 + +C# 렉싱은 그 반대다. `string url = "https://example.com/x";`에서 `//`는 주석이 아니라 +문자열 안이다. 그런데 주석 규칙이 먼저 돌면 `//example.com/x";`가 통째로 잘리고 +**닫는 따옴표가 사라진다**. 그 다음 문자열 규칙이 남은 여는 `"`를 파일 저 아래의 +다음 `"`와 짝지어 그 사이의 **진짜 코드를 중괄호째 삼킨다**. 결과는 중괄호 불균형 +오경고. FirebaseConfig.cs에서 { 23개 vs } 20개로 실제 발화했다. + +해결은 순서 교체가 아니라 **단일 패스**다. 주석과 문자열은 서로 배타적 문맥이므로, +왼쪽에서 오른쪽으로 훑으며 먼저 열리는 쪽이 이기게 하면 양쪽 다 옳다: + - `"https://..."` → 따옴표가 먼저 열리므로 문자열이 이긴다 + - `// "인용"` → 슬래시가 먼저 오므로 주석이 이긴다 +정규식 교대(alternation)가 정확히 이 규칙이다. +""" +import re + +# 한 번의 스캔으로 처리한다. 위치가 같을 때의 우선순위가 아니라 **먼저 열리는 쪽**이 +# 이긴다는 게 핵심 — re.finditer가 왼쪽부터 훑으므로 자연히 그렇게 된다. +# +# @"..." 를 "..." 보다 앞에 둔다: `@`에서 시작하는 축자 문자열은 백슬래시를 이스케이프로 +# 보지 않으므로(`@"C:\path"`) 규칙이 다르다. +_TOKEN = re.compile( + r""" + @"(?:[^"]|"")*" # 축자 문자열 @"..." ($@"..." 는 @ 에서 걸린다) + | "(?:\\.|[^"\\])*" # 일반/보간 문자열 "..." ($"..." 는 " 에서 걸린다) + | '(?:\\.|[^'\\])*' # 문자 리터럴 '{' 등 + | //[^\n]* # 한 줄 주석 + | /\*[\s\S]*?\*/ # 블록 주석 + """, + re.VERBOSE, +) + + +def _blank(m: "re.Match") -> str: + """매치를 같은 줄 수의 공백으로 바꾼다 — 라인 번호와 오프셋을 보존.""" + return "".join("\n" if ch == "\n" else " " for ch in m.group(0)) + + +def strip_cs(src: str) -> str: + """주석·문자열·문자 리터럴을 공백으로 치환. 줄 번호는 보존된다. + + 지우지 않고 공백으로 바꾸는 이유: 훅들이 `파일:라인`으로 보고하므로 줄이 밀리면 + 엉뚱한 라인을 가리킨다. + """ + return _TOKEN.sub(_blank, src) + + +if __name__ == "__main__": + import io + import sys + + sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace") + + cases = [ + ('string u = "https://a.com/x"; { }', "URL의 //가 주석으로 오인되면 안 됨"), + ('// 주석 안의 "따옴표" 는 문자열이 아님\n{ }', "주석이 이기는 경우"), + (r'string p = @"C:\temp\x"; { }', "축자 문자열의 백슬래시"), + ('string s = "a\\"b"; { }', "이스케이프된 따옴표"), + ("char c = '{'; { }", "문자 리터럴 중괄호"), + ('/* 블록\n주석 { */ { }', "블록 주석"), + ('string i = $"{x}/{y}"; { }', "보간 문자열"), + ] + ok = 0 + for src, label in cases: + out = strip_cs(src) + o, c = out.count("{"), out.count("}") + balanced = o == c + same_lines = out.count("\n") == src.count("\n") + mark = "OK " if balanced and same_lines else "실패" + if balanced and same_lines: + ok += 1 + print(f" [{mark}] {label}: {{ {o}개 / }} {c}개, 줄 보존 {same_lines}") + print(f"\n {ok}/{len(cases)} 통과") + sys.exit(0 if ok == len(cases) else 1) diff --git a/.claude/scripts/data_lint.py b/.claude/scripts/data_lint.py new file mode 100644 index 0000000..f0e104c --- /dev/null +++ b/.claude/scripts/data_lint.py @@ -0,0 +1,591 @@ +"""데이터 정합성 자동 검증. 코드 내 하드코딩된 ID 정의/참조 비교, 고아/누락/중복 검출.""" +import argparse +import os +import re +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import game_facts # noqa: E402 +from game_facts import ExtractorBroken # noqa: E402 — 예외 타입은 game_facts가 소유 + +if hasattr(sys.stdout, "reconfigure") and sys.stdout.encoding and sys.stdout.encoding.lower() != "utf-8": + sys.stdout.reconfigure(encoding="utf-8") # type: ignore[attr-defined] + +# === 임계값 === +THRESHOLD_DEAD_METHOD_FAIL = True # 호출자 0건 메서드 = FAIL +THRESHOLD_MISSING_REF_FAIL = True # 참조하는데 정의 없는 ID = FAIL +THRESHOLD_ORPHAN_DEF_WARN = True # 정의했는데 참조 0건 = WARN +THRESHOLD_DUPLICATE_DEF_WARN = True # 같은 ID 다중 정의 = WARN +THRESHOLD_GACHA_PROB_TOLERANCE = 0.01 # 가챠 확률 합 100% ± 0.01 + +# === Unity 메서드 콜백 (반사 호출되므로 dead 검출 제외) === +UNITY_CALLBACKS = { + "Awake", "Start", "Update", "FixedUpdate", "LateUpdate", + "OnEnable", "OnDisable", "OnDestroy", "OnGUI", + "OnApplicationQuit", "OnApplicationPause", "OnApplicationFocus", + "OnTriggerEnter", "OnTriggerStay", "OnTriggerExit", + "OnCollisionEnter", "OnCollisionStay", "OnCollisionExit", + "OnDrawGizmos", "OnDrawGizmosSelected", "OnValidate", "Reset", +} + +# ExtractorBroken은 game_facts가 소유한다 (위에서 import). 예외 타입까지 사본을 두면 +# 한쪽만 잡히는 사고가 난다. + +# === 데이터 소스 경로 === +PATHS = { + "bootstrap": "Assets/Scripts/Core/PlaySceneBootstrap.cs", + "region_defs": "Assets/Scripts/Core/RegionDefinitions.cs", + "region_mgr": "Assets/Scripts/Core/RegionManager.cs", + "region_terrain": "Assets/Scripts/Core/RegionTerrainBuilder.cs", + "region_map_ui": "Assets/Scripts/UI/RegionMapUI.cs", + "audio_mgr": "Assets/Scripts/Core/AudioManager.cs", + "cash_shop": "Assets/Scripts/Core/CashShopManager.cs", + "cash_shop_ui": "Assets/Scripts/UI/CashShopUI.cs", + "gacha_mgr": "Assets/Scripts/Core/GachaBoxManager.cs", + "shop_ui": "Assets/Scripts/Core/ShopUIController.cs", + "spawner": "Assets/Scripts/Spawning/InsectSpawner.cs", +} + + +def _read(path: str) -> str: + if not os.path.isfile(path): + return "" + try: + with open(path, encoding="utf-8") as f: + return f.read() + except (OSError, UnicodeDecodeError): + return "" + + +def _read_clean(path: str) -> str: + """주석 제거된 파일 내용 반환. 추출기에서 false positive 방지용.""" + return _strip_comments(_read(path)) + + +def _strip_comments(content: str) -> str: + """C# 한 줄 주석 (//...) 제거. 블록 주석(/*...*/)은 단순 처리만.""" + lines = [] + for line in content.splitlines(): + # `//`이 라인의 첫 비공백이면 주석 라인. 인라인 주석은 보존 (코드도 살릴 수 있음) + stripped = line.lstrip() + if stripped.startswith("//"): + lines.append("") # 빈 줄로 대체 (라인 번호 보존) + else: + # 인라인 주석 제거 (간단 처리, 문자열 안의 //는 무시 못 함 — 보수적 처리) + idx = line.find("//") + if idx >= 0 and '"' not in line[:idx]: + lines.append(line[:idx]) + else: + lines.append(line) + return "\n".join(lines) + + +# ===== 추출기 ===== + +def extract_region_ids() -> set: + """RegionDefinitions.CreateAll() 안의 regionId 추출. 다음 메서드 정의까지. + + 이전엔 PlaySceneBootstrap의 CreateRegions()/CreateExpandedRegions()를 팠다. 두 메서드는 + 이제 코드베이스에 0곳이다 — 정의가 RegionDefinitions.cs로 분리됐다. + """ + content = _read_clean(PATHS["region_defs"]) + if not content: + raise ExtractorBroken(f"{PATHS['region_defs']}를 읽을 수 없음 — 파일이 옮겨갔는가?") + pattern = re.compile( + r"CreateAll\s*\(\)\s*\{(.*?)(?=\n\s*(?:private|public)\s+(?:static\s+)?[\w\.\[\]<>]+\s+\w+\s*\()", + re.DOTALL + ) + m = pattern.search(content) + if not m: + raise ExtractorBroken( + f"{PATHS['region_defs']}에서 CreateAll() 본체를 찾지 못함 — 메서드가 개명/이동했는가?" + ) + ids = set(re.findall(r'regionId\s*=\s*"(\w+)"', m.group(1))) + if not ids: + raise ExtractorBroken( + "CreateAll() 본체에서 regionId를 하나도 못 찾음 — 정의 문법이 바뀌었는가?" + ) + return ids + + +def extract_region_refs() -> dict: + """파일별로 regionId 참조 수집. environmentType case (GetSubAreaColor)는 제외.""" + refs = {} + # RegionManager switch + content = _read_clean(PATHS["region_mgr"]) + refs["RegionManager"] = set(re.findall(r'case\s+"(\w+)":\s*return\s+"\w+"', content)) + refs["RegionManager"].update(re.findall(r'return\s+"(\w+)";', content)) + # RegionTerrainBuilder + content = _read_clean(PATHS["region_terrain"]) + refs["RegionTerrainBuilder"] = set(re.findall(r'case\s+"(\w+)":', content)) + # RegionMapUI: connections 배열 + GetRegionSymbol만 (GetSubAreaColor는 environmentType이라 제외) + content = _read_clean(PATHS["region_map_ui"]) + refs["RegionMapUI"] = set(re.findall(r'\{\s*"(\w+)"\s*,\s*"\w+"\s*\}', content)) + refs["RegionMapUI"].update(re.findall(r'\{\s*"\w+"\s*,\s*"(\w+)"\s*\}', content)) + # GetRegionSymbol 메서드 본체에서만 case 추출 (다음 메서드 정의 전까지) + sym_match = re.search( + r'GetRegionSymbol\s*\([^)]*\)\s*\{(.*?)(?=\n\s*private|\n\s*public)', + content, re.DOTALL + ) + if sym_match: + refs["RegionMapUI"].update(re.findall(r'case\s+"(\w+)":', sym_match.group(1))) + # AudioManager + content = _read_clean(PATHS["audio_mgr"]) + refs["AudioManager"] = set(re.findall(r'case\s+"(\w+)":', content)) + return refs + + +def detect_dead_methods() -> list: + """PlaySceneBootstrap의 private 메서드 중 참조 0건 검출. + 참조 = 정의 라인 외에서 메서드명이 등장하는 라인 (직접 호출 + 이벤트 += 구독 + 델리게이트 할당 포함). + Unity 콜백(Awake/Update 등)은 반사 호출이므로 제외.""" + content = _read_clean(PATHS["bootstrap"]) + methods = {} # name -> 정의 라인 + for m in re.finditer( + r'^\s*private\s+(?:static\s+)?[\w\.\[\]<>]+\s+(\w+)\s*\(', + content, re.MULTILINE + ): + name = m.group(1) + if name in UNITY_CALLBACKS: + continue + methods[name] = content[:m.start()].count("\n") + 1 + + dead = [] + lines = content.splitlines() + for name, def_line in methods.items(): + ref_count = 0 + for i, line in enumerate(lines, 1): + if i == def_line: + continue + if re.search(rf'\b{re.escape(name)}\b', line): + ref_count += 1 + break + if ref_count == 0: + dead.append((name, def_line)) + return sorted(dead, key=lambda x: x[1]) + + +def extract_cashshop_items() -> tuple: + """CashShopManager의 itemId, rewardItemId 추출. 주석 라인 제외.""" + content = _read_clean(PATHS["cash_shop"]) + items = re.findall(r'itemId\s*=\s*"(\w+)"', content) + rewards = re.findall(r'rewardItemId\s*=\s*"(\w+)"', content) + return set(items), set(rewards) + + +def extract_capture_items() -> set: + """PlaySceneBootstrap.CreateCaptureItems의 itemId 추출. nested {} 회피 위해 다음 메서드 정의까지로 한정.""" + content = _read_clean(PATHS["bootstrap"]) + pattern = re.compile( + r"CreateCaptureItems\s*\(\)\s*\{(.*?)(?=\n\s*(?:private|public)\s+(?:static\s+)?[\w\.\[\]<>]+\s+\w+\s*\()", + re.DOTALL + ) + m = pattern.search(content) + if not m: + return set() + return set(re.findall(r'itemId\s*=\s*"(\w+)"', m.group(1))) + + +def extract_cashshop_ui_boxes() -> dict: + """CashShopUI.DrawBoxCard 호출에서 boxId/price/rateText 추출. + rateText는 'C:55% U:30% ...' 형식. 반환: {boxId: {'price': int, 'rates': {grade: pct}}}""" + content = _read_clean(PATHS["cash_shop_ui"]) + boxes = {} + # 시그니처: DrawBoxCard("boxId", "title", new Color(...), "rateText", price, gems) + # `new Color(...)` 안의 콤마 때문에 lazy 매칭 사용 + pattern = re.compile( + r'DrawBoxCard\(\s*"(box_\w+)"\s*,\s*"[^"]+"\s*,.+?,\s*"([^"]+)"\s*,\s*(\d+)\s*,', + re.DOTALL + ) + for m in pattern.finditer(content): + box_id = m.group(1) + rate_text = m.group(2) + rates = {r.group(1).upper(): float(r.group(2)) + for r in re.finditer(r'([CUREL])\s*:\s*([\d.]+)%', rate_text)} + boxes[box_id] = {"price": int(m.group(3)), "rates": rates} + return boxes + + +def extract_cashshop_manager_box_prices() -> dict: + """CashShopManager.shopItems에서 box_* itemId의 gemPrice 추출.""" + content = _read_clean(PATHS["cash_shop"]) + prices = {} + for m in re.finditer( + r'itemId\s*=\s*"(box_\w+)".*?gemPrice\s*=\s*(\d+)', + content + ): + prices[m.group(1)] = int(m.group(2)) + return prices + + +def extract_gacha_pools() -> dict: + """GachaBoxManager의 gachaExclusives 사전 영역만 추출.""" + content = _read_clean(PATHS["gacha_mgr"]) + # gachaExclusives 사전 본체만 분리 (`Dictionary gachaExclusives = new ... { ... };`) + excl_match = re.search( + r'gachaExclusives\s*=\s*new\s+Dictionary\s*\{(.*?)\};', + content, re.DOTALL + ) + pools = {} + if excl_match: + body = excl_match.group(1) + # { InsectRarity.Rare, new[] { "id1", "id2" } } + for m in re.finditer(r'InsectRarity\.(\w+)\s*,\s*new\[\]\s*\{([^}]*)\}', body): + rarity = m.group(1) + ids = re.findall(r'"(gacha_\w+)"', m.group(2)) + pools[rarity] = set(ids) + # exclusiveDisplayNames: { "id", "표시명" } + name_map = dict(re.findall(r'\{\s*"(gacha_\w+)"\s*,\s*"([^"]+)"\s*\}', content)) + return {"pools": pools, "names": name_map} + + +def extract_gacha_normal_pool() -> dict: + """GachaBoxManager의 `normalPool` — {티어: {insectId, ...}}. + + `gachaExclusives`와 달리 여기 ID는 **일반 곤충**이라 접두가 없다. 티어별로 나뉜 이 배치가 + 곧 저가 박스의 상한이므로, DB 실제 rarity와 어긋나면 브론즈 상자가 상위 곤충을 흘린다. + """ + content = _read_clean(PATHS["gacha_mgr"]) + match = re.search( + r'normalPool\s*=\s*new\s+Dictionary\s*\{(.*?)\n \};', + content, re.DOTALL + ) + if not match: + raise ExtractorBroken( + "GachaBoxManager에서 normalPool을 못 읽었다 — 사전 구조가 바뀌었는가?") + + pools = {} + for m in re.finditer(r'InsectRarity\.(\w+),\s*new\[\]\s*\{(.*?)\}\s*\}', match.group(1), re.DOTALL): + pools[m.group(1)] = set(re.findall(r'"([^"]+)"', m.group(2))) + if not pools: + raise ExtractorBroken("normalPool에서 티어를 하나도 못 읽었다") + return pools + + +def extract_insect_rarities() -> dict: + """{insectId: "Common"|...} — 곤충 등급의 실질 단일 출처 세 곳을 합친다. + + 시드 파일 둘(1막·2막 확장)과 부트스트랩의 `CreateStableInsect`(기본 종·가챠 전용). + 셋 다 코드 하드코딩이라 `.asset`을 읽을 필요가 없다. + """ + out = {} + seed_files = [ + "Assets/Scripts/Data/InsectExpansionDefinitions.cs", + "Assets/Scripts/Data/InsectExpansion2Definitions.cs", + ] + for path in seed_files: + content = _read_clean(path) + for iid, rarity in re.findall( + r'new InsectSeed\(\s*"([^"]+)",\s*"[^"]*",\s*InsectRarity\.(\w+)', content): + out[iid] = rarity + + boot = _read_clean(PATHS["bootstrap"]) + for iid, rarity in re.findall( + r'CreateStableInsect\(\s*"([^"]+)",\s*"[^"]*",\s*InsectRarity\.(\w+)', boot): + out[iid] = rarity + + if not out: + raise ExtractorBroken( + "곤충 등급을 하나도 못 읽었다 — 시드/CreateStableInsect 시그니처가 바뀌었는가?") + return out + + +def extract_gacha_probabilities() -> dict: + """{"bronze": [C상한, U상한, R상한, E상한], ...} 누적 임계값. + + 추출 자체는 game_facts가 소유한다 — 여기에 또 사본을 두면 같은 병이 반복된다. + """ + return game_facts.gacha_thresholds() + + +# ===== 평가 ===== + +def evaluate_signals() -> list: + signals = [] + + # 1. 리전 정의 vs 참조 정합성 + defined = extract_region_ids() + refs = extract_region_refs() + all_refs = set() + for v in refs.values(): + all_refs.update(v) + + missing = all_refs - defined + orphan = defined - all_refs + judge = "FAIL" if missing else "PASS" + signals.append(( + "리전 참조 정합성 (정의 없는데 참조)", + "0건", + f"{len(missing)}건 ({sorted(missing)})" if missing else "0건", + judge + )) + + judge = "WARN" if orphan else "PASS" + signals.append(( + "리전 고아 (정의했는데 참조 0건)", + "0건", + f"{len(orphan)}건 ({sorted(orphan)})" if orphan else "0건", + judge + )) + + # 2. (삭제) 리전 중복 정의 — CreateRegions vs CreateExpandedRegions 이분법은 없어졌다. + # 정의는 RegionDefinitions.CreateAll() 하나뿐이라 중복될 곳이 없다. 두 메서드가 + # 코드에서 사라진 뒤로 이 검사는 빈 집합 ∩ 빈 집합 = PASS만 찍는 공허한 통과였다. + + # 3. dead method 일반 검출 (PlaySceneBootstrap의 private 메서드 중 참조 0건) + dead_methods = detect_dead_methods() + judge = "FAIL" if dead_methods else "PASS" + detail = ", ".join(f"{n}() L{l}" for n, l in dead_methods) if dead_methods else "0건" + signals.append(( + "dead method 일반 검출 (PlaySceneBootstrap)", + "0건", + detail, + judge + )) + + # 4. CashShop rewardItemId ↔ CreateCaptureItems 매칭 + shop_items, reward_items = extract_cashshop_items() + capture_items = extract_capture_items() + # rewardItemId 중 캐시샵 아이템(gem_*, shop_*, box_*) 외의 ID는 실제 아이템 풀에 있어야. + # 소비형 아이템 풀은 하드코딩 사본 대신 ItemDatabase(CreateItem) 단일 출처(game_facts.item_ids)를 읽는다 + # — 신규 소비형(golden_censer 등)이 자동 인정돼 거짓 WARN을 안 낸다. candy만 ItemDatabase 밖이라 별도. + expected_pool = capture_items.union(game_facts.item_ids()).union({"candy"}) + unmapped = reward_items - expected_pool + judge = "WARN" if unmapped else "PASS" + signals.append(( + "CashShop rewardItemId ↔ 아이템 풀 매칭", + "0건 미매칭", + f"{len(unmapped)}건 ({sorted(unmapped)})" if unmapped else "0건", + judge + )) + + # 5. itemId 중복 (CreateCaptureItems와 CashShop shopItems 충돌) + overlap = capture_items & shop_items + judge = "FAIL" if overlap else "PASS" + signals.append(( + "itemId 중복 (CreateCaptureItems ∩ shopItems)", + "0건 중복", + f"{len(overlap)}건 ({sorted(overlap)})" if overlap else "0건", + judge + )) + + # 6. Gacha 풀 ↔ displayName 매핑 + gacha = extract_gacha_pools() + pool_ids = set() + for ids in gacha["pools"].values(): + pool_ids.update(ids) + name_ids = set(gacha["names"].keys()) + missing_names = pool_ids - name_ids + orphan_names = name_ids - pool_ids + judge = "FAIL" if missing_names else "PASS" + signals.append(( + "Gacha 풀 ID ↔ displayName 매핑 (풀에 있고 이름 없음)", + "0건", + f"{len(missing_names)}건 ({sorted(missing_names)})" if missing_names else "0건", + judge + )) + judge = "WARN" if orphan_names else "PASS" + signals.append(( + "Gacha displayName 고아 (이름 있는데 풀에 없음)", + "0건", + f"{len(orphan_names)}건 ({sorted(orphan_names)})" if orphan_names else "0건", + judge + )) + + # 7. Gacha 확률 합 100% 검증 — 마지막 임계값과 100 사이 격차가 Legendary 확률(>0)이어야 정상 + probs = extract_gacha_probabilities() + for box, thresholds in probs.items(): + # 추출 실패 분기는 없다 — extract_gacha_probabilities()가 ExtractorBroken으로 죽는다. + epic_pct = thresholds[-1] - thresholds[-2] + legendary_pct = 100 - thresholds[-1] + is_sorted = thresholds == sorted(thresholds) and len(thresholds) >= 4 + # 서열 규칙: 최고 레어도(전설)가 차상위(에픽)보다 흔하면 안 된다. + # 봉우리형은 허용한다 — 실버는 Rare 봉우리(L8 ≤ E22)라 정상이다. 하지만 전설이 + # 에픽을 넘으면 등급 서열이 뒤집힌 것이다. 골드 {5,10,23,55}가 L45 > E32로 + # 이 규칙을 어겼는데(전설이 커먼의 9배), 옛 검사는 "단조증가 + L>0"만 봐서 + # 통과시켰다. 45% 오타가 3개월 방치된 이유가 이 구멍이었다. + legendary_le_epic = legendary_pct <= epic_pct + 1e-6 + ok = legendary_pct > 0 and is_sorted and legendary_le_epic + note = "" + if not is_sorted: + note = " — 임계값 단조증가 아님" + elif legendary_pct <= 0: + note = " — Legendary 0%" + elif not legendary_le_epic: + note = f" — 전설({legendary_pct:.1f}%) > 에픽({epic_pct:.1f}%) 서열 역전" + signals.append(( + f"Gacha {box} 등급 분포 (L={legendary_pct:.1f}% E={epic_pct:.1f}%)", + "단조증가 + Legendary > 0 + 전설 ≤ 에픽", + f"임계값 {thresholds}{note or ' — 정상'}", + "PASS" if ok else "FAIL" + )) + + # 8. CashShop UI 표시 가격 ↔ Manager gemPrice 정합성 + # 가격은 아직 UI 리터럴이라 정본과 갈릴 수 있다. 표시 가격 ≠ 실제 차감액 = 결제 오인. + ui_prices = game_facts.ui_box_prices() + mgr_prices = game_facts.box_gem_prices() + price_mismatches = [ + f"{box}: UI={ui_prices[box]} ≠ Manager={mgr_prices[box]}" + for box in game_facts.BOXES + if ui_prices[box] != mgr_prices[box] + ] + judge = "FAIL" if price_mismatches else "PASS" + signals.append(( + "CashShop UI 표시 가격 ↔ Manager gemPrice", + "0건 불일치", + f"{len(price_mismatches)}건 ({price_mismatches})" if price_mismatches + else f"0건 (검사한 박스 {len(game_facts.BOXES)}개)", + judge + )) + + # 9. UI 확률 표기가 코드 파생인가 (하드코딩 회귀 감시) + # 예전엔 "UI 텍스트 % vs 코드 확률" 값 비교였다. 그 검사는 폐물이 됐다 — UI가 + # GetGachaRateText → GachaBoxManager.GetRateText → GetRates → *Thresholds로 + # 파생받게 바뀌었기 때문(CashShopUI 주석: "하드코딩 금지(공시 위반 방지)"). + # 그런데 옛 추출기는 rateText 문자열 리터럴을 찾다가 0개를 반환했고, 그 결과 이 검사와 + # 위 가격 검사가 **둘 다 공허한 PASS**를 찍고 있었다. 값 비교 대신 파생 구조 자체를 지킨다. + derives = game_facts.ui_derives_gacha_rates() + hardcoded = game_facts.ui_hardcoded_rate_literals() + if hardcoded: + judge, detail = "FAIL", f"확률 리터럴 {len(hardcoded)}건 부활 ({hardcoded})" + elif not derives: + judge, detail = "FAIL", "GetGachaRateText → GachaBoxManager 파생 사슬이 끊김" + else: + judge, detail = "PASS", "코드 파생 + 리터럴 0건" + signals.append(( + "CashShop UI 확률 표기 = 코드 파생 (하드코딩 회귀)", + "파생 유지 + 리터럴 0건", + detail, + judge + )) + + # 10. 가챠 normalPool 티어 = 곤충 DB 실제 rarity + # GachaBoxManager 주석이 "각 티어의 ID는 InsectDatabase 실제 rarity와 일치해야 함 + # (저가 박스가 상위 곤충을 누출하지 않도록)"이라 못박는데 검사하는 곳이 없었다. + # `GetDbRarity`는 결과 **표시**만 보정하고 **풀 선택은 바꾸지 않는다** — 배치가 어긋나면 + # 브론즈 상자가 55% 확률로 상위 곤충을 흘리고, 그건 공시한 확률과 실제 분포가 갈리는 것이다. + # 위 9번이 지키는 건 "표기가 코드에서 파생되는가"이지 "그 코드가 옳은가"가 아니다. + pool_tiers = extract_gacha_normal_pool() + known_rarity = extract_insect_rarities() + tier_mismatch = [] + unknown_ids = [] + pooled_total = 0 + for tier, ids in sorted(pool_tiers.items()): + for iid in sorted(ids): + pooled_total += 1 + actual = known_rarity.get(iid) + if actual is None: + unknown_ids.append(f"{tier}:{iid}") + elif actual != tier: + tier_mismatch.append(f"{iid}(풀={tier} 실제={actual})") + + problems = tier_mismatch + unknown_ids + signals.append(( + "가챠 normalPool 티어 ↔ 곤충 실제 등급", + "0건 불일치", + f"{len(problems)}건 ({problems[:6]})" if problems + else f"0건 (풀 {pooled_total}종 × 등급 {len(known_rarity)}종 대조)", + "FAIL" if problems else "PASS" + )) + + # 11. 스타터 곤충 3종 — 실재 + 등급 일치 + # 이 검사가 없었으면 잡지 못했을 실수가 실제로 있었다: `dragonfly_lake`를 Rare로 알고 + # 후보에 넣었는데, 활성 DB(`CreateStableInsect`)에서는 **Uncommon**이었다. + # 옛 `CreateInsect` 목록에만 Rare로 적혀 있어 같은 id의 등급이 두 곳에서 갈린다. + # 등급이 섞이면 `basePower = 8 + rarity*5`라 어느 카드를 고르느냐로 시작 전투력이 달라지는데, + # 화면에는 아무 표시도 없어 플레이어는 알 방법이 없다. + starter_src = _read_clean("Assets/Scripts/Data/StarterInsectCatalog.cs") + starter_ids = re.findall(r'new Choice\(\s*(?:DefaultId|"([^"]+)")', starter_src) + # DefaultId로 적힌 첫 항목은 상수를 따라간다. + default_id = re.search(r'DefaultId\s*=\s*"([^"]+)"', starter_src) + starter_ids = [i if i else (default_id.group(1) if default_id else "") for i in starter_ids] + starter_ids = [i for i in starter_ids if i] + + starter_problems = [] + starter_rarities = set() + if not starter_ids: + starter_problems.append("추출 실패 — StarterInsectCatalog의 Choice 배열 형식이 바뀌었나") + for iid in starter_ids: + actual = known_rarity.get(iid) + if actual is None: + starter_problems.append(f"{iid}(DB에 없음 — 지급 시 LogWarning만 뜨고 곤충이 안 들어온다)") + else: + starter_rarities.add(actual) + if len(starter_rarities) > 1: + starter_problems.append(f"등급이 섞임 {sorted(starter_rarities)} — 고르는 카드로 시작 전투력이 갈린다") + + signals.append(( + "스타터 곤충 실재 + 등급 일치", + "전부 존재 & 같은 등급", + f"{len(starter_problems)}건 ({starter_problems[:3]})" if starter_problems + else f"0건 ({len(starter_ids)}종 전부 {sorted(starter_rarities)[0] if starter_rarities else '?'})", + "FAIL" if starter_problems else "PASS" + )) + + return signals + + +def render_signals(signals: list) -> str: + out = ["| 항목 | 임계값 | 측정값 | 판정 |", "|------|--------|--------|------|"] + for name, threshold, value, judge in signals: + out.append(f"| {name} | {threshold} | {value} | **{judge}** |") + fail_n = sum(1 for s in signals if s[3] == "FAIL") + warn_n = sum(1 for s in signals if s[3] == "WARN") + pass_n = sum(1 for s in signals if s[3] == "PASS") + out.append("") + out.append(f"요약: **{fail_n} FAIL** / {warn_n} WARN / {pass_n} PASS") + if fail_n > 0: + out.append("→ FAIL 1건 이상. PASS 보고 금지. 즉시 정정 필요.") + return "\n".join(out) + + +def main(): + p = argparse.ArgumentParser(description="데이터 정합성 자동 검증") + p.add_argument("--detail", action="store_true", help="추출된 데이터셋 상세 출력") + args = p.parse_args() + + print("# data-lint — 곤충게임 데이터 정합성 검증\n") + + if args.detail: + print("## 추출된 데이터셋") + defined = extract_region_ids() + refs = extract_region_refs() + shop_items, reward_items = extract_cashshop_items() + capture_items = extract_capture_items() + gacha = extract_gacha_pools() + print(f"- RegionDefinitions.CreateAll() regionId: {sorted(defined)}") + for src, ids in refs.items(): + print(f"- {src} 참조: {sorted(ids)}") + print(f"- CashShop itemIds: {sorted(shop_items)}") + print(f"- CashShop rewardItemIds: {sorted(reward_items)}") + print(f"- CreateCaptureItems itemIds: {sorted(capture_items)}") + print(f"- Gacha 풀: {gacha['pools']}") + print(f"- Gacha displayNames: {sorted(gacha['names'].keys())}") + print() + + print("## 위험 신호 표") + signals = evaluate_signals() + print(render_signals(signals)) + print() + + print("## 가정 / 한계") + print("- 코드 내 하드코딩된 ID만 검증. ScriptableObject(.asset) 직렬화는 미지원") + print("- 정규식 기반 추출 — 코드 포맷이 바뀌면 추출기가 깨진다. 단 조용히 깨지진") + print(" 않는다: 기대 심볼을 못 찾으면 ExtractorBroken으로 죽고 exit 2를 낸다") + print("- InsectDatabase의 insectId는 .asset 파일에 있어 미검증 (후속 작업)") + print("- Inspector 직렬화 필드(예: ShopUIController.itemIds[])는 grep 미지원") + + fail_count = sum(1 for s in signals if s[3] == "FAIL") + return 1 if fail_count > 0 else 0 + + +if __name__ == "__main__": + # 종료 코드: 0=이상 없음, 1=데이터 FAIL(진짜 결함), 2=추출기 고장(검증기 자신의 문제). + # 1과 2를 가르는 게 핵심이다. 섞으면 "늘 빨간불"이 되어 아무도 안 본다. + try: + sys.exit(main()) + except ExtractorBroken as e: + print(f"\n## 추출기 고장\n\n**{e}**\n") + print("데이터 결함이 아니라 이 스크립트가 코드를 못 따라간 것이다.") + print("검증 결과는 신뢰할 수 없다 — 추출기를 먼저 고칠 것.") + sys.exit(2) diff --git a/.claude/scripts/dex_grant_lint.py b/.claude/scripts/dex_grant_lint.py new file mode 100644 index 0000000..9dd75d8 --- /dev/null +++ b/.claude/scripts/dex_grant_lint.py @@ -0,0 +1,100 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +"""곤충을 지급하면 도감에도 올렸는가 — `AddCapturedInsect` ↔ `RegisterCapture` 짝 검사. + +왜 필요한가 +----------- +곤충 지급 경로는 여섯 곳이다(포획·전투·레이드·가챠·튜토리얼 보상·스토리 보상). 각각이 +`PlayerInsectCollection.AddCapturedInsect`와 `DexController.RegisterCapture`를 **따로** 불러야 +하는데, 이건 컴파일러도 런타임도 잡아주지 않는 배선이라 조용히 빠진다. + +2026-08-17 audit에서 **두 곳이 동시에 빠져 있는 것**을 찾았다: + - `TutorialQuestManager` — 튜토리얼 보상 곤충(첫 파트너)이 소유·출전까지 하는데 도감엔 + 영원히 미발견. 100% 완주가 불가능하고, `DexController.CapturedSpeciesCount`가 전 + 플레이어에게 1 낮게 잡혀 스토리 DexProgress 비트가 한 종 늦게 열렸다. + `dexController` 필드가 선언·대입만 되고 **한 번도 읽히지 않는** 죽은 필드였다. + - `StoryDirector` — 같은 형태. 지금은 Story.json 전 비트가 `rewardInsectId: ""`라 휴면이지만, + 첫 곤충 보상 비트를 저작하는 순간 "준 곤충이 자기 트리거를 못 밀어올리는" 상태가 된다. + +증상이 조용한 게 핵심이다 — 예외도 경고도 없고, 곤충은 멀쩡히 손에 들어온다. +도감을 열어 세어 보기 전까지 아무도 모른다. + +검사 규칙 +--------- +`.AddCapturedInsect(` 호출이 있는 파일은 같은 파일 안 ±`WINDOW`줄 이내에 +`RegisterCapture(`가 있어야 한다. 없으면 FAIL. + +`PlayerInsectCollection.cs`는 면제다 — 선언부이고 내부 재사용(`EnsureOwned` 등)이라 +도감 등록의 주체가 아니다. + +이 검사는 **호출 지점의 근접성**만 본다. 정적으로 "실제로 실행되는가"까지는 알 수 없으므로, +가짜 통과를 만들려면 주석에 `RegisterCapture(`를 적으면 된다 — 그건 검사기를 속이는 것이지 +검사기의 결함이 아니다. 주석·문자열은 지급 호출 쪽에서만 제외한다. +""" +import os +import re +import sys + +ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +SCAN_DIR = os.path.join(ROOT, "Assets", "Scripts") + +# 선언부 + 내부 재사용. 도감 등록의 주체가 아니다. +EXEMPT_FILES = {"PlayerInsectCollection.cs"} + +GRANT = re.compile(r"\.AddCapturedInsect\s*\(") +REGISTER = re.compile(r"RegisterCapture\s*\(") + +# 지급과 등록이 같은 블록 안에 있는지 보는 창. 실측 최대 간격은 12줄 +# (TutorialQuestManager: 772 지급 → 784 등록)이라 40줄이면 충분히 넉넉하다. +WINDOW = 40 + + +def is_code(line): + """주석 한 줄인지. 지급 호출을 셀 때만 쓴다(주석 속 예시가 FAIL을 만들지 않게).""" + s = line.strip() + return not (s.startswith("//") or s.startswith("*") or s.startswith("/*")) + + +def main(): + if not os.path.isdir(SCAN_DIR): + print(f"[dex_grant_lint] 스캔 대상 없음: {SCAN_DIR}", file=sys.stderr) + return 2 + + failures = [] + grant_sites = 0 + + for dirpath, _, filenames in os.walk(SCAN_DIR): + for name in sorted(filenames): + if not name.endswith(".cs") or name in EXEMPT_FILES: + continue + path = os.path.join(dirpath, name) + rel = os.path.relpath(path, ROOT).replace("\\", "/") + try: + lines = open(path, encoding="utf-8").read().splitlines() + except (OSError, UnicodeDecodeError) as e: + print(f"[dex_grant_lint] 읽기 실패 {rel}: {e}", file=sys.stderr) + return 2 + + reg_lines = [i for i, l in enumerate(lines) if REGISTER.search(l)] + + for i, line in enumerate(lines): + if not GRANT.search(line) or not is_code(line): + continue + grant_sites += 1 + if not any(abs(r - i) <= WINDOW for r in reg_lines): + failures.append((rel, i + 1, line.strip()[:90])) + + for rel, ln, src in failures: + print(f"FAIL {rel}:{ln} — 곤충을 지급하는데 {WINDOW}줄 안에 " + f"DexController.RegisterCapture 호출이 없다: {src}") + + print(f"\n요약: 지급 지점 {grant_sites}곳, 도감 등록 누락 {len(failures)}건") + if failures: + print("도감에 안 올라간 곤충은 100% 완주를 막고 CapturedSpeciesCount " + "기반 스토리 트리거를 늦춘다. 지급 옆에 RegisterEncounter + RegisterCapture를 붙일 것.") + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.claude/scripts/economy_sim.py b/.claude/scripts/economy_sim.py new file mode 100644 index 0000000..aecd5fa --- /dev/null +++ b/.claude/scripts/economy_sim.py @@ -0,0 +1,320 @@ +"""경제 시뮬레이션. 캔디/젬/코인 수입·지출 균형, 코드 결함(데드 화폐, 교환 부재) 자동 점검. + +가격 사본은 두지 않는다 — game_facts가 코드에서 읽는다. +""" +import argparse +import os +import re +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import game_facts # noqa: E402 +from cs_strip import strip_cs # noqa: E402 — 주석/문자열 제거 공용 + +# Windows cp949 환경에서 유니코드 출력 보장 +if hasattr(sys.stdout, "reconfigure") and sys.stdout.encoding and sys.stdout.encoding.lower() != "utf-8": + sys.stdout.reconfigure(encoding="utf-8") # type: ignore[attr-defined] + +# === 임계값 (디자이너가 조정 가능) === +# 근거: 일일 1캡처 권장 페이스. 4일+ 대기는 단일 비용이 일주일 행동량 초과 — 진행 막힘. +THRESHOLD_CANDY_BOTTLENECK_DAYS_FAIL = 4.0 # 6마리 팀 동시 레벨업 4일+ 대기 = FAIL +# 근거: 화폐 정의는 있는데 발행/사용 경로 0건이면 dead 시스템. TryPurchase 호출부 0건(코인) 알려진 결함. +THRESHOLD_DEAD_CURRENCY_FAIL = True # 발행 경로 0건이면 FAIL +# 근거: 14일 후 젬 잉여는 신규 지출 경로 부족 신호. 골드박스 4회분(4800) 초과는 디자인 의도 외 축적. +THRESHOLD_GEM_HOARD_DAYS_WARN = 14 # 14일 후 젬 잔고 > 골드박스 4회분 = WARN +# 근거: 캔디 부족분을 젬으로 보충 못 하면 P2W 외 우회로 0 — 경제 유연성 부재. +THRESHOLD_NO_EXCHANGE_WARN = True # 캔디↔젬 교환 경로 0건 = WARN +# 근거: 효과 1% 차이에 가격 5배(diamond_net 2000젬 vs normal_net 150코인)는 명백한 P2W 함정. +THRESHOLD_P2W_GAP_RATIO_WARN = 5.0 # 프리미엄/베이직 가격 비율 5배+ = WARN + +# === 정본 = 코드. 사본 없음 === +# "동기화 필요"라 적어둔 사본은 동기화되지 않는다 — 실제로 박스 가격이 실버 800 / +# 골드 1200에 멈춰 있는 동안 코드는 600 / 750으로 바뀌어 있었다. +# +# CASH_ITEM_GEMS는 삭제했다: 정의만 있고 사용처가 0인 죽은 사본이었고, 그나마도 +# incense_rare(350젬)라는 유령 품목을 들고 있었다 — CashShopManager.cs:79가 +# "ItemData SO 미정의로 비활성화"라고 적어둔, 상점에 존재하지 않는 아이템이다. +def _load_facts(): + # import 시점에 돌아 main()의 예외 처리보다 이르므로 여기서 잡는다. + try: + return (game_facts.box_gem_prices(), game_facts.gem_packages(), + game_facts.team_max_slots()) + except game_facts.ExtractorBroken as e: + print(f"추출기 고장: {e}\n가격을 코드에서 읽지 못했다 — 시뮬을 돌리지 않는다.", + file=sys.stderr) + sys.exit(2) + + +GACHA_BOX_PRICES, CASH_GEM_PACKAGES, TEAM_MAX_SLOTS = _load_facts() # CASH_GEM_PACKAGES: [(KRW, gems), ...] + +# Assets/Scripts/Data/InsectLevelCurve.cs (progression_sim과 동일) +BASE_CANDY = 4 +CANDY_GROWTH = 1.14 + +# 사용자 행동 프로파일 +PROFILES = { + "ftp": {"gem_topup_per_week": 0, "label": "Free-to-play (충전 없음)"}, + "mid": {"gem_topup_per_week": 400, "label": "중과금 (gem_550 주 1회)"}, + "whale": {"gem_topup_per_week": 2700, "label": "고래 (gem_1200 주 3회)"}, +} + + +def candy_cost(level: int) -> int: + return max(1, int(BASE_CANDY * (CANDY_GROWTH ** (level - 1)))) + + +def estimate_daily_candy(captures_per_day: int, battles_per_day: int, + raids_per_week: int, avg_candy_per_event: float = 5.0) -> float: + """행동량 기반 일일 캔디 평균 수입 추정. 레이드는 ×3 보상 + 주->일 환산.""" + capture_income = captures_per_day * avg_candy_per_event + battle_income = battles_per_day * avg_candy_per_event + raid_income = raids_per_week * avg_candy_per_event * 3.0 / 7.0 + return capture_income + battle_income + raid_income + + +def candy_bottleneck_days(team_size: int, current_level: int, + daily_candy_income: float) -> float: + """팀원 동시 레벨업 비용 / 일일 캔디 수입.""" + cost_per_member = candy_cost(current_level) + total = cost_per_member * team_size + if daily_candy_income <= 0: + return float("inf") + return total / daily_candy_income + + +# gem_balance_after는 삭제했다 — 동어반복(income − spend = topup×주 − gacha×silver×주)이라 +# FTP(topup=0)면 항상 음수를 "측정"했다. 젬 획득 경로 검사(gem_free_income_count)로 대체. + + +def _scan_files(root: str = "Assets/Scripts", suffix: str = ".cs"): + """root 하위 .cs 파일 경로 yield.""" + if not os.path.isdir(root): + return + for dirpath, _, filenames in os.walk(root): + for fn in filenames: + if fn.endswith(suffix): + yield os.path.join(dirpath, fn) + + +def _count_matches(pattern: str, exclude_definitions: bool = True) -> int: + """pattern 매칭 라인 수. 메서드 정의 라인 제외 옵션.""" + rx = re.compile(pattern) + def_rx = re.compile(r"\b(public|private|protected|internal)\s+\w+\s+\w+\s*\(") + total = 0 + for path in _scan_files(): + try: + with open(path, encoding="utf-8") as f: + for line in f: + if rx.search(line): + if exclude_definitions and def_rx.search(line): + continue + total += 1 + except (OSError, UnicodeDecodeError): + continue + return total + + +def coin_income_count() -> int: + """AddCoins 호출부 (정의 제외).""" + return _count_matches(r"\.AddCoins\s*\(|\bwallet\.AddCoins\s*\(") + + +def coin_spend_count() -> int: + return _count_matches(r"\.SpendCoins\s*\(|\bwallet\.SpendCoins\s*\(") + + +def gem_free_income_count() -> int: + """무료 젬 **양수 지급** 호출부. 유료 결제·차감·복원·치트는 제외. + + 예전엔 gem_balance_after가 `topup×주 − gacha×silver×주`로 잔고를 "계산"했다. 그건 + 동어반복이었다 — FTP(topup=0)면 무조건 음수가 나오고, 그 음수는 측정이 아니라 가정한 + 지출이 되돌아온 것이다. 젬은 음수가 될 수도 없다(SpendGems/PurchaseWithGems가 거절). + 코인 데드 화폐를 faucet 유무로 잡았듯, 젬도 무료 수입원 유무로 본다. + + 단, `.AddGems(`를 그냥 세면 오탐이 쏟아진다(실측: 5건 전부 무료 수입 아님): + - `AddGems(-item.gemPrice)` / `AddGems(-price)` — 음수, 구매 차감 + - `AddGems(diff)` — 클라우드 복원(동기화) + - `AddGems(99999 - Gems)` — 디버그 치트 + - `// ...AddGems(-price)` — 주석 + 그래서 strip_cs로 주석을 지우고, 인자가 음수(`-`)거나 Gems·diff를 참조하면(복원·치트) + 뺀다. CashShopManager(GrantGemPackage=유료 패키지 지급)도 통째로 제외. + 이래도 grep 휴리스틱이라 완벽하진 않다 — 진짜 무료 지급이 늘면 수동 확인 권장. + """ + total = 0 + for path in _scan_files(): + if "CashShopManager" in path: + continue + try: + with open(path, encoding="utf-8") as f: + src = strip_cs(f.read()) + except (OSError, UnicodeDecodeError): + continue + for m in re.finditer(r"\.AddGems\s*\(([^)]*)\)", src): + arg = m.group(1).strip() + if arg.startswith("-"): + continue # 차감 (구매) + if "Gems" in arg or "diff" in arg: + continue # 복원(diff) / 치트(99999 - Gems) + total += 1 + return total + + +def candy_gem_exchange_count() -> int: + """같은 라인에 (Add|Spend)Gems와 (Add|Spend)Candy 동시 등장.""" + rx = re.compile(r"(Add|Spend)Gems.*?(Add|Spend)Candy|(Add|Spend)Candy.*?(Add|Spend)Gems") + total = 0 + for path in _scan_files(): + try: + with open(path, encoding="utf-8") as f: + for line in f: + if rx.search(line): + total += 1 + except (OSError, UnicodeDecodeError): + continue + return total + + +def p2w_outfit_gap() -> dict: + """프리미엄 의상 평균 젬 가격 / 베이직 의상 평균 코인 가격.""" + # 정본은 CharacterOutfitManager.cs MakeBaseItem/MakePremiumItem 호출 + # 단순화: 사용자 입력 또는 휴리스틱 + return {"basic_avg_coin": 300, "premium_avg_gem": 1100, + "ratio_note": "평균 의상가 — 정확값은 CharacterOutfitManager.cs 인스펙터 참조"} + + +def evaluate_signals(args) -> list: + signals = [] + + # 1. 코인 발행 경로 (수입) + coin_income = coin_income_count() + coin_spend = coin_spend_count() + if coin_income == 0 and coin_spend > 0: + signals.append(("코인 발행 경로", ">= 1", f"수입 {coin_income} / 지출 {coin_spend} (데드 화폐)", "FAIL")) + else: + signals.append(("코인 발행 경로", ">= 1", f"수입 {coin_income} / 지출 {coin_spend}", "PASS")) + + # 2. 캔디 병목 + bottleneck = candy_bottleneck_days(args.team_size, args.current_level, args.daily_candy_income) + judge = "FAIL" if bottleneck >= THRESHOLD_CANDY_BOTTLENECK_DAYS_FAIL else "PASS" + signals.append((f"중반 캔디 병목 ({args.team_size}마리, Lv{args.current_level})", + f"< {THRESHOLD_CANDY_BOTTLENECK_DAYS_FAIL:.1f}일", + f"{bottleneck:.1f}일", judge)) + + # 3. 젬 획득 경로 — 코인과 대비. 젬은 하드 화폐라 유료 획득이 정상이다. + # 코인은 무료·유료 faucet 둘 다 0 + sink >0 이라 완전 데드(FAIL). + # 젬은 무료 faucet 0이어도 유료 패키지(CASH_GEM_PACKAGES)가 있으면 하드 화폐 설계 → PASS. + # 무료도 유료도 없을 때만 진짜 문제(WARN). + gem_faucet = gem_free_income_count() + has_paid = len(CASH_GEM_PACKAGES) > 0 + if gem_faucet == 0 and not has_paid: + judge, note = "WARN", "젬 획득 경로 전무" + elif gem_faucet == 0: + judge, note = "PASS", "무료 없음 — 유료 패키지로만 획득(하드 화폐, 가챠 게임 관행)" + else: + judge, note = "PASS", f"무료 수입원 {gem_faucet}건" + signals.append(("젬 획득 경로", ">= 1 (무료 또는 유료)", note, judge)) + + # 4. 캔디↔젬 교환 — 부재 자체는 정상 설계(소프트→하드 전환은 가챠 게임 관행). + # "강제 결제 유도" 낙인은 미검증 가치판단이라 뺀다. 정보성으로만 남긴다. + exchange = candy_gem_exchange_count() + signals.append(("캔디↔젬 교환 경로 (정보)", "-", + f"{exchange}건" + (" (없음 — 소프트↔하드 전환은 관행상 미제공)" if exchange == 0 else ""), + "PASS")) + + # 5. P2W 격차 (의상 가격대비) + p2w = p2w_outfit_gap() + ratio = p2w["premium_avg_gem"] / max(1, p2w["basic_avg_coin"]) + judge = "WARN" if ratio >= THRESHOLD_P2W_GAP_RATIO_WARN else "PASS" + signals.append(("프리미엄 vs 베이직 의상 가격비", + f"< {THRESHOLD_P2W_GAP_RATIO_WARN:.1f}x", + f"{ratio:.1f}x (코인 {p2w['basic_avg_coin']} vs 젬 {p2w['premium_avg_gem']})", + judge)) + + return signals + + +def render_signals(signals: list) -> str: + out = ["| 항목 | 임계값 | 측정값 | 판정 |", "|------|--------|--------|------|"] + for name, threshold, value, judge in signals: + out.append(f"| {name} | {threshold} | {value} | **{judge}** |") + fail_n = sum(1 for s in signals if s[3] == "FAIL") + warn_n = sum(1 for s in signals if s[3] == "WARN") + pass_n = sum(1 for s in signals if s[3] == "PASS") + out.append("") + out.append(f"요약: **{fail_n} FAIL** / {warn_n} WARN / {pass_n} PASS") + if fail_n > 0: + out.append("→ FAIL 1건 이상. PASS 보고 금지. Phase 4 권장사항 적용 필요.") + return "\n".join(out) + + +def main(): + p = argparse.ArgumentParser(description="경제 균형 시뮬") + p.add_argument("--days", type=int, default=14, help="시뮬 기간 (일, 기본 14)") + p.add_argument("--profile", default="ftp", choices=list(PROFILES.keys())) + p.add_argument("--gacha-per-week", type=int, default=2) + p.add_argument("--captures-per-day", type=int, default=20) + p.add_argument("--battles-per-day", type=int, default=10) + p.add_argument("--raids-per-week", type=int, default=3) + p.add_argument("--daily-candy-income", type=float, default=None, + help="일일 캔디 평균 수입. 지정 안 하면 captures/battles/raids에서 자동 계산") + p.add_argument("--avg-candy-per-event", type=float, default=5.0, + help="이벤트(포획/전투/레이드 1회)당 평균 캔디 (기본 5)") + p.add_argument("--team-size", type=int, default=TEAM_MAX_SLOTS) + p.add_argument("--current-level", type=int, default=25, + help="병목 점검 시 곤충 레벨 (기본 25)") + args = p.parse_args() + + # daily-candy-income이 None이면 행동량 기반 자동 계산 + if args.daily_candy_income is None: + args.daily_candy_income = estimate_daily_candy( + args.captures_per_day, args.battles_per_day, + args.raids_per_week, args.avg_candy_per_event + ) + income_source = f"자동 계산 (포획 {args.captures_per_day}/일, 전투 {args.battles_per_day}/일, 레이드 {args.raids_per_week}/주, 이벤트당 {args.avg_candy_per_event} 캔디)" + else: + income_source = "사용자 직접 입력" + + print(f"# economy-sim — {args.days}일 시뮬 ({PROFILES[args.profile]['label']})\n") + + print("## 입력 파라미터") + print(f"- days: {args.days}, profile: {args.profile}") + print(f"- 행동: 가챠 {args.gacha_per_week}/주, 포획 {args.captures_per_day}/일, 전투 {args.battles_per_day}/일, 레이드 {args.raids_per_week}/주") + print(f"- daily_candy_income: {args.daily_candy_income:.1f} ({income_source})") + print(f"- team_size: {args.team_size} / current_level: Lv{args.current_level}") + print() + + print("## 캐시샵 정본") + for krw, gems in CASH_GEM_PACKAGES: + print(f"- {krw:,}원 → {gems}젬 (KRW/gem = {krw/gems:.2f})") + print(f"- 가챠 박스: bronze {GACHA_BOX_PRICES['bronze']}젬 / silver {GACHA_BOX_PRICES['silver']}젬 / gold {GACHA_BOX_PRICES['gold']}젬") + print() + + print("## 코드 결함 점검") + coin_in = coin_income_count() + coin_out = coin_spend_count() + gem_free = gem_free_income_count() + exch = candy_gem_exchange_count() + print(f"- AddCoins 호출부: {coin_in}건 (0 + 지출>0이면 데드 화폐)") + print(f"- SpendCoins 호출부: {coin_out}건") + print(f"- 무료 젬 양수 지급: {gem_free}건 (유료 패키지·차감·복원·치트 제외)") + print(f"- 캔디↔젬 교환: {exch}건 (부재는 하드 화폐 관행 — 결함 아님)") + print() + + print("## 위험 신호 표") + signals = evaluate_signals(args) + print(render_signals(signals)) + print() + + print("## 가정 / 한계") + print(f"- 일일 캔디 수입 {args.daily_candy_income} 가정 (사용자 입력 의존)") + print("- 사용자 행동 균등 가정 (실제는 초반 폭주 후반 정체)") + print("- 이벤트/시즌 보상 미반영") + print("- P2W 격차는 의상 평균가 휴리스틱 — 정확한 검증은 CharacterOutfitManager.cs 인스펙터 참조") + print("- 텔레메트리 수집 후 재검증 필수") + + fail_count = sum(1 for s in signals if s[3] == "FAIL") + return 1 if fail_count > 0 else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.claude/scripts/gacha_sim.py b/.claude/scripts/gacha_sim.py new file mode 100644 index 0000000..f34598e --- /dev/null +++ b/.claude/scripts/gacha_sim.py @@ -0,0 +1,349 @@ +"""가챠 박스 몬테카를로 시뮬 + UI-코드 정합성 자동 검증. + +수치 사본은 두지 않는다 — 전부 game_facts가 코드에서 읽는다. 예전엔 BOX_DEFS가 확률· +가격 사본을 들었고 실버/골드가 드리프트해 존재하지 않는 게임을 시뮬레이션했다 +(골드 Legendary 사본 5% vs 실제 45%). +""" +import argparse +import os +import random +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import game_facts # noqa: E402 + +if hasattr(sys.stdout, "reconfigure") and sys.stdout.encoding and sys.stdout.encoding.lower() != "utf-8": + sys.stdout.reconfigure(encoding="utf-8") # type: ignore[attr-defined] + +# === 임계값 === +# 근거: 100연차에 Legendary 0개 확률 50%+ = 천장 없는 가챠 = 사용자 보호 부재. 산업 관행 50~100연 천장. +THRESHOLD_NO_PITY_LEGENDARY_FAIL = 0.50 # 100연차 Legendary 0개 확률 50%+ = FAIL +# 근거: UI 텍스트와 코드 확률 분리는 UX 신뢰 위반. 사용자에게 표시된 값과 실 확률 불일치 = 환불 사유. +THRESHOLD_UI_CODE_MISMATCH_FAIL = True # UI 텍스트 ≠ 코드 = FAIL +# 근거: UI 표기 가격과 Manager 정본 가격이 갈리면 결제 표시 오인 — 환불 사유. +THRESHOLD_PRICE_MISMATCH_FAIL = True # CashShopUI 가격 ≠ Manager 가격 = FAIL +# 의미: 차상위 박스의 EV 증가율이 가격 증가율 × 이 비율 미만이면 가성비 함정 WARN. +# 예) 가격 +60%일 때 EV 증가율이 +36%(60×0.6) 미만이면 WARN. +THRESHOLD_VALUE_TRAP_RATIO_WARN = 0.6 # 가격 증가율 대비 EV 증가율 최소 비율 +# 근거: 100연차에 평균 15회 이상 중복 = 신규 곤충 획득 효율 너무 낮음. 풀 크기 확대 또는 천장 신설 필요. +THRESHOLD_EXCLUSIVE_DUP_WARN = 15 # 100연차 평균 중복 15+ = WARN +# 근거: 필드 샤이니 1% vs 가챠 0%는 가챠 매력 저하. 가챠 자체 보너스 디자인 부재 신호. +THRESHOLD_SHINY_GACHA_GAP_WARN = True # 가챠 샤이니 0% vs 필드 1% = WARN + +# === 정본 = 코드. 사본 없음 === +# 전부 game_facts가 GachaBoxManager.cs / CashShopManager.cs / InsectRewardCalculator.cs / +# InsectEntity.cs에서 직접 읽는다. 추출이 실패하면 낡은 값으로 조용히 시뮬을 돌리는 대신 +# 여기서 exit 2로 죽는다. 이 로딩은 import 시점에 돌아서 main()의 try보다 이르므로 +# 트레이스백이 새어나가지 않게 여기서 잡는다. +def _load_facts(): + try: + pcts = game_facts.gacha_rarity_pcts() + prices = game_facts.box_gem_prices() + excl = game_facts.gacha_exclusive_chances() + candy = game_facts.gacha_candy_bonus() + return ( + { + box: { + "price_manager": prices[box], + "rarity_pcts": pcts[box], + "exclusive_chance": excl[box], + "candy_bonus_min": candy[box][0], + "candy_bonus_max": candy[box][1], + } + for box in game_facts.BOXES + }, + game_facts.gacha_exclusive_pool_sizes(), + game_facts.rarity_multipliers(), + game_facts.field_shiny_pct(), + 0.0 if not game_facts.gacha_has_shiny() else float("nan"), + ) + except game_facts.ExtractorBroken as e: + print(f"추출기 고장: {e}\n", file=sys.stderr) + print("게임 수치를 코드에서 읽지 못했다. 시뮬 결과는 무의미하므로 실행하지 않는다.", + file=sys.stderr) + sys.exit(2) + + +# 가챠 EV 가중치 = 게임 내 실제 보상 배율 — 박스 1회 가치를 보상 가치 기준으로 환산. +BOX_DEFS, EXCLUSIVE_POOL_SIZE, RARITY_MULT, FIELD_SHINY_PCT, GACHA_SHINY_PCT = _load_facts() + +# Lv30 캔디 비용 (progression_sim과 일치) +LV30_CANDY_COST = int(4 * (1.14 ** 29)) + + +def draw_rarity(box: str, rng: random.Random) -> str: + """확률 분포에 따라 레어도 1개 추첨.""" + r = rng.uniform(0, 100) + cum = 0.0 + for rarity in ["Common", "Uncommon", "Rare", "Epic", "Legendary"]: + cum += BOX_DEFS[box]["rarity_pcts"][rarity] + if r < cum: + return rarity + return "Legendary" + + +def simulate_pulls(box: str, pulls: int, rng: random.Random) -> dict: + """1회 시도(pulls회 가챠)의 결과.""" + counts = {"Common": 0, "Uncommon": 0, "Rare": 0, "Epic": 0, "Legendary": 0} + candy = 0 + for _ in range(pulls): + rarity = draw_rarity(box, rng) + counts[rarity] += 1 + candy += rng.randint(BOX_DEFS[box]["candy_bonus_min"], + BOX_DEFS[box]["candy_bonus_max"]) + return {"counts": counts, "candy": candy} + + +def monte_carlo(box: str, pulls: int, trials: int, seed: int) -> dict: + """몬테카를로 trials회 반복. 5%/50%/95% 분위 + 평균.""" + rng = random.Random(seed) + legendary_results = [] + epic_results = [] + candy_results = [] + for _ in range(trials): + r = simulate_pulls(box, pulls, rng) + legendary_results.append(r["counts"]["Legendary"]) + epic_results.append(r["counts"]["Epic"]) + candy_results.append(r["candy"]) + + def stats(arr): + s = sorted(arr) + n = len(s) + return { + "min": s[0], + "p5": s[n // 20], + "p50": s[n // 2], + "mean": sum(s) / n, + "p95": s[int(n * 0.95)], + "max": s[-1], + "zero": sum(1 for x in arr if x == 0) / n, + } + + return { + "legendary": stats(legendary_results), + "epic": stats(epic_results), + "candy": stats(candy_results), + } + + +def expected_value(box: str) -> float: + """RARITY_MULT(보상 배율 정본)로 박스 1회 기댓값 산출.""" + pcts = BOX_DEFS[box]["rarity_pcts"] + return sum(RARITY_MULT[r] * pcts[r] / 100.0 for r in RARITY_MULT) + + +def render_box_comparison() -> str: + """박스 간 가격당 EV 비교 표. 차상위 박스의 효율 증가가 가격 증가에 부합하는지 가시화.""" + out = ["| 박스 | 가격(젬) | Legendary % | EV (가중) | 가격당 EV(×1000) | bronze 대비 효율 |", + "|------|---------:|------------:|----------:|----------------:|----------------:|"] + bronze_ev_per_gem = expected_value("bronze") / BOX_DEFS["bronze"]["price_manager"] + for box, defs in BOX_DEFS.items(): + ev = expected_value(box) + ev_per_gem = ev / defs["price_manager"] + ratio = ev_per_gem / bronze_ev_per_gem + out.append(f"| {box} | {defs['price_manager']} | {defs['rarity_pcts']['Legendary']}% | {ev:.2f} | {ev_per_gem*1000:.3f} | {ratio:.2f}x |") + return "\n".join(out) + + +def epic_exclusive_duplicates(box: str, pulls: int, trials: int, seed: int) -> float: + """Epic 전용곤충 풀에서 중복 등장 평균. (Epic 추첨 횟수 - Epic 풀 크기)의 평균.""" + rng = random.Random(seed + 1) # 메인 시뮬과 다른 시드 + pool_size = EXCLUSIVE_POOL_SIZE["Epic"] + exclusive_chance = BOX_DEFS[box]["exclusive_chance"] + duplicates = [] + for _ in range(trials): + epic_exclusive_count = 0 + for _ in range(pulls): + rarity = draw_rarity(box, rng) + if rarity == "Epic" and rng.random() < exclusive_chance: + epic_exclusive_count += 1 + # pool_size 이상 뽑으면 그 만큼 중복 발생 (균등 분포 가정 단순화) + dup = max(0, epic_exclusive_count - pool_size) + duplicates.append(dup) + return sum(duplicates) / len(duplicates) + + +def prob_zero_legendary_analytic(box: str, pulls: int) -> float: + """해석적 P(Legendary 0개 in N연차) = (1-p)^N.""" + p = BOX_DEFS[box]["rarity_pcts"]["Legendary"] / 100.0 + return (1 - p) ** pulls + + +# === UI-코드 정합성 === +# 추출은 전부 game_facts가 소유한다. 한때 data_lint의 extract_cashshop_ui_boxes를 빌려 +# 썼는데, 그 추출기는 rateText 문자열 리터럴을 찾다가 0개를 반환하고 있었다 — UI가 +# 확률을 코드 파생으로 바꾼 뒤로. 그 결과 이쪽은 "UI에서 추출 실패" FAIL을 찍고 +# data_lint 쪽은 공허한 PASS를 찍었다. 같은 고장이 두 얼굴로 나타난 셈이다. + +def check_price_mismatch() -> list: + """UI 표시 가격 vs 정본(Manager) 가격 비교. 표시 ≠ 차감 = 결제 오인.""" + ui_prices = game_facts.ui_box_prices() + return [ + (box, ui_prices[box], BOX_DEFS[box]["price_manager"]) + for box in game_facts.BOXES + if ui_prices[box] != BOX_DEFS[box]["price_manager"] + ] + + +def evaluate_signals(args) -> list: + signals = [] + + # 1. 천장 부재 — Legendary 0개 확률 + p0 = prob_zero_legendary_analytic(args.box, args.pulls) + judge = "FAIL" if p0 >= THRESHOLD_NO_PITY_LEGENDARY_FAIL else "PASS" + signals.append((f"천장 부재: {args.box} {args.pulls}연차 Legendary 0개", + f"< {THRESHOLD_NO_PITY_LEGENDARY_FAIL*100:.0f}%", + f"{p0*100:.1f}%", judge)) + + # 2. 가격 불일치 (UI 표시 vs Manager 정본) + price_mm = check_price_mismatch() + judge = "FAIL" if price_mm else "PASS" + details = (", ".join(f"{b}: UI={u} 정본={m}" for b, u, m in price_mm) if price_mm + else f"0건 (검사한 박스 {len(game_facts.BOXES)}개)") + signals.append(("CashShopUI 표시 가격 vs Manager 정본", "0건 불일치", details, judge)) + + # 3. UI 확률 표기가 코드 파생인가 (하드코딩 회귀 감시) + # 값 비교였다가 폐물이 됐다 — UI가 GetGachaRateText → GachaBoxManager.GetRateText → + # GetRates → *Thresholds로 파생받게 바뀌었다. 남은 위험은 하드코딩 부활뿐이다. + hardcoded = game_facts.ui_hardcoded_rate_literals() + if hardcoded: + judge, details = "FAIL", f"확률 리터럴 {len(hardcoded)}건 부활 ({hardcoded})" + elif not game_facts.ui_derives_gacha_rates(): + judge, details = "FAIL", "GetGachaRateText → GachaBoxManager 파생 사슬이 끊김" + else: + judge, details = "PASS", "코드 파생 + 리터럴 0건" + signals.append(("UI 확률 표기 = 코드 파생 (하드코딩 회귀)", "파생 유지 + 리터럴 0건", + details, judge)) + + # 4. 실버 가성비 + bronze_ev = expected_value("bronze") + silver_ev = expected_value("silver") + ev_gain = (silver_ev - bronze_ev) / bronze_ev + price_gain = (BOX_DEFS["silver"]["price_manager"] - BOX_DEFS["bronze"]["price_manager"]) / BOX_DEFS["bronze"]["price_manager"] + judge = "WARN" if ev_gain < price_gain * THRESHOLD_VALUE_TRAP_RATIO_WARN else "PASS" + signals.append(("실버 가성비 (브론즈 대비)", + f"기댓값 +{price_gain*THRESHOLD_VALUE_TRAP_RATIO_WARN*100:.0f}%+", + f"가격 +{price_gain*100:.0f}% / 기댓값 +{ev_gain*100:.0f}%", judge)) + + # 4-b. 골드 가성비 (실버 대비) — 차상위 박스가 효율적인지 + silver_ev_b = expected_value("silver") + gold_ev = expected_value("gold") + ev_gain_g = (gold_ev - silver_ev_b) / silver_ev_b if silver_ev_b > 0 else 0 + price_gain_g = (BOX_DEFS["gold"]["price_manager"] - BOX_DEFS["silver"]["price_manager"]) / BOX_DEFS["silver"]["price_manager"] + judge = "WARN" if ev_gain_g < price_gain_g * THRESHOLD_VALUE_TRAP_RATIO_WARN else "PASS" + signals.append(("골드 가성비 (실버 대비)", + f"기댓값 +{price_gain_g*THRESHOLD_VALUE_TRAP_RATIO_WARN*100:.0f}%+", + f"가격 +{price_gain_g*100:.0f}% / 기댓값 +{ev_gain_g*100:.0f}%", judge)) + + # 4-c. 박스 가성비 역전 — 차상위 박스의 가격당 EV가 차하위보다 낮으면 가성비 함정 신호 + ev_per_gem = {b: expected_value(b) / BOX_DEFS[b]["price_manager"] for b in BOX_DEFS} + inversions = [] + if ev_per_gem["silver"] < ev_per_gem["bronze"]: + inversions.append(f"silver({ev_per_gem['silver']*1000:.3f}) < bronze({ev_per_gem['bronze']*1000:.3f})") + if ev_per_gem["gold"] < ev_per_gem["silver"]: + inversions.append(f"gold({ev_per_gem['gold']*1000:.3f}) < silver({ev_per_gem['silver']*1000:.3f})") + if ev_per_gem["gold"] < ev_per_gem["bronze"]: + inversions.append(f"gold({ev_per_gem['gold']*1000:.3f}) < bronze({ev_per_gem['bronze']*1000:.3f})") + judge = "WARN" if inversions else "PASS" + signals.append(("박스 가성비 역전 (×1000 단위)", + "역전 0건", + "; ".join(inversions) if inversions else "0건", + judge)) + + # 5. 골드 캔디보너스 가치 + avg_candy = (BOX_DEFS["gold"]["candy_bonus_min"] + BOX_DEFS["gold"]["candy_bonus_max"]) / 2 + candy_ratio = avg_candy / LV30_CANDY_COST + judge = "WARN" if candy_ratio < 0.5 else "PASS" + signals.append(("골드 캔디보너스 / Lv30 레벨업비", + ">= 50%", + f"{candy_ratio*100:.1f}% ({avg_candy:.0f}/{LV30_CANDY_COST})", judge)) + + # 6. Epic 전용곤충 중복 (풀이 작아 중복 누적) + avg_dup = epic_exclusive_duplicates(args.box, args.pulls, min(args.trials, 5000), args.seed) + judge = "WARN" if avg_dup >= THRESHOLD_EXCLUSIVE_DUP_WARN else "PASS" + signals.append((f"Epic 전용곤충 {args.pulls}연차 평균 중복 (풀 {EXCLUSIVE_POOL_SIZE['Epic']}종)", + f"< {THRESHOLD_EXCLUSIVE_DUP_WARN}", + f"{avg_dup:.1f}", judge)) + + # 7. 가챠 샤이니 + judge = "WARN" if GACHA_SHINY_PCT < FIELD_SHINY_PCT else "PASS" + signals.append(("가챠 샤이니 적용", + f"{FIELD_SHINY_PCT}%", + f"{GACHA_SHINY_PCT}% (코드 미구현)", judge)) + + return signals + + +def render_distribution(mc: dict) -> str: + out = ["| 항목 | min | p5 | p50 | mean | p95 | max | 0개 비율 |", + "|------|----:|---:|----:|-----:|----:|----:|---------:|"] + for key in ["legendary", "epic", "candy"]: + s = mc[key] + out.append(f"| {key.title()} | {s['min']} | {s['p5']} | {s['p50']} | {s['mean']:.1f} | {s['p95']} | {s['max']} | {s['zero']*100:.1f}% |") + return "\n".join(out) + + +def render_signals(signals: list) -> str: + out = ["| 항목 | 임계값 | 측정값 | 판정 |", "|------|--------|--------|------|"] + for name, threshold, value, judge in signals: + out.append(f"| {name} | {threshold} | {value} | **{judge}** |") + fail_n = sum(1 for s in signals if s[3] == "FAIL") + warn_n = sum(1 for s in signals if s[3] == "WARN") + pass_n = sum(1 for s in signals if s[3] == "PASS") + out.append("") + out.append(f"요약: **{fail_n} FAIL** / {warn_n} WARN / {pass_n} PASS") + if fail_n > 0: + out.append("→ FAIL 1건 이상. PASS 보고 금지.") + return "\n".join(out) + + +def main(): + p = argparse.ArgumentParser(description="가챠 박스 몬테카를로 + UI-코드 정합성") + p.add_argument("--box", default="bronze", choices=["bronze", "silver", "gold", "all"]) + p.add_argument("--pulls", type=int, default=100) + p.add_argument("--trials", type=int, default=10000) + p.add_argument("--seed", type=int, default=42) + args = p.parse_args() + + boxes = ["bronze", "silver", "gold"] if args.box == "all" else [args.box] + + print(f"# gacha-sim — 몬테카를로 {args.trials:,}회, {args.pulls}연차/시도\n") + + print("## 박스 정본 — GachaBoxManager.cs / CashShopManager.cs에서 실시간 추출 (사본 없음)") + for b, d in BOX_DEFS.items(): + pcts = d["rarity_pcts"] + print(f"- {b} ({d['price_manager']}젬): C{pcts['Common']}/U{pcts['Uncommon']}/R{pcts['Rare']}/E{pcts['Epic']}/L{pcts['Legendary']}, 픽업{int(d['exclusive_chance']*100)}%") + print() + + print("## 박스 간 가성비 비교 (가격당 가중 기댓값)") + print(render_box_comparison()) + print() + + for box in boxes: + args.box = box + print(f"## {box} 박스 분포 ({args.pulls}연차 × {args.trials:,}회 시도)") + mc = monte_carlo(box, args.pulls, args.trials, args.seed) + print(render_distribution(mc)) + print(f"- 해석적 P(Legendary 0): {prob_zero_legendary_analytic(box, args.pulls)*100:.2f}%") + print(f"- 박스 1회 기댓값(가중): {expected_value(box):.2f}") + print() + + # 신호는 box=bronze 기준 1회만 (가격/확률 정합성은 박스 무관 전체) + args.box = boxes[0] + print("## 위험 신호 표") + signals = evaluate_signals(args) + print(render_signals(signals)) + print() + + print("## 가정 / 한계") + print("- 천장(pity) 미구현 가정 — 실제 코드와 일치") + print("- 곤충 풀 균등 무작위 가정 (가중치 데이터 미확인)") + print("- IV 미적용 — 곤충별 스탯 편차 무시") + print(f"- random.seed={args.seed} (--seed로 변경 가능)") + + fail_count = sum(1 for s in signals if s[3] == "FAIL") + return 1 if fail_count > 0 else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.claude/scripts/game_facts.py b/.claude/scripts/game_facts.py new file mode 100644 index 0000000..965f6f8 --- /dev/null +++ b/.claude/scripts/game_facts.py @@ -0,0 +1,1012 @@ +"""게임 코드가 말하는 사실을 읽는 단일 모듈. 하네스는 수치 사본을 들지 않는다. + +왜 있나 +------- +`.claude/rules/balance.md`와 CLAUDE.md는 "수치의 단일 출처는 코드"라고 못박는다. +그런데 정작 그걸 검증하는 하네스 자신이 사본을 들고 있었다. gacha_sim.py의 BOX_DEFS는 +골드 Legendary를 **5%**로 알고 있었지만 코드는 **45%**였다(GoldThresholds={5,10,23,55} +→ L=100-55). 실버/골드 가격도 800/1200으로 알았지만 실제는 600/750이었다. 브론즈만 +맞았다 — 즉 원래 맞았던 사본이 코드 변경을 못 따라간 순수한 드리프트다. + +그 위에서 돌린 시뮬은 존재하지 않는 게임을 시뮬레이션했고, "천장 부재" 판정과 +"골드 Legendary 5%→7% 상향" 권고는 정반대로 무의미했다. 아이러니하게도 코드는 이미 +GetGachaRates(boxId)로 "UI 표기 단일 출처"를 만들어놨다 — 코드가 사본을 없앤 뒤에도 +하네스만 사본을 붙들고 있었다. + +그래서 사실을 읽는 곳을 하나로 모은다. 여기가 유일한 추출 지점이다. + +썩을 때 거짓말하지 않는 법 +-------------------------- +정규식 추출기는 앞으로도 리팩터링을 따라가지 못한다. 그건 막을 수 없다. +막을 수 있는 건 **썩으면서 거짓말하는 것**이다. 기대한 심볼을 못 찾으면 빈 값을 +반환하고 계속 가는 대신 ExtractorBroken으로 죽는다. 호출자는 exit 2로 종료해 +"데이터 결함"(exit 1)과 "검증기 자신의 고장"(exit 2)을 구별해야 한다. + +조용한 오탐은 무시된다. 무시되는 검증기는 없느니만 못하다. +""" +import os +import re + +from cs_strip import strip_cs # 주석/문자열 제거 — 배선 분석이 주석을 코드로 오인하지 않게 + +BOXES = ("bronze", "silver", "gold") + +PATHS = { + "gacha": "Assets/Scripts/Core/GachaBoxManager.cs", + "cash_shop": "Assets/Scripts/Core/CashShopManager.cs", + "cash_shop_ui": "Assets/Scripts/UI/CashShopUI.cs", + "reward_calc": "Assets/Scripts/Data/InsectRewardCalculator.cs", + "tutorial": "Assets/Scripts/Core/TutorialQuestManager.cs", + "insect_entity": "Assets/Scripts/Spawning/InsectEntity.cs", + "raid": "Assets/Scripts/Battle/RaidBattleController.cs", + "game_constants": "Assets/Scripts/Core/GameConstants.cs", + "trainer_progress": "Assets/Scripts/Core/PlayerProgressController.cs", + "insect_curve": "Assets/Scripts/Data/InsectLevelCurve.cs", + "bootstrap": "Assets/Scripts/Core/PlaySceneBootstrap.cs", + "insect_expansion": "Assets/Scripts/Data/InsectExpansionDefinitions.cs", + # 2막(ver2) 곤충 시드. 1막 확장과 파일이 갈린 이유는 InsectExpansion2Definitions 주석 참조. + # 곤충 ID를 읽는 곳(all_insect_ids / field_roster)은 두 파일을 **모두** 봐야 한다 — + # 한쪽만 보면 2막 종이 통째로 "존재하지 않는 ID"로 잘못 보고된다. + "insect_expansion2": "Assets/Scripts/Data/InsectExpansion2Definitions.cs", + "region_defs": "Assets/Scripts/Core/RegionDefinitions.cs", + "region_data": "Assets/Scripts/Data/RegionData.cs", + "tutorial_data": "Assets/Scripts/Core/TutorialQuestData.cs", + "npc_dialogue": "Assets/Scripts/NPC/NpcDialogueDatabase.cs", + "npc_manager": "Assets/Scripts/NPC/NpcManager.cs", + "npc_visual": "Assets/Scripts/NPC/NpcVisualBuilder.cs", + "story_json": "Assets/Resources/Story.json", + "story_director": "Assets/Scripts/Story/StoryDirector.cs", + "item_db": "Assets/Scripts/Data/ItemDatabase.cs", + "village_builder": "Assets/Scripts/Core/VillageBuilder.cs", + "stage_library": "Assets/Scripts/Story/StoryStageLibrary.cs", + "subarea_builder": "Assets/Scripts/Core/SubAreaWorldBuilder.cs", +} + +RARITIES = ("Common", "Uncommon", "Rare", "Epic", "Legendary") + + +class ExtractorBroken(Exception): + """기대한 심볼을 코드에서 찾지 못했다 — 데이터 결함이 아니라 추출기 자신의 고장.""" + + +def _read(key: str) -> str: + path = PATHS[key] + if not os.path.isfile(path): + raise ExtractorBroken(f"{path}가 없다 — 파일이 옮겨갔는가?") + with open(path, encoding="utf-8") as f: + return f.read() + + +def _need(m, what: str, key: str): + if not m: + raise ExtractorBroken(f"{PATHS[key]}에서 {what}을(를) 찾지 못했다 — 개명/이동했는가?") + return m + + +# ── 가챠 ──────────────────────────────────────────────────────────────────── + +def gacha_thresholds() -> dict: + """{"bronze": [C상한, U상한, R상한, E상한], ...} 누적 임계값(roll 0~100). + + 출처: GachaBoxManager.cs의 Bronze/Silver/GoldThresholds 상수 배열. + Get{box}Rarity()는 이 배열을 GetRarityByThresholds()에 넘기는 한 줄일 뿐이므로 + 메서드 본체를 파봐야 리터럴이 없다(예전 추출기가 여기서 조용히 실패했다). + """ + src = _read("gacha") + out = {} + for box in BOXES: + name = box.capitalize() + "Thresholds" + m = _need(re.search(rf"{name}\s*=\s*\{{([^}}]*)\}}", src), f"{name} 배열", "gacha") + vals = [float(x) for x in re.findall(r"([\d.]+)f", m.group(1))] + if len(vals) != 4: + raise ExtractorBroken( + f"{name}에서 임계값 4개를 기대했으나 {len(vals)}개 추출 ({vals}) — 구조가 바뀌었는가?" + ) + if vals != sorted(vals): + raise ExtractorBroken(f"{name}이 단조증가가 아니다 ({vals}) — 추출이 어긋났는가?") + out[box] = vals + return out + + +def gacha_rarity_pcts() -> dict: + """{"bronze": {"Common": 55.0, ..., "Legendary": 0.5}, ...} 등급별 확률(%). + + 환산식은 GachaBoxManager.cs 주석이 명시한다: + 임계 [a,b,c,d] → C=a, U=b-a, R=c-b, E=d-c, L=100-d. + """ + out = {} + for box, t in gacha_thresholds().items(): + a, b, c, d = t + out[box] = dict(zip(RARITIES, [a, b - a, c - b, d - c, 100.0 - d])) + return out + + +def gacha_exclusive_chances() -> dict: + """{"bronze": 0.2, "silver": 0.3, "gold": 0.5} — 전용(픽업) 곤충이 뽑힐 확률. + + 출처: PickRandomInsect()의 boxId 분기. gold/silver는 명시 분기, 나머지는 else. + """ + src = _read("gacha") + body = _need( + re.search(r"PickRandomInsect\s*\([^)]*\)\s*\{(.*?)\n\s{8}\}", src, re.DOTALL), + "PickRandomInsect() 본체", "gacha", + ).group(1) + out = {} + for box in ("gold", "silver"): + m = _need( + re.search(rf'boxId\s*==\s*"box_{box}"\s*\)\s*exclusiveChance\s*=\s*([\d.]+)f', body), + f"box_{box} exclusiveChance 분기", "gacha", + ) + out[box] = float(m.group(1)) + m = _need( + re.search(r"else\s+exclusiveChance\s*=\s*([\d.]+)f", body), + "기본(else) exclusiveChance", "gacha", + ) + out["bronze"] = float(m.group(1)) + return out + + +def gacha_candy_bonus() -> dict: + """{"bronze": (5, 15), ...} — 박스별 보너스 캔디 범위(양끝 포함). + + 출처: OpenBox()의 switch(boxId) 안 Random.Range(min, maxExclusive). + Unity의 int Random.Range는 상한 배타이므로 max는 -1 해서 돌려준다. + """ + src = _read("gacha") + out = {} + for box in BOXES: + m = _need( + re.search( + rf'case\s+"box_{box}":.*?Random\.Range\((\d+),\s*(\d+)\)', src, re.DOTALL + ), + f'case "box_{box}"의 보너스 캔디 Random.Range', "gacha", + ) + lo, hi_excl = int(m.group(1)), int(m.group(2)) + out[box] = (lo, hi_excl - 1) + return out + + +def gacha_exclusive_pool_sizes() -> dict: + """{"Rare": 3, "Epic": 4, "Legendary": 3} — 등급별 전용 곤충 풀 크기.""" + src = _read("gacha") + block = _need( + re.search(r"gachaExclusives\s*=\s*new\s+Dictionary[^{]*\{(.*?)\n\s{8}\};", src, re.DOTALL), + "gachaExclusives 딕셔너리", "gacha", + ).group(1) + out = {} + for rarity, ids in re.findall(r"InsectRarity\.(\w+)\s*,\s*new\[\]\s*\{([^}]*)\}", block): + out[rarity] = len(re.findall(r'"[^"]+"', ids)) + if not out: + raise ExtractorBroken("gachaExclusives에서 등급별 풀을 하나도 못 읽었다 — 구조가 바뀌었는가?") + return out + + +# ── 상점 ──────────────────────────────────────────────────────────────────── + +def box_gem_prices() -> dict: + """{"bronze": 500, "silver": 600, "gold": 750} — 가챠 박스 젬 가격(정본). + + 출처: CashShopManager.shopItems의 gemPrice. + """ + src = _read("cash_shop") + out = {} + for box in BOXES: + m = _need( + re.search(rf'itemId\s*=\s*"box_{box}".*?gemPrice\s*=\s*(\d+)', src, re.DOTALL), + f'box_{box}의 gemPrice', "cash_shop", + ) + out[box] = int(m.group(1)) + return out + + +def gem_packages() -> list: + """[(KRW, gems), ...] — 현금 젬 패키지. 출처: CashShopManager의 priceKRW > 0 품목.""" + src = _read("cash_shop") + out = [ + (int(krw), int(count)) + for krw, count in re.findall( + r"priceKRW\s*=\s*(\d+)\s*,\s*gemPrice\s*=\s*0\s*,\s*rewardCount\s*=\s*(\d+)", src + ) + if int(krw) > 0 + ] + if not out: + raise ExtractorBroken( + f"{PATHS['cash_shop']}에서 젬 패키지(priceKRW>0, gemPrice=0)를 찾지 못했다 — " + "필드 순서나 구조가 바뀌었는가?" + ) + return sorted(out) + + +def ui_box_prices() -> dict: + """{"bronze": 500, ...} — CashShopUI가 화면에 찍는 박스 가격. + + 출처: `GetGachaRateText("box_X"), , gems, mobile` 호출 인자. + 가격은 아직 UI 리터럴이라 정본(box_gem_prices)과 갈릴 수 있다 — 그게 검사 대상이다. + 표시 가격과 실제 차감액이 갈리면 결제 오인이므로 값이 아니라 **일치 여부**가 중요하다. + """ + src = _read("cash_shop_ui") + out = {} + for box in BOXES: + m = _need( + re.search(rf'GetGachaRateText\(\s*"box_{box}"\s*\)\s*,\s*(\d+)\s*,', src), + f'box_{box} 박스 카드의 가격 인자', "cash_shop_ui", + ) + out[box] = int(m.group(1)) + return out + + +def ui_derives_gacha_rates() -> bool: + """CashShopUI가 확률 표기를 코드에서 파생받는가(하드코딩이 아닌가). + + 한때는 UI가 확률 문자열을 하드코딩해서 "UI 텍스트 vs 코드 확률" 값 비교 검사가 + 의미 있었다. 지금은 GetGachaRateText → GachaBoxManager.GetRateText → GetRates → + *Thresholds로 파생된다(CashShopUI 주석: "하드코딩 금지(공시 위반 방지)"). + 그래서 값 비교는 폐물이고, 남은 위험은 **하드코딩이 되돌아오는 회귀**뿐이다. + """ + src = _read("cash_shop_ui") + return bool(re.search(r"GetGachaRateText\s*\(", src)) and bool( + re.search(r"mgr\.GetRateText\s*\(|GachaBoxManager\.Instance", src) + ) + + +def ui_hardcoded_rate_literals() -> list: + """UI에 되살아난 확률 표기 리터럴(예: "C:55% U:30%"). 있으면 공시 위반 회귀.""" + src = _read("cash_shop_ui") + return [ + m.group(0) + for m in re.finditer(r'"[^"]*\b[CUREL]\s*:\s*\d+(?:\.\d+)?\s*%[^"]*"', src) + ] + + +# ── 보상 ──────────────────────────────────────────────────────────────────── + +def rarity_multipliers() -> dict: + """{"Common": 1.0, ..., "Legendary": 2.8} — 보상 배율. + + 출처: InsectRewardCalculator.GetRarityMultiplier()의 case별 return. + """ + src = _read("reward_calc") + body = _need( + re.search(r"GetRarityMultiplier\s*\([^)]*\)\s*\{(.*?)\n\s{8}\}", src, re.DOTALL), + "GetRarityMultiplier() 본체", "reward_calc", + ).group(1) + out = {} + for rarity, val in re.findall(r"case\s+InsectRarity\.(\w+):\s*return\s+([\d.]+)f", body): + out[rarity] = float(val) + # default(Common)는 case 없이 return일 수 있다 + if "Common" not in out: + m = re.search(r"(?:default:|^\s*)\s*return\s+([\d.]+)f\s*;", body, re.MULTILINE) + if m: + out["Common"] = float(m.group(1)) + missing = [r for r in RARITIES if r not in out] + if missing: + raise ExtractorBroken( + f"GetRarityMultiplier에서 {missing} 배율을 못 읽었다 — 분기 구조가 바뀌었는가?" + ) + return out + + +def trainer_xp_curve() -> dict: + """트레이너 레벨 EXP 곡선. 출처: PlayerProgressController. + + 선형: max(floor, base + (level-1)*growth). 배틀/포획/레이드/튜토리얼 EXP가 여기로 간다. + 곤충 레벨(캔디, 지수)과는 별개 시스템이다 — progression_sim이 이 둘을 혼동했다. + """ + src = _read("trainer_progress") + base = int(_need(re.search(r"baseXpToLevel\s*=\s*(\d+)", src), "baseXpToLevel", "trainer_progress").group(1)) + growth = int(_need(re.search(r"xpGrowthPerLevel\s*=\s*(\d+)", src), "xpGrowthPerLevel", "trainer_progress").group(1)) + maxlv = int(_need(re.search(r"maxLevel\s*=\s*(\d+)", src), "maxLevel", "trainer_progress").group(1)) + # GetXpToNextLevel = Mathf.Max(floor, base + (level-1)*growth) + floor_m = re.search(r"Mathf\.Max\((\d+),\s*baseXpToLevel", src) + floor = int(floor_m.group(1)) if floor_m else 1 + return {"base": base, "growth": growth, "floor": floor, "max": maxlv, "kind": "linear"} + + +def insect_candy_curve() -> dict: + """곤충 레벨업 캔디 비용 곡선. 출처: InsectLevelCurve.GetCandyCost. + + 지수: baseCandyCost * growth^(level-1). 곤충은 이 캔디 경로로만 큰다 + (TryLevelUpWithCandy). 곤충 XP 곡선(GetXpToNextLevel, 20*1.12^)은 배선만 돼 있고 + 게임플레이가 곤충에 XP를 주지 않아 미사용이다 — 진행 경로로 쓰면 안 된다. + """ + src = _read("insect_curve") + base = int(_need(re.search(r"baseCandyCost\s*=\s*(\d+)", src), "baseCandyCost", "insect_curve").group(1)) + # GetCandyCost 본체의 Mathf.Pow(1.14f, level - 1) + body = _need(re.search(r"GetCandyCost\s*\([^)]*\)\s*\{(.*?)\n\s{8}\}", src, re.DOTALL), + "GetCandyCost() 본체", "insect_curve").group(1) + growth = float(_need(re.search(r"Pow\(\s*([\d.]+)f", body), "GetCandyCost의 성장률", "insect_curve").group(1)) + maxlv = int(_need(re.search(r"maxLevel\s*=\s*(\d+)", src), "maxLevel", "insect_curve").group(1)) + return {"base": base, "growth": growth, "max": maxlv, "kind": "exponential"} + + +def battle_rewards_by_rarity() -> dict: + """등급별 전투/포획 보상 base(등급배율 적용 전). 출처: PlaySceneBootstrap switch(rarity). + + 실제 지급 = base * rarity_multiplier. 예: Legendary 캔디 = 6 * 2.8 = 16.8. + 반환: {"Common": {"exp": 5, "candy": 2}, ...} + """ + src = _read("bootstrap") + block = _need( + re.search(r"switch\s*\(\s*rarity\s*\)\s*\{(.*?)itemRewardCount", src, re.DOTALL), + "switch(rarity) 보상 블록", "bootstrap", + ).group(1) + out = {} + # 각 case (default=Legendary)의 expReward/candyReward를 순서대로 잡는다. + cases = re.findall( + r"(?:case\s+InsectRarity\.(\w+)|default)\s*:.*?expReward\s*=\s*(\d+).*?candyReward\s*=\s*(\d+)", + block, re.DOTALL, + ) + order = ["Common", "Uncommon", "Rare", "Epic", "Legendary"] + for i, (name, exp, candy) in enumerate(cases): + key = name if name else (order[i] if i < len(order) else f"tier{i}") + out[key] = {"exp": int(exp), "candy": int(candy)} + missing = [r for r in RARITIES if r not in out] + if missing: + raise ExtractorBroken( + f"switch(rarity)에서 {missing} 보상을 못 읽었다 ({len(cases)}개 case 추출) — 구조가 바뀌었는가?" + ) + return out + + +def field_roster() -> dict: + """{insectId: (rarity, spawnWeight)} — 필드 스폰되는 곤충 전체(weight>0). + + 출처: PlaySceneBootstrap.CreateStableInsect(id, name, InsectRarity.X, weight, ...) + + InsectExpansion(2)Definitions.new InsectSeed(id, name, InsectRarity.X, weight, ...). + 가챠 전용(weight=0)은 필드 스폰이 없으므로 제외한다. InsectSpawner.GetWeightedRandom이 + 이 spawnWeight로 후보를 뽑으므로, 리전 내 실제 조우 등급 분포의 단일 출처다. + """ + out = {} + for key, pat in ( + ("bootstrap", r'CreateStableInsect\("([^"]+)",\s*"[^"]+",\s*InsectRarity\.(\w+),\s*([\d.]+)f'), + ("insect_expansion", r'new InsectSeed\("([^"]+)",\s*"[^"]+",\s*InsectRarity\.(\w+),\s*([\d.]+)f'), + ("insect_expansion2", r'new InsectSeed\("([^"]+)",\s*"[^"]+",\s*InsectRarity\.(\w+),\s*([\d.]+)f'), + ): + for _id, rarity, w in re.findall(pat, _read(key)): + weight = float(w) + if weight > 0: + out[_id] = (rarity, weight) + if not out: + raise ExtractorBroken( + "필드 곤충 로스터를 하나도 못 읽었다 (CreateStableInsect/InsectSeed) — 시그니처가 바뀌었는가?" + ) + return out + + +def all_insect_ids() -> set: + """게임에 존재하는 모든 곤충 ID (필드 + 가챠 전용). 퀘스트 보상 곤충 검증용. + + field_roster는 weight>0만 주므로 별개다 — 보상은 가챠 전용(weight 0) 곤충일 수도 있다. + """ + ids = set() + for key, pat in ( + ("bootstrap", r'CreateStableInsect\("([^"]+)"'), + ("insect_expansion", r'new InsectSeed\("([^"]+)"'), + ("insect_expansion2", r'new InsectSeed\("([^"]+)"'), + ): + ids |= set(re.findall(pat, _read(key))) + block = re.search(r"gachaExclusives\s*=.*?\{(.*?)\n\s{8}\};", _read("gacha"), re.DOTALL) + if block: + ids |= set(re.findall(r'"(gacha_\w+)"', block.group(1))) + if not ids: + raise ExtractorBroken("곤충 ID를 하나도 못 읽었다 (CreateStableInsect/InsectSeed)") + return ids + + +def item_ids() -> set: + """게임에 존재하는 모든 아이템 ID. 보상 아이템(rewardItemId) 검증용 레지스트리. + + ItemDatabase.CreateRuntimeDefault()의 CreateXxx("id", ...) 팩토리들이 런타임 아이템 + 레지스트리다 (PlayerItemInventory.AddItem이 이 ID로 해석). 채집망은 capture item, exp_boost처럼 + 상점 보상으로만 지급되는 아이템·치료 아이템(CreateTreatment)도 여기 등록돼 있으나 capture item + 목록엔 없다 — 그래서 lint는 이 집합을 capture item·shop reward와 합집합해 어느 소스든 인정한다. + + **팩토리 이름을 열거하지 않는다.** 예전엔 `Create(?:Item|Treatment)`로 두 개를 박아 뒀는데, + 기술 디스크가 `CreateSkillDisc`로 들어오자 그 8종이 통째로 "존재하지 않는 아이템"이 됐다 + (캐시샵 보상 3건이 WARN으로 떴다). 팩토리는 앞으로도 늘어날 수 있으므로 `Create*` 전부를 + 본다 — 이 메서드들은 전부 `ItemData`를 만들어 같은 리스트에 넣는 같은 성질의 것이다. + """ + ids = set(re.findall(r'Create[A-Za-z]*\(\s*"(\w+)"', _read("item_db"))) + if not ids: + raise ExtractorBroken('아이템 ID를 하나도 못 읽었다 (ItemDatabase의 Create* 팩토리) — 구조가 바뀌었는가?') + return ids + + +# ── 퀘스트 ────────────────────────────────────────────────────────────────── + +def quest_types_enum() -> list: + """QuestType enum 목록. 출처: TutorialQuestData.cs.""" + src = _read("tutorial_data") + m = _need(re.search(r"enum\s+QuestType\s*\{([^}]*)\}", src), "QuestType enum", "tutorial_data") + return [ + t.strip() for t in m.group(1).split(",") + if t.strip() and not t.strip().startswith("//") + ] + + +def quest_defs() -> list: + """[{questId, type, prereq, reward_insect, reward_item, reward_item_count, target}, ...] + + 출처: TutorialQuestManager의 allQuests 배열. 각 `new TutorialQuest { ... }` 블록을 파싱한다. + 블록 안에 중첩 중괄호가 없어(필드는 전부 리터럴) [^}]* 로 안전하게 자른다. + """ + src = _read("tutorial") + arr = _need( + re.search(r"allQuests\s*=\s*new\s+TutorialQuest\[\]\s*\{(.*?)\n\s*\};", src, re.DOTALL), + "allQuests 배열", "tutorial", + ) + out = [] + for block in re.findall(r"new\s+TutorialQuest\s*\{([^}]*)\}", arr.group(1), re.DOTALL): + def s(name): + m = re.search(rf'{name}\s*=\s*"([^"]*)"', block) + return m.group(1) if m else None + + def i(name): + m = re.search(rf"{name}\s*=\s*(\d+)", block) + return int(m.group(1)) if m else None + + t = re.search(r"type\s*=\s*QuestType\.(\w+)", block) + cat = re.search(r"category\s*=\s*QuestCategory\.(\w+)", block) + rep = re.search(r"repeatable\s*=\s*(true|false)", block) + out.append({ + "questId": s("questId"), + "type": t.group(1) if t else None, + "prereq": s("prerequisiteQuestId"), + "reward_insect": s("rewardInsectId"), + "reward_item": s("rewardItemId"), + "reward_item_count": i("rewardItemCount"), + "target": i("targetCount"), + # 분류/반복 상승(필드 생략 시 C# 기본값: category=Story, repeatable=false, increment=0). + "category": cat.group(1) if cat else "Story", + "repeatable": (rep.group(1) == "true") if rep else False, + "target_increment": i("targetIncrement") or 0, + }) + if not out: + raise ExtractorBroken("allQuests 배열에서 퀘스트를 하나도 못 읽었다 — 구조가 바뀌었는가?") + return out + + +def quest_progress_wiring() -> dict: + """{QuestType: [(via, wired, detail), ...]} — 각 QuestType이 IncrementProgress에 닿는 경로들. + + 세 경로가 있다: + - 'update' : Update() 본문이 직접 처리 (Movement). 항상 wired. + - 'event' : OnXxx() 핸들러가 NotifyAction(QuestType.Y). SubscribeEvents에 그 핸들러가 + += 로 등록됐는지가 wired. **q_team 회귀(핸들러는 있으나 미등록)를 잡는 지점.** + - 'notify' : 외부 Notify 메서드가 처리. wired=None — 게임플레이 호출부 존재는 quest_lint가 + 코드베이스 grep으로 확인한다(game_facts는 단일 파일만 읽는다). + + 한 QuestType이 여러 경로를 가질 수 있다(예: Battle = 이벤트 + NotifyBattleWon). quest_lint는 + 경로 중 하나라도 reachable이면 통과로 본다. + + 주석을 제거하고 분석한다 — `// TeamChanged += OnTeamChanged`처럼 주석 처리된 구독을 + 등록으로 오인하면 q_team류 회귀(구독 누락)를 놓친다. + """ + src = strip_cs(_read("tutorial")) + + upd = re.search(r"void\s+Update\s*\(\)\s*\{(.*?)\n\s{8}\}", src, re.DOTALL) + update_types = set(re.findall(r"QuestType\.(\w+)", upd.group(1))) if upd else set() + + # 이벤트 핸들러 On___ 이 NotifyAction(QuestType.X)를 부름 → {qtype: handler}. + # 핸들러가 `=> NotifyAction(...)` 한 줄이거나 `{ if (...) { NotifyAction(...) } }` 중첩 + # 블록일 수 있어, 블록은 중괄호 깊이로 본문을 통째로 떠서 그 안의 NotifyAction을 찾는다. + # (옛 정규식 [^;{}]* 는 중첩 { 에서 멈춰 VisitRegion/VisitSubArea를 놓쳤다.) + handler_of = {} # qtype -> handler name (On___) + for m in re.finditer(r"\b(On\w+)\s*\([^)]*\)\s*(?:=>\s*([^;]*);|\{)", src): + handler = m.group(1) + if m.group(2) is not None: + body = m.group(2) + else: + depth, i = 1, m.end() + while i < len(src) and depth: + if src[i] == "{": + depth += 1 + elif src[i] == "}": + depth -= 1 + i += 1 + body = src[m.end():i] + for qt in re.findall(r"NotifyAction\(QuestType\.(\w+)\)", body): + handler_of[qt] = handler + + sub = re.search(r"SubscribeEvents\s*\(\)\s*\{(.*?)\n\s{8}\}", src, re.DOTALL) + subscribed = set(re.findall(r"\+=\s*(On\w+)", sub.group(1))) if sub else set() + + notify_of = {} # qtype -> notify method name + for m in re.finditer( + r"public\s+void\s+(Notify\w+)\s*\([^)]*\)\s*\{(.*?)\n\s{8}\}", src, re.DOTALL + ): + name, body = m.group(1), m.group(2) + for qt in re.findall(r"QuestType\.(\w+)", body): + notify_of.setdefault(qt, name) + + out = {} + for qt in update_types | set(handler_of) | set(notify_of): + paths = [] + if qt in update_types: + paths.append(("update", True, "Update() 내부")) + if qt in handler_of: + h = handler_of[qt] + paths.append(("event", h in subscribed, h)) + if qt in notify_of: + paths.append(("notify", None, notify_of[qt])) + out[qt] = paths + return out + + +# ── 스토리 ────────────────────────────────────────────────────────────────── + +def story_beats() -> list: + """Story.json의 비트 목록 (파싱된 dict). json.load라 퀘스트 정규식보다 견고하다. + + 설계(Docs/StorySystemDesign.md)가 데이터 모델을 JSON으로 정한 이유가 이것이다 — + lines[]/choices[] 중첩 구조를 정규식으로 자르는 대신 네이티브 파싱한다. + """ + import json + path = PATHS["story_json"] + if not os.path.isfile(path): + raise ExtractorBroken(f"{path}가 없다 — 스토리 시스템이 아직 없거나 파일이 옮겨갔는가?") + try: + with open(path, encoding="utf-8") as f: + data = json.load(f) + except (OSError, ValueError) as e: + raise ExtractorBroken(f"{path} 파싱 실패: {e}") + beats = data.get("beats") + if beats is None: + raise ExtractorBroken(f"{path}에 'beats' 키가 없다 — StoryList 형식이 바뀌었는가?") + return beats + + +def story_trigger_wiring() -> dict: + """{triggerType: (in_switch, has_event_source)} — StoryDirector가 각 트리거를 처리하는가. + + 검사 근거: JSON이 쓰는 trigger.type이 (a) EvaluateTriggers switch의 case에 있고 + (b) 그 트리거를 발화하는 이벤트 소스가 구독돼 있어야 비트가 발화한다. q_team 회귀의 + 스토리 등가물 — 새 trigger.type을 JSON에 넣고 switch/구독을 빠뜨리면 비트 영구 미발화. + + - in_switch: `case TriggerX:` 또는 상수 정의가 switch 문맥에 존재 + - has_event_source: 그 트리거를 EvaluateTriggers/RouteTrigger(TriggerX, ...)로 호출하는 + 지점이 있음 (Immediate는 Start에서, 나머지는 OnXxx 이벤트 핸들러에서) + + **RouteTrigger도 발화 지점이다.** 대화 중 끼어들기·전투 결과 화면 덮기를 막으려고 + 모든 즉시 발화가 이 관문을 지나게 바뀌었다 — 지금 띄워도 되면 EvaluateTriggers로, + 아니면 DeferTrigger로 간다. 즉 **지연 발화와 같은 성질**이라 아래 배출부 확인을 + 똑같이 요구한다(큐에 넣기만 하고 아무도 안 빼면 그 비트는 영영 안 뜬다). + + **지연 발화도 배선으로 친다.** BattleWin/GuardianDefeat는 전투 결과 화면과 겹치지 않게 + `DeferTrigger(TriggerX, ...)`로 큐에 넣었다가 화면이 닫힐 때 흘린다 — 이벤트 핸들러가 + `EvaluateTriggers`를 직접 부르지 않는다. 그렇다고 그냥 인정해 주면 **큐에 넣기만 하고 + 아무도 안 빼는** 코드가 통과한다(그 비트는 영영 안 뜬다). 그래서 배출부의 존재를 함께 + 확인한다 — `EvaluateTriggers(`를 상수가 아닌 인자로 부르는 지점(= 큐를 흘리는 루프)이 + 하나라도 있어야 지연분을 배선으로 센다. + """ + raw = _read("story_director") + # 상수 정의는 원본에서 읽는다 — strip_cs가 "Y" 리터럴을 지우면 값을 못 읽는다. + consts = dict(re.findall(r'const\s+string\s+(Trigger\w+)\s*=\s*"(\w+)"', raw)) + if not consts: + raise ExtractorBroken("StoryDirector에서 Trigger 상수를 못 읽었다 — 정의 형태가 바뀌었는가?") + # switch case / 발화 지점은 주석 제거 후에 본다 — 주석 처리된 case를 살아있다고 오인 않게. + src = strip_cs(raw) + in_switch = set(re.findall(r"case\s+(Trigger\w+)\s*:", src)) + fired = set(re.findall(r"EvaluateTriggers\(\s*(Trigger\w+)", src)) + + # 배출부: EvaluateTriggers를 Trigger 상수가 **아닌** 인자로 부르는 지점(큐를 흘리는 루프). + # **선언부를 먼저 지운다** — `private bool EvaluateTriggers(string triggerType, ...)`의 + # `string`이 그대로 매칭돼, 배출부를 통째로 지워도 PASS가 나왔다(검출력 실측에서 잡혔다). + calls_only = re.sub( + r"(?:private|public|internal|protected)[^\n]*EvaluateTriggers\s*\([^)]*\)", "", src) + drains = bool(re.search(r"EvaluateTriggers\(\s*(?!Trigger\w)[A-Za-z_]", calls_only)) + if drains: + # DeferTrigger(큐 직행)와 RouteTrigger(관문 — 조건에 따라 즉시 또는 큐) 둘 다 + # 배출부가 살아 있을 때만 배선으로 센다. + fired |= set(re.findall(r"DeferTrigger\(\s*(Trigger\w+)", src)) + fired |= set(re.findall(r"RouteTrigger\(\s*(Trigger\w+)", src)) + + out = {} + for const, ttype in consts.items(): + out[ttype] = (const in in_switch, const in fired) + return out + + +def story_npc_ids() -> set: + """월드에 배치된 스토리 NPC ID 집합 — NpcTalk 트리거 param 검증용. + + 출처: VillageBuilder의 storyNpcId = "..." 앵커. 여기에 없는 NPC를 NpcTalk param으로 + 쓰면 그 NPC가 월드에 없어 비트가 영영 발화하지 않는다(조용한 미발화). story_lint가 잡는다. + + **주석을 먼저 지운다.** VillageBuilder에는 이 추출기의 요구사항을 적어 둔 주석 + (`storyNpcId = "리터럴"`)이 있는데, 파이썬 `\\w`가 한글을 먹어서 그 예시가 실제 NPC로 + 집계됐다 — 추출기가 자기 사용설명서를 데이터로 읽은 셈이다. 집합이 커지면 오타 param이 + 통과할 수 있고, "모든 스토리 NPC에 앰비언트 대사가 있는가"(검사 20)가 거짓 FAIL을 낸다. + """ + # strip_cs는 못 쓴다 — 그쪽은 **문자열 리터럴까지** 지워서 읽어야 할 값이 통째로 사라진다 + # (story_trigger_wiring이 상수 정의를 원본에서 읽는 것과 같은 이유). 줄 주석만 걷는다. + ids = set() + for line in _read("village_builder").splitlines(): + ids.update(re.findall(r'storyNpcId\s*=\s*"(\w+)"', line.split("//", 1)[0])) + if not ids: + raise ExtractorBroken('VillageBuilder에서 storyNpcId를 하나도 못 읽었다 — 배치 형태가 바뀌었는가?') + return ids + + +def story_npc_ambient_ids() -> set: + """전용 앰비언트 대사가 있는 스토리 NPC 집합(NpcDialogueDatabase.StoryNpcLines 키). + + 없는 인물은 비트가 없을 때 **마을 주민 풀로 떨어진다** — 명부회 간부가 날씨 이야기를 한다. + 예외도 경고도 없이 그럴듯한 대사가 나오므로 눈으로만 잡을 수 있었다(2026-08-23에 실제로 그랬다). + """ + # strip_cs는 문자열 리터럴까지 지운다 — 여기서 읽어야 할 것이 바로 그 리터럴이라 + # 줄 주석만 걷는다(story_npc_ids와 같은 이유). + src = "\n".join(l.split("//", 1)[0] for l in _read("npc_dialogue").splitlines()) + idx = src.find("StoryNpcLines") + if idx < 0: + raise ExtractorBroken("NpcDialogueDatabase에서 StoryNpcLines를 못 찾았다 — 이름이 바뀌었는가?") + ids = set(re.findall(r'\{\s*"(\w+)",\s*new\[\]', src[idx:])) + if not ids: + raise ExtractorBroken("StoryNpcLines에서 키를 하나도 못 읽었다 — 초기화 형태가 바뀌었는가?") + return ids + + +def _switch_case_ids(key: str, func: str) -> set: + """`func` 메서드 본문의 `case "id":` 집합. + + 본문만 보는 이유: 같은 파일 다른 switch의 case까지 세면 "등록됐다"가 거짓으로 참이 된다. + 메서드 끝은 다음 `private`/`public` 선언으로 자른다(이 저장소의 두 switch 모두 그 형태다). + """ + src = "\n".join(l.split("//", 1)[0] for l in _read(key).splitlines()) + # **선언**을 찾는다 — 이름만으로 찾으면 같은 파일 위쪽의 **호출부**가 먼저 걸려 + # 본문이 아니라 그 뒤 빈 구간을 읽고 "case 0건"으로 죽는다(실제로 그랬다). + m = re.search(r"static\s+[\w.\[\]]+\s+" + re.escape(func) + r"\s*\(", src) + if m is None: + raise ExtractorBroken(f"{key}에서 {func} 선언을 못 찾았다 — 시그니처가 바뀌었는가?") + rest = src[m.end():] + end = min((i for i in (rest.find("\n private "), rest.find("\n public ")) + if i >= 0), default=len(rest)) + ids = set(re.findall(r'case\s+"(\w+)"\s*:', rest[:end])) + if not ids: + raise ExtractorBroken(f"{func}에서 case를 하나도 못 읽었다 — switch 형태가 바뀌었는가?") + return ids + + +def story_npc_display_ids() -> set: + """표시명 switch에 등록된 스토리 NPC(NpcManager.StoryNpcDisplayName). + + **빠뜨리면 default로 떨어져 그 인물이 "마을 어르신"으로 뜬다.** 조용하다. + """ + return _switch_case_ids("npc_manager", "StoryNpcDisplayName") + + +def story_npc_appearance_ids() -> set: + """외형 switch에 등록된 스토리 NPC(NpcVisualBuilder.StoryNpcAppearance). + + **빠뜨리면 default로 떨어져 마을 어르신 외형(백발·모자·따뜻한 상의)으로 뜬다.** + 명부회 하수가 마을 어르신 얼굴로 서 있어도 예외도 경고도 안 난다. + """ + return _switch_case_ids("npc_visual", "StoryNpcAppearance") + + +def stage_offsets() -> dict: + """저작 연출별 배치 좌표 — {stageId: [(action, x, y, z), ...]}. + + 출처: StoryStageLibrary. `case Ch7ScholarFollow: steps = BuildCh7ScholarFollow()`로 + 상수 → 빌더를 잇고, 빌더 본문의 `Warp/MoveTo(..., new Vector3(x, y, z))`를 읽는다. + 좌표가 방 밖인지 판정하는 검사 21이 쓴다. + + strip_cs는 못 쓴다 — NPC id가 문자열 리터럴이고 그게 지워지면 파싱이 어긋난다. + """ + src = "\n".join(l.split("//", 1)[0] for l in _read("stage_library").splitlines()) + + consts = dict(re.findall(r'const\s+string\s+(\w+)\s*=\s*"([^"]+)"', src)) + if not consts: + raise ExtractorBroken("StoryStageLibrary에서 스테이지 상수를 하나도 못 읽었다") + + dispatch = dict(re.findall(r"case\s+(\w+)\s*:\s*steps\s*=\s*(\w+)\s*\(", src)) + if not dispatch: + raise ExtractorBroken("StoryStageLibrary의 TryGet switch를 못 읽었다 — 형태가 바뀌었는가?") + + bodies = dict(re.findall( + r"private\s+static\s+StoryStageStep\[\]\s+(\w+)\s*\(\s*\)\s*\{(.*?)\n \}", + src, re.DOTALL)) + if not bodies: + raise ExtractorBroken("StoryStageLibrary의 빌더 본문을 못 읽었다 — 형태가 바뀌었는가?") + + out = {} + for const_name, builder in dispatch.items(): + stage_id = consts.get(const_name) + if stage_id is None or builder not in bodies: + continue + steps = [] + for action, x, y, z in re.findall( + r"StoryStageStep\.(Warp|MoveTo)\([^,]+,\s*new Vector3\(" + r"\s*(-?[\d.]+)f?\s*,\s*(-?[\d.]+)f?\s*,\s*(-?[\d.]+)f?\s*\)", + bodies[builder]): + steps.append((action, float(x), float(y), float(z))) + out[stage_id] = steps + return out + + +# 방을 봉하는 호출과 그 halfSize 인자 위치(0-based). +# CreateBoundaryWalls(mat, halfSize, height) +# CreateGlassWall(glassMat, frameMat, halfSize, height) +# **둘 다 collider를 남기는 축정렬 정사각 벽이다** — 유리라고 통과되지 않는다 +# (SubAreaWorldBuilder의 "유리지만 collider 보존" 주석). 그래서 방 크기 판정에서 +# 둘을 같게 다뤄야 한다. 예전엔 CreateBoundaryWalls만 읽어서 **온실(garden_greenhouse)이 +# subarea_room_bounds()에서 통째로 빠졌고**, story_lint 검사 21이 `half is None`을 +# 건너뛰는 탓에 그 방의 연출 워프 좌표는 **아무도 검사하지 않았다**(무증상 구멍). +_ROOM_WALL_CALLS = (("CreateBoundaryWalls(", 1), ("CreateGlassWall(", 2)) + + +def _boundary_half_size(body: str): + """이 빌더가 세우는 방의 halfSize. 못 찾으면 None. + + 인자에 `Mat(new Color(0.4f, 0.4f, 0.4f))`처럼 콤마가 들어 있어 단순 정규식으로는 + 괄호 안의 색 성분을 크기로 읽는다(실제로 0.2를 방 크기로 보고했다). 괄호를 세어 자른다. + """ + for call, size_index in _ROOM_WALL_CALLS: + half = _wall_call_arg(body, call, size_index) + if half is not None: + return half + return None + + +def _wall_call_arg(body: str, call: str, size_index: int): + idx = body.find(call) + if idx < 0: + return None + i = idx + len(call) + depth, arg, args = 0, [], [] + while i < len(body): + ch = body[i] + if ch == "(": + depth += 1 + elif ch == ")": + if depth == 0: + args.append("".join(arg)) + break + depth -= 1 + elif ch == "," and depth == 0: + args.append("".join(arg)) + arg = [] + i += 1 + continue + arg.append(ch) + i += 1 + if len(args) <= size_index: + return None + m = re.fullmatch(r"\s*(-?[\d.]+)f?\s*", args[size_index]) + return float(m.group(1)) if m else None + + +def subarea_room_bounds() -> dict: + """서브에리어별 방 반쪽 크기 — {subAreaId: halfSize}. + + 두 파일을 잇는다. RegionDefinitions의 `subAreaId`/`environmentType`, 그리고 + SubAreaWorldBuilder의 `switch (subArea.environmentType)` → 빌더 → 그 빌더가 부르는 + `CreateBoundaryWalls(mat, halfSize, height)`. + + 방은 축정렬 정사각이라 halfSize 하나로 네 벽이 정해진다(CreateBoundaryWalls 참조). + """ + defs = _read("region_defs") + envs = {} + # `exclusiveInsectIds = new[] { ... }`가 중간에 있어 "다음 }까지"로는 못 자른다 — + # subAreaId 다음에 처음 나오는 environmentType을 그 항목의 것으로 본다. + marks = [(m.group(1), m.end()) for m in re.finditer(r'subAreaId\s*=\s*"(\w+)"', defs)] + for i, (sub_id, start) in enumerate(marks): + stop = marks[i + 1][1] if i + 1 < len(marks) else len(defs) + m = re.search(r'environmentType\s*=\s*"(\w+)"', defs[start:stop]) + if m: + envs[sub_id] = m.group(1) + if not envs: + raise ExtractorBroken("RegionDefinitions에서 subAreaId↔environmentType을 못 읽었다") + + builder_src = _read("subarea_builder") + # case "vault": / case "cave": case "underground": → Build___(subArea); + env_to_builder = {} + pending = [] + for line in builder_src.splitlines(): + m = re.search(r'case\s+"(\w+)"\s*:', line) + if m: + pending.append(m.group(1)) + continue + m = re.search(r"(Build\w+)\(subArea\)\s*;", line) + if m and pending: + for env in pending: + env_to_builder[env] = m.group(1) + pending = [] + if not env_to_builder: + raise ExtractorBroken("SubAreaWorldBuilder의 environmentType switch를 못 읽었다") + + walls = {} + for name, body in re.findall( + r"private\s+void\s+(Build\w+)\s*\(SubAreaData[^)]*\)\s*\{(.*?)\n \}", + builder_src, re.DOTALL): + half = _boundary_half_size(body) + if half is not None: + walls[name] = half + if not walls: + raise ExtractorBroken("SubAreaWorldBuilder에서 CreateBoundaryWalls 크기를 못 읽었다") + + out = {} + for sub_id, env in envs.items(): + builder = env_to_builder.get(env) + if builder and builder in walls: + out[sub_id] = walls[builder] + return out + + +def subarea_entry_offset() -> tuple: + """서브에리어 진입 시 플레이어가 서는 방 로컬 좌표 (x, z). + + `FindSafeSpawnPosition`의 1순위 좌표다. 막혀 있으면 주변을 탐색하지만, 연출 좌표를 + 저작할 때 기준으로 삼는 것은 이 값이다 — 워프 오프셋이 방 안인지 여기서부터 잰다. + """ + src = _read("subarea_builder") + m = _need( + re.search(r"preferred\s*=\s*origin\s*\+\s*new Vector3\(" + r"\s*(-?[\d.]+)f\s*,\s*-?[\d.]+f\s*,\s*(-?[\d.]+)f\s*\)", src), + "FindSafeSpawnPosition의 1순위 좌표", "subarea_builder", + ) + return float(m.group(1)), float(m.group(2)) + +def dialogue_region_keys() -> set: + """NpcDialogueDatabase.RegionLines의 regionId 키 집합. 리전 ID 드리프트 검증용.""" + src = _read("npc_dialogue") + block = _need( + re.search(r"RegionLines\s*=\s*new\s+Dictionary[^{]*\{(.*?)\n\s*\};", src, re.DOTALL), + "RegionLines 딕셔너리", "npc_dialogue", + ) + # collection initializer 형태: ["meadow"] = new[]{...} + keys = set(re.findall(r'\["(\w+)"\]\s*=', block.group(1))) + if not keys: + raise ExtractorBroken("RegionLines에서 regionId 키를 못 읽었다 — 초기화 형태가 바뀌었는가?") + return keys + + +def region_pools() -> list: + """[(regionId, requiredLevel, [insectIds]), ...] — 리전별 메인필드 곤충 풀. + + 출처: RegionDefinitions.CreateAll()의 각 RegionData{ regionId, requiredLevel, insectIds }. + 서브에리어(exclusiveInsectIds)는 선택적 고레벨 구역이라 제외 — 메인 진행 경로만 본다. + """ + src = _read("region_defs") + pools = [] + for m in re.finditer( + r'regionId = "(\w+)".*?requiredLevel = (\d+),\s*insectIds = new\[\]\s*\{(.*?)\}', + src, re.DOTALL, + ): + ids = re.findall(r'"([^"]+)"', m.group(3)) + if ids: + pools.append((m.group(1), int(m.group(2)), ids)) + if not pools: + raise ExtractorBroken( + "RegionDefinitions에서 리전 풀(regionId/requiredLevel/insectIds)을 못 읽었다 — 구조가 바뀌었는가?" + ) + return pools + + +def blight_sites() -> list: + """[(regionId, bossNpcId, siteName, returningInsectId), ...] — 명부회 오염 거점. + + 출처: RegionDefinitions.CreateAll()의 각 RegionData{ blightBossNpcId, ... }. + 거점 정의를 별도 표가 아니라 RegionData 필드로 둔 이유는 그 파일 상단 주석 참조 + (하드코딩 리전 목록이 이 저장소에서 세 번 조용히 어긋났다). + + **0건이어도 예외를 던지지 않는다** — 거점이 하나도 없는 것은 파서 고장이 아니라 + "아직 아무 리전에도 거점을 두지 않았다"는 정상 상태다. 대신 필드 선언 자체가 + 사라졌으면(개명·삭제) 그건 고장이므로 잡는다. + """ + src = _read("region_defs") + data_src = _read("region_data") + if "blightBossNpcId" not in data_src: + raise ExtractorBroken( + "RegionData에 blightBossNpcId 필드가 없다 — 개명했는가? 이 추출기도 함께 고칠 것") + + sites = [] + # regionId부터 다음 regionId 직전까지를 한 리전 블록으로 자른다(필드 순서 비의존). + starts = [m for m in re.finditer(r'regionId = "(\w+)"', src)] + for i, m in enumerate(starts): + end = starts[i + 1].start() if i + 1 < len(starts) else len(src) + block = src[m.start():end] + boss = re.search(r'blightBossNpcId = "([^"]*)"', block) + if not boss or not boss.group(1): + continue + name = re.search(r'blightSiteName = "([^"]*)"', block) + ret = re.search(r'blightReturningInsectId = "([^"]*)"', block) + sites.append((m.group(1), boss.group(1), + name.group(1) if name else "", ret.group(1) if ret else "")) + return sites + + +def team_max_slots() -> int: + """배틀 팀 최대 슬롯 수. 출처: GameConstants.Battle.MaxTeamSlots. + + progression_sim이 이 값을 6으로 하드코딩해 "팀 6마리 캔디 비용 FAIL"을 냈는데, + 실제는 5다(GameConstantsTests가 단언). 6마리는 게임에 존재하지 않는 팀이라 신호가 + 통째로 허수였다. + """ + src = _read("game_constants") + m = _need(re.search(r"MaxTeamSlots\s*=\s*(\d+)", src), "MaxTeamSlots", "game_constants") + return int(m.group(1)) + + +def raid_reward_mult() -> float: + """레이드 보상 배율. 출처: RaidBattleController의 `RewardCandy = ...candyBase * N * ...`. + + 상수로 분리돼 있지 않고 계산식에 박힌 매직넘버라 계산식째로 고정해 읽는다. + 캔디와 EXP가 서로 다른 배율을 쓰면 가정이 깨진 것이므로 죽는다. + """ + src = _read("raid") + candy = _need( + re.search(r"RewardCandy\s*=\s*Mathf\.RoundToInt\(\s*candyBase\s*\*\s*([\d.]+)f?\s*\*", src), + "RewardCandy 계산식의 레이드 배율", "raid", + ) + exp = _need( + re.search(r"RewardExp\s*=\s*Mathf\.RoundToInt\(\s*expBase\s*\*\s*([\d.]+)f?\s*\*", src), + "RewardExp 계산식의 레이드 배율", "raid", + ) + c, e = float(candy.group(1)), float(exp.group(1)) + if c != e: + raise ExtractorBroken( + f"레이드 캔디 배율({c})과 EXP 배율({e})이 다르다 — 단일 배율 가정이 깨졌다" + ) + return c + + +def tutorial_rewards() -> dict: + """{"candy": 336, "exp": 475} — 튜토리얼 퀘스트 보상 총합. + + 출처: TutorialQuestManager.cs의 rewardCandy = N / rewardExp = N 대입 전량. + 지급 코드(quest.rewardCandy 등)는 대입이 아니라 자연히 제외된다. + """ + src = _read("tutorial") + candy = [int(x) for x in re.findall(r"rewardCandy\s*=\s*(\d+)", src)] + exp = [int(x) for x in re.findall(r"rewardExp\s*=\s*(\d+)", src)] + if not candy or not exp: + raise ExtractorBroken( + f"튜토리얼 보상 대입을 못 찾았다 (candy {len(candy)}건 / exp {len(exp)}건) — " + "필드명이 바뀌었는가?" + ) + return {"candy": sum(candy), "exp": sum(exp), "candy_n": len(candy), "exp_n": len(exp)} + + +def field_shiny_pct() -> float: + """필드 샤이니 확률(%). 출처: InsectEntity.cs의 `shiny = Random.value < 0.01f`. + + 느슨한 정규식(`shiny\\w*\\s*[=<]\\s*([\\d.]+)f`)을 먼저 썼다가 `cachedShinyShift = -1f` + 같은 무관한 필드를 물어 조용히 0.0을 반환했다. 대입 형태를 통째로 고정한다 — + 형태가 바뀌면 0을 반환하는 대신 ExtractorBroken으로 죽는 게 낫다. + """ + src = _read("insect_entity") + m = _need( + re.search(r"\bshiny\s*=\s*UnityEngine\.Random\.value\s*<\s*([\d.]+)f", src), + "`shiny = UnityEngine.Random.value < Xf` 형태의 샤이니 확률", + "insect_entity", + ) + return float(m.group(1)) * 100.0 + + +def gacha_has_shiny() -> bool: + """가챠에 샤이니 로직이 있는가. 없으면 필드와의 격차가 위험 신호.""" + return bool(re.search(r"[Ss]hiny", _read("gacha"))) + + +if __name__ == "__main__": + import io + import sys + + sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace") + try: + print("# 코드가 말하는 사실\n") + print(f"가챠 임계값 : {gacha_thresholds()}") + for box, pcts in gacha_rarity_pcts().items(): + print(f" {box:7} 확률(%): " + " / ".join(f"{k[0]}{v:g}" for k, v in pcts.items())) + print(f"박스 젬 가격 : {box_gem_prices()}") + print(f"픽업 확률 : {gacha_exclusive_chances()}") + print(f"보너스 캔디 : {gacha_candy_bonus()}") + print(f"전용 풀 크기 : {gacha_exclusive_pool_sizes()}") + print(f"보상 배율 : {rarity_multipliers()}") + print(f"튜토리얼 보상 합 : {tutorial_rewards()}") + print(f"필드 샤이니(%) : {field_shiny_pct()}") + print(f"가챠 샤이니 존재 : {gacha_has_shiny()}") + except ExtractorBroken as e: + print(f"\n추출기 고장: {e}") + sys.exit(2) diff --git a/.claude/scripts/literal_fit_lint.py b/.claude/scripts/literal_fit_lint.py new file mode 100644 index 0000000..f722cec --- /dev/null +++ b/.claude/scripts/literal_fit_lint.py @@ -0,0 +1,113 @@ +"""리터럴 문구가 폰트보다 작은 상자에 그려지는 자리를 찾는다 — text_fit_lint의 사각지대. + +`text_fit_lint.py`는 **길이를 데이터가 정하는데 상자가 고정**인 자리만 본다(".description" 등). +그래서 `GUI.Label(rect, "고정 문구", style)`처럼 리터럴을 쓰는 자리는 아무도 안 본다 — +36pt를 28px 상자에 그리던 곳이 실제로 있었다(2026-08-08 커밋 a523726이 손으로 둘 찾아 고쳤다). + +판정: 한글 줄높이 ≈ fontSize × 1.35 (`DexScreenUI.LineH` / `TutorialQuestUI.RowH`와 같은 계산). +그보다 낮은 Rect면 위아래가 깎인다. **상자 높이 ≤ fontSize인 자리는 논란의 여지가 없다** — +글립 상자는 언제나 폰트 크기보다 크다. + +**FAIL 기준은 10% 이상 부족이다.** 그보다 작은 차이는 폰트 패딩에 묻혀 실제로 안 잘릴 수 있어 +정보로만 센다 — 전부를 기준으로 삼으면 정상 라벨까지 잡아 `text_fit_lint`가 247건에서 +31건으로 좁혀졌던 것과 같은 실수를 되풀이한다. + +고치는 방법은 `UIHelper.LabelFit`으로 바꾸는 것이다. **상자는 그대로 두고 글자를 줄인다** — +상자를 키우면 아래 요소가 전부 밀려 회귀 범위가 커진다(rules/ui-layout.md). IMGUI는 배치모드로 +캡처할 수 없어 눈으로 확인할 수 없으므로(rules/testing.md), 레이아웃을 안 건드리는 쪽이 맞다. +상자를 키우는 건 아래가 밀려도 되는 자리(스크롤 목록 등)에서, 이웃 y를 검산한 뒤에만. + + python -X utf8 .claude/scripts/literal_fit_lint.py + +한계: 스타일 변수의 fontSize가 리터럴로 정해진 것만 본다(동적 대입은 제외). +Rect 높이도 숫자 리터럴인 것만 — 변수·수식은 판정하지 않으므로 실제 모수는 이보다 크다. +""" +import io, re, glob, os, collections + +def sweep(path): + src = io.open(path, encoding='utf-8').read() + sizes = {} + # `new GUIStyle(...) { ... }` 와 `new GUIStyle { ... }` 두 형태를 모두 본다 — + # 괄호 없는 초기화를 빠뜨려 검출력 실측에서 주입한 결함을 놓쳤다(2026-08-17). + for m in re.finditer(r'(\w+)\s*=\s*new GUIStyle\s*(?:\([^)]*\))?\s*\{([^}]*)\}', src, re.S): + fm = re.search(r'fontSize\s*=\s*(\d+)', m.group(2)) + if fm: + sizes[m.group(1)] = int(fm.group(1)) + for m in re.finditer(r'(\w+)\.fontSize\s*=\s*(\d+)\s*;', src): + sizes[m.group(1)] = int(m.group(2)) + dynamic = set(re.findall(r'(\w+)\.fontSize\s*=\s*(?!\d)', src)) + out = [] + for m in re.finditer(r'GUI\.Label\(\s*new Rect\(', src): + i = m.end() + d = 1 + while i < len(src) and d: + if src[i] == '(': + d += 1 + elif src[i] == ')': + d -= 1 + i += 1 + args = src[m.end():i - 1] + dd, cur, parts = 0, '', [] + for ch in args: + if ch == '(': + dd += 1 + if ch == ')': + dd -= 1 + if ch == ',' and dd == 0: + parts.append(cur) + cur = '' + else: + cur += ch + parts.append(cur) + if len(parts) != 4: + continue + try: + h = float(parts[3].strip().rstrip('f')) + except ValueError: + continue + j, d2, rest = i, 1, '' + while j < len(src) and d2: + if src[j] == '(': + d2 += 1 + elif src[j] == ')': + d2 -= 1 + if d2: + rest += src[j] + j += 1 + tail = [p.strip() for p in re.split(r',(?![^()]*\))', rest) if p.strip()] + if len(tail) < 2: + continue + text, style = tail[-2], tail[-1] + if not (text.startswith('"') or text.startswith('$"')): + continue + if style in dynamic or style not in sizes: + continue + need = sizes[style] * 1.35 + if need > h + 0.5: + rel = os.path.relpath(path).replace(os.sep, '/') + out.append((rel, src[:m.start()].count('\n') + 1, sizes[style], h, + round(need, 1), round((need - h) / h * 100))) + return out + +allhits = [] +for f in glob.glob('Assets/Scripts/**/*.cs', recursive=True): + allhits += sweep(f) +allhits.sort(key=lambda r: -r[5]) +sev = [h for h in allhits if h[5] >= 10] +print('# literal-fit-lint — 리터럴 문구 잘림 검사') +print() +print('| 항목 | 임계값 | 측정값 | 판정 |') +print('|------|--------|--------|------|') +print('| 리터럴 라벨 상자 부족(10%% 이상) | 0건 | %d건 | **%s** |' + % (len(sev), 'FAIL' if sev else 'PASS')) +print('| 경미(10%% 미만, 정보) | — | %d건 | — |' % (len(allhits) - len(sev))) +print() +print() +for f, line, fs, h, need, pct in allhits[:20]: + print('%s %s:%d %dpt 상자%g 필요%g (%d%% 부족)' + % ('**' if pct >= 10 else ' ', f, line, fs, h, need, pct)) +print() +print('파일별:', dict(collections.Counter(h[0].split('/')[-1] for h in allhits))) + +import sys +sys.exit(1 if sev else 0) diff --git a/.claude/scripts/progression_sim.py b/.claude/scripts/progression_sim.py new file mode 100644 index 0000000..818746b --- /dev/null +++ b/.claude/scripts/progression_sim.py @@ -0,0 +1,318 @@ +"""진행 곡선 시뮬 — 곤충게임의 **이원 레벨 구조**를 모델링한다. + +이 게임엔 분리된 두 레벨 시스템이 있다(코드로 확인): + - 트레이너 레벨(PlayerProgressController): **선형** max(floor, base+(lv-1)*growth). + 배틀/포획/레이드/튜토리얼 EXP가 전부 여기로 간다(GainXp → 트레이너). + - 곤충 레벨(InsectLevelCurve): **캔디**로만 큰다(TryLevelUpWithCandy). 지수 base*1.14^(lv-1). + +옛 progression_sim은 이 둘을 혼동했다. 곤충 XP 곡선(20*1.12^)을 진행 경로로 썼는데, +곤충 XP(GainXp/currentXp)는 코드·UI에 배선만 돼 있고 **어떤 게임플레이도 곤충에 XP를 +주지 않는다**(dead 배선). 그 결과 5개 신호가 전부 오탐이었다: + · 캔디 수입을 배틀 전용·고정 3으로 모델(실제 등급별 2~16.8, 포획·레이드·가챠·튜토리얼) + · team_size 6(실제 MaxTeamSlots 5) + · 곤충 지수 XP곡선을 진행 경로로 오인(실제 미사용) + · 훈련=EXP 소스 오전제(실제 스킬 학습) + · 튜토리얼 비율 분모=단일 곤충 평생 캔디 + +이 재설계는 트레이너/곤충을 분리하고, 전투당 보상을 등급별 실제값으로 읽는다. +수치 사본은 두지 않는다 — 전부 game_facts가 코드에서 읽는다. +""" +import argparse +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import game_facts # noqa: E402 + +if hasattr(sys.stdout, "reconfigure") and sys.stdout.encoding and sys.stdout.encoding.lower() != "utf-8": + sys.stdout.reconfigure(encoding="utf-8") # type: ignore[attr-defined] + +# === 임계값 (밸런스 휴리스틱 — 디자이너 조정 가능) === +# 근거: 평균 전투 30초 가정 시 4000전투 = 33시간. 곤충 1마리 육성 그라인딩 한계. +# 이 임계값은 **현실 진행**(insect_candy_battles_realistic)에 적용한다 — 곤충 레벨이 +# 리전 진행과 동기화되면 비싼 후반 레벨(전체 캔디의 84%가 Lv36+)이 고레어 리전 +# income으로 벌린다. Common 고정 상한(insect_candy_battles)은 참고용이며 FAIL 트리거가 +# 아니다: 시뮬 자신이 그걸 "최악(순수 Common·배틀만)"이라 명시하면서 그걸로 FAIL을 내면 +# 검증기가 자기가 인정한 극단으로 거짓 경보를 울리는 셈이다. +THRESHOLD_BATTLES_FAIL = 4000 +# 근거: 트레이너 곡선은 선형이라 후반/초반비가 완만해야 정상. 선형이 5배를 넘으면 +# 어딘가 지수가 섞인 것(이탈 구간). 곤충 캔디(지수)는 이 검사 대상이 아니다 — 지수가 설계다. +THRESHOLD_TRAINER_CURVE_WARN = 5.0 +# 근거: 팀 전체 동시 Lv50 캔디. MaxTeamSlots 반영. 10만 초과는 캔디 인플레. +THRESHOLD_TEAM_CANDY_FAIL = 100000 +# 근거: 튜토리얼 캔디가 초반(Lv1→10) 곤충 캔디 비용의 몇 배인지. 초반 부양 강도. +# 5% 미만이면(엔드게임 대비가 아니라 초반 대비) 신규 인센티브 부족. +THRESHOLD_TUTORIAL_EARLY_WARN = 0.05 + + +def _load_facts(): + # import 시점에 돌아 main()의 예외 처리보다 이르므로 여기서 잡는다. + try: + return { + "mult": game_facts.rarity_multipliers(), + "tut": game_facts.tutorial_rewards(), + "raid_mult": game_facts.raid_reward_mult(), + "team_max": game_facts.team_max_slots(), + "trainer": game_facts.trainer_xp_curve(), + "candy_curve": game_facts.insect_candy_curve(), + "battle": game_facts.battle_rewards_by_rarity(), + "roster": game_facts.field_roster(), + "regions": game_facts.region_pools(), + } + except game_facts.ExtractorBroken as e: + print(f"추출기 고장: {e}\n게임 수치를 코드에서 읽지 못했다 — 시뮬을 돌리지 않는다.", + file=sys.stderr) + sys.exit(2) + + +F = _load_facts() +MULT = F["mult"] +RARITIES = ("Common", "Uncommon", "Rare", "Epic", "Legendary") + + +# ── 곡선 (코드 공식 그대로) ── + +def trainer_xp_to_next(level: int) -> int: + """트레이너 Lv->Lv+1 필요 EXP. 선형: max(floor, base+(lv-1)*growth).""" + c = F["trainer"] + return max(c["floor"], c["base"] + (level - 1) * c["growth"]) + + +def insect_candy_cost(level: int) -> int: + """곤충 Lv->Lv+1 필요 캔디. 지수: base*growth^(lv-1).""" + c = F["candy_curve"] + return max(1, round(c["base"] * (c["growth"] ** (level - 1)))) + + +def total_trainer_xp(target: int) -> int: + return sum(trainer_xp_to_next(L) for L in range(1, target)) + + +def total_insect_candy(target: int) -> int: + return sum(insect_candy_cost(L) for L in range(1, target)) + + +# ── 전투당 보상 (등급별 실제값 = base * 등급배율) ── + +def reward_per_battle(rarity: str) -> dict: + """적 곤충 1마리 처치/포획 시 실제 EXP·캔디. base(등급별) * 등급배율.""" + b = F["battle"][rarity] + m = MULT[rarity] + return {"exp": b["exp"] * m, "candy": b["candy"] * m} + + +# ── 진행 추정 ── + +def trainer_battles(target: int, rarity: str) -> int: + """트레이너 target 레벨까지 필요한 전투 수(같은 등급 적 기준).""" + per = reward_per_battle(rarity)["exp"] + return round(total_trainer_xp(target) / per) if per else 0 + + +def insect_candy_battles(target: int, rarity: str) -> int: + """곤충 1마리 target 레벨까지 캔디를 배틀+포획으로만 모을 때 전투 수. + + 배틀 승리와 포획은 같은 GetCandyReward를 준다(둘 다 처치/포획당 1회). 레이드(×3)· + 가챠·튜토리얼은 보너스라 여기 안 넣는다 — 단일 등급 고정 상한(최악=Common)을 본다. + """ + per = reward_per_battle(rarity)["candy"] + return round(total_insect_candy(target) / per) if per else 0 + + +def _candy_of(rarity: str) -> int: + """적 1마리 처치/포획 캔디(정수) = base(등급별) * 등급배율. GetCandyReward와 동일 반올림.""" + return int(F["battle"][rarity]["candy"] * MULT[rarity]) + + +def region_income_curve() -> list: + """[(requiredLevel, E[candy]/전투), ...] 오름차순 — 리전별 스폰가중 기대 캔디. + + 각 리전 insectIds를 spawnWeight로 가중해(InsectSpawner.GetWeightedRandom 그대로) 등급 + 분포를 구하고 전투당 기대 캔디를 낸다. 리전 진행에 따라 조우 등급이 오르므로 income도 오른다. + """ + roster = F["roster"] # {id: (rarity, weight)} + out = [] + for _rid, req, ids in F["regions"]: + tot = 0.0 + acc = 0.0 + for iid in ids: + if iid in roster: + rar, w = roster[iid] + if w > 0: + tot += w + acc += w * _candy_of(rar) + if tot > 0: + out.append((req, acc / tot)) + out.sort() + return out + + +def insect_candy_battles_realistic(target: int) -> int: + """곤충 레벨이 리전 진행과 동기화된다는 가정의 현실적 전투 수. + + 곤충 레벨 L을 올릴 캔디를, 그 시점 플레이어가 있는 리전(requiredLevel<=L 중 최상위)의 + 기대 캔디로 나눠 누적한다. 캔디 비용이 지수라 후반 레벨(전체의 84%가 Lv36+)이 + 고레어 엔드리전 income으로 벌리므로 Common 고정보다 크게 낮다. 레이드×3·가챠·튜토리얼은 + 여전히 별도(추가 하향)라 이 값도 상한 성격이다. + """ + income = region_income_curve() + if not income: + return 0 + + def income_at(level: int) -> float: + cur = income[0][1] + for req, e in income: + if level >= req: + cur = e + return cur + + battles = 0.0 + for level in range(1, target): + battles += insect_candy_cost(level) / income_at(level) + return round(battles) + + +def trainer_curve_ratio(target: int) -> float: + """트레이너 후반(Lv35+)/초반(Lv1-20) EXP 평균비. 선형이라 완만해야 정상.""" + early = [trainer_xp_to_next(L) for L in range(1, min(21, target))] + late = [trainer_xp_to_next(L) for L in range(35, target)] + if not early or not late: + return 0.0 + return (sum(late) / len(late)) / (sum(early) / len(early)) + + +def tutorial_early_share(early_level: int = 10) -> float: + """튜토리얼 캔디 / 곤충 Lv1→early_level 캔디 비용. 초반 부양 강도.""" + early_cost = total_insect_candy(early_level) + return F["tut"]["candy"] / early_cost if early_cost else 0.0 + + +def render_curve_table(target: int) -> str: + out = ["| Lv | 트레이너 EXP/Lv (선형) | 곤충 캔디/Lv (지수) | 트레이너 EXP누적 | 곤충 캔디누적 |", + "|----|----:|----:|----:|----:|"] + cum_xp = cum_candy = 0 + checkpoints = {1, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50} + for L in range(1, target + 1): + if L < target: + cum_xp += trainer_xp_to_next(L) + cum_candy += insect_candy_cost(L) + if L in checkpoints: + out.append(f"| {L} | {trainer_xp_to_next(L):,} | {insect_candy_cost(L):,} | " + f"{cum_xp:,} | {cum_candy:,} |") + return "\n".join(out) + + +def evaluate_signals(args) -> list: + signals = [] + + # 1. 곤충 Lv50 캔디 전투 수 — 판정은 현실 진행(리전 동기화), 참고로 Common 고정 상한 병기. + # Common 고정(ib_worst)은 시뮬 자신이 "최악"이라 부르는 값이라 FAIL 트리거로 쓰지 않는다. + ib_real = insect_candy_battles_realistic(args.target_level) + ib_worst = insect_candy_battles(args.target_level, "Common") + judge = "FAIL" if ib_real >= THRESHOLD_BATTLES_FAIL else "PASS" + signals.append((f"곤충 Lv{args.target_level} 캔디 전투 수 (현실 진행·리전 동기화)", + f"< {THRESHOLD_BATTLES_FAIL:,}", + f"{ib_real:,}회 (최악=Common 고정 {ib_worst:,}; 레이드×{F['raid_mult']:.0f}·가챠·튜토리얼 별도 하향)", + judge)) + + # 2. 팀 전체 동시 Lv50 캔디 비용 (MaxTeamSlots 반영) + team_candy = total_insect_candy(args.target_level) * args.team_size + judge = "FAIL" if team_candy >= THRESHOLD_TEAM_CANDY_FAIL else "PASS" + signals.append((f"팀 {args.team_size}마리 동시 캔디 비용", + f"< {THRESHOLD_TEAM_CANDY_FAIL:,}", f"{team_candy:,}", judge)) + + # 3. 트레이너 곡선 형태 — 선형이라 후반/초반비가 완만해야 정상. + # 곤충 캔디(지수)는 여기 검사 대상이 아니다. 지수 성장은 설계다. + tcr = trainer_curve_ratio(args.target_level) + judge = "WARN" if tcr > THRESHOLD_TRAINER_CURVE_WARN else "PASS" + signals.append(("트레이너 EXP 후반/초반비 (선형 곡선)", + f"<= {THRESHOLD_TRAINER_CURVE_WARN:.1f}x", + f"{tcr:.1f}x", judge)) + + # 4. 튜토리얼 초반 부양 — 엔드게임 단일 곤충 평생 캔디가 아니라 초반(Lv1→10) 대비. + tes = tutorial_early_share(10) + judge = "WARN" if tes < THRESHOLD_TUTORIAL_EARLY_WARN else "PASS" + signals.append(("튜토리얼 캔디 / 곤충 Lv1→10 비용", + f">= {THRESHOLD_TUTORIAL_EARLY_WARN*100:.0f}%", + f"{tes*100:.0f}%", judge)) + + return signals + + +def render_signals(signals: list) -> str: + out = ["| 항목 | 임계값 | 측정값 | 판정 |", "|------|--------|--------|------|"] + for name, threshold, value, judge in signals: + out.append(f"| {name} | {threshold} | {value} | **{judge}** |") + fail_n = sum(1 for s in signals if s[3] == "FAIL") + warn_n = sum(1 for s in signals if s[3] == "WARN") + pass_n = sum(1 for s in signals if s[3] == "PASS") + out.append("") + out.append(f"요약: **{fail_n} FAIL** / {warn_n} WARN / {pass_n} PASS") + if fail_n > 0: + out.append("→ FAIL 1건 이상. PASS 보고 금지.") + return "\n".join(out) + + +def main(): + p = argparse.ArgumentParser(description="이원 레벨 진행 곡선 시뮬") + p.add_argument("--target-level", type=int, default=F["candy_curve"]["max"]) + p.add_argument("--rarity", default="Common", choices=list(RARITIES)) + p.add_argument("--avg-battle-sec", type=float, default=30.0) + p.add_argument("--team-size", type=int, default=F["team_max"], + help=f"팀 크기 (기본 = MaxTeamSlots = {F['team_max']})") + args = p.parse_args() + + print(f"# progression-sim — 이원 레벨 (트레이너 EXP·선형 / 곤충 캔디·지수)\n") + print("## 코드에서 읽은 구조") + print(f"- 트레이너 곡선(선형): max({F['trainer']['floor']}, " + f"{F['trainer']['base']}+(lv-1)*{F['trainer']['growth']}) ← 배틀/포획/레이드/튜토리얼 EXP") + print(f"- 곤충 캔디 곡선(지수): {F['candy_curve']['base']}*{F['candy_curve']['growth']}^(lv-1)" + f" ← 캔디로만 (TryLevelUpWithCandy)") + print(f"- 곤충 XP 곡선은 미사용(dead 배선) — 진행 경로 아님") + print(f"- 전투당 보상({args.rarity} 적): EXP {reward_per_battle(args.rarity)['exp']:.1f} / " + f"캔디 {reward_per_battle(args.rarity)['candy']:.1f} (base × 등급배율 {MULT[args.rarity]})") + print(f"- 튜토리얼: 캔디 {F['tut']['candy']} / EXP {F['tut']['exp']}") + print() + + print("## 누적 곡선") + print(render_curve_table(args.target_level)) + print() + + print("## 핵심 지표") + print(f"- 트레이너 Lv{args.target_level} 총 EXP: **{total_trainer_xp(args.target_level):,}** " + f"→ 전투 **{trainer_battles(args.target_level, args.rarity):,}회** ({args.rarity} 적)") + print(f"- 곤충 1마리 Lv{args.target_level} 총 캔디: **{total_insect_candy(args.target_level):,}**") + print(f" · 현실 진행(리전 동기화): 전투 **{insect_candy_battles_realistic(args.target_level):,}회** ← 판정 대상") + print(f" · 최악(Common 고정, 배틀+포획): 전투 **{insect_candy_battles(args.target_level, 'Common'):,}회** (참고 상한)") + print(f"- 팀 {args.team_size}마리 캔디: **{total_insect_candy(args.target_level)*args.team_size:,}**") + print() + + print("## 리전별 스폰가중 캔디 income (진행에 따라 상승)") + print("| requiredLevel | E[candy]/전투 |") + print("|----:|----:|") + for req, e in region_income_curve(): + print(f"| {req} | {e:.2f} |") + print() + + print("## 위험 신호 표") + print(render_signals(evaluate_signals(args))) + print() + + print("## 가정 / 한계") + print("- 판정(신호1)은 **현실 진행**: 곤충 레벨 L의 캔디를 그 시점 리전(requiredLevel<=L 중") + print(" 최상위)의 스폰가중 기대 캔디로 벌어들인다고 본다. 캔디 비용이 지수라 전체의 84%가") + print(" Lv36+에 몰리고, 그 구간은 고레어 엔드리전(예: 유적 ~7캔디/전투)에서 벌린다.") + print("- 캔디는 전역 단일 풀(PlayerCandyInventory)이라 종 무관하게 합산된다 — 종별 캔디 아님.") + print("- 레이드(×3, 예: Epic 30·Legendary 48캔디)·가챠 박스(5~50)·튜토리얼(336)은 별도라") + print(" 현실 전투 수를 더 낮춘다. 현실 수치도 그 의미에서 상한이다.") + print("- 리전 income은 InsectSpawner.GetWeightedRandom(무아이템)과 동일하게 spawnWeight로") + print(" 가중. 레어스폰 아이템/의상 보너스는 미반영(있으면 고레어↑ → income↑).") + print("- 곤충별 candyReward는 PlaySceneBootstrap의 등급별 하드코딩을 읽는다" + "(InsectDatabase .asset의 개체별 편차는 미반영).") + + fail = sum(1 for s in evaluate_signals(args) if s[3] == "FAIL") + return 1 if fail else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.claude/scripts/quest_lint.py b/.claude/scripts/quest_lint.py new file mode 100644 index 0000000..96cc03b --- /dev/null +++ b/.claude/scripts/quest_lint.py @@ -0,0 +1,344 @@ +"""퀘스트 정합성 검증. 보상 ID·prerequisite 체인·QuestType 배선·대화 리전키 검사. + +data_lint의 형제다. data_lint는 리전/샵/가챠 ID를 전담하고 이미 12검사라, 퀘스트는 섞지 +않고 별 파일로 둔다(관심사 분리, 독립 실행/CI). + +가장 중요한 검사는 QuestType↔진행 배선(검사 5)이다. 새 퀘스트 목표 타입을 추가하면 5곳 +(QuestType enum / 배열 / Notify 메서드 / **게임플레이 호출부** / 이벤트 등록)을 건드려야 +하는데, 호출부나 이벤트 등록이 누락되면 그 퀘스트가 IncrementProgress에 영영 도달 못 해 +영구 정지한다. 실제로 q_team(SetTeam)이 TeamChanged 미구독으로 멈춘 회귀가 있었다 +(TutorialQuestManager.cs:322 주석). 이 검사가 그 회귀를 잡는다. + +종료 코드: 0 정상 / 1 데이터 결함 / 2 추출기 고장 (관통 원칙 — data_lint와 동일). +""" +import glob +import io +import os +import re +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import game_facts # noqa: E402 +from game_facts import ExtractorBroken # noqa: E402 +import data_lint # noqa: E402 + +try: + sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace") +except Exception: + pass + + +def extract_boss_duel_rewards(): + """`NpcBossDuels`의 (storyNpcId, rewardItemId) 목록. + + 표가 C# 하드코딩 배열이라 소스를 읽는다(퀘스트 배열과 같은 형태). + 추출이 0건이면 표가 비었거나 파서가 낡은 것이므로 `ExtractorBroken`을 던진다 — + 빈 목록을 "위반 0건"으로 읽으면 검사가 조용히 죽는다(이 파일의 관통 원칙). + """ + path = os.path.join("Assets", "Scripts", "NPC", "NpcBossDuels.cs") + if not os.path.exists(path): + raise ExtractorBroken(f"{path}: 파일 없음 — 경로가 바뀌었으면 이 추출기도 고칠 것") + + with io.open(path, encoding="utf-8", errors="replace") as f: + src = f.read() + + # new BossDuel { ... storyNpcId = "x" ... rewardItemId = "y" ... } — 필드 순서에 의존하지 + # 않도록 블록을 먼저 자르고 그 안에서 각각 찾는다. + out = [] + for block in re.findall(r"new\s+BossDuel\s*\{(.*?)\}", src, re.S): + npc = re.search(r'storyNpcId\s*=\s*"([^"]+)"', block) + item = re.search(r'rewardItemId\s*=\s*"([^"]+)"', block) + if npc and item: + out.append((npc.group(1), item.group(1))) + + if not out: + raise ExtractorBroken( + "NpcBossDuels에서 대결을 하나도 못 읽었다 — 표 구조가 바뀌었는지 확인할 것") + return out + + +def notify_called_in_gameplay(method: str) -> bool: + """게임플레이 코드(TutorialQuestManager 밖)에서 `.method(`가 호출되나. + + notify 경로 QuestType은 전용 Notify 메서드가 게임플레이 시스템(배틀/포획 등)에서 + 호출돼야 진행된다. 메서드 정의만 있고 호출부가 없으면 그 QuestType은 영구 정지다. + """ + pat = re.compile(r"\." + re.escape(method) + r"\s*\(") + for path in glob.glob("Assets/Scripts/**/*.cs", recursive=True): + if "TutorialQuestManager" in path: + continue + try: + with open(path, encoding="utf-8") as f: + if pat.search(f.read()): + return True + except (OSError, UnicodeDecodeError): + continue + return False + + +def _prereq_cycle(quests) -> str: + """prerequisite 체인에 사이클이 있으면 그 시작 questId, 없으면 None.""" + prereq = {q["questId"]: q["prereq"] for q in quests} + for start in prereq: + seen, cur = set(), start + while cur: + if cur in seen: + return start + seen.add(cur) + cur = prereq.get(cur) + return None + + +def evaluate_signals() -> list: + signals = [] + quests = game_facts.quest_defs() + ids = [q["questId"] for q in quests] + idset = set(ids) + + # 1. questId 중복 + dups = sorted({x for x in ids if ids.count(x) > 1}) + signals.append(("questId 중복", "0건", + f"{len(dups)}건 ({dups})" if dups else f"0건 ({len(ids)}개 퀘스트)", + "FAIL" if dups else "PASS")) + + # 2. prerequisite 무결성 — 끊긴 참조 / 자기참조 / 순환 + broken = [] + for q in quests: + p = q["prereq"] + if p and p not in idset: + broken.append(f"{q['questId']}→{p}(없음)") + if p and p == q["questId"]: + broken.append(f"{q['questId']} 자기참조") + cycle = _prereq_cycle(quests) + if cycle: + broken.append(f"순환({cycle})") + signals.append(("prerequisite 무결성", "0건", + f"{len(broken)}건 ({broken})" if broken else "0건", + "FAIL" if broken else "PASS")) + + # 3. 보상 곤충 ID → 곤충 존재 + insect_ids = game_facts.all_insect_ids() + bad_insect = [f"{q['questId']}:{q['reward_insect']}" + for q in quests if q["reward_insect"] and q["reward_insect"] not in insect_ids] + signals.append(("보상 곤충 ID 존재", "0건 미존재", + f"{len(bad_insect)}건 ({bad_insect})" if bad_insect + else f"0건 (곤충 {len(insect_ids)}종 대조)", + "FAIL" if bad_insect else "PASS")) + + # 4. 보상 아이템 ID → 아이템 존재 + # 집합 = capture item ∪ shop 진열ID ∪ shop 지급ID ∪ ItemDatabase 레지스트리. + # (예전엔 shop 지급ID(rewards)를 _로 버려 exp_boost처럼 ItemDatabase에만 있는 아이템이 + # 오탐으로 걸렸다 — story_lint와 동일 수정. game_facts.item_ids()가 런타임 레지스트리.) + shop_items, shop_rewards = data_lint.extract_cashshop_items() + item_ids = (data_lint.extract_capture_items() | shop_items | shop_rewards + | game_facts.item_ids()) + bad_item = [f"{q['questId']}:{q['reward_item']}" + for q in quests if q["reward_item"] and q["reward_item"] not in item_ids] + signals.append(("보상 아이템 ID 존재", "0건 미존재", + f"{len(bad_item)}건 ({bad_item})" if bad_item + else f"0건 (아이템 {len(item_ids)}종 대조)", + "FAIL" if bad_item else "PASS")) + + # 4b. 보스 대결 보상 아이템 ID → 아이템 존재 (NpcBossDuels) + # 퀘스트 보상과 같은 함정을 공유한다: 오타를 물어도 런타임엔 조용히 실패해 승리 보상이 + # 사라진다. `NpcBossDuels` 주석은 "아이템 ID는 ItemDatabase에 실재해야 하며 + # NpcBossDuelTests가 그 정합을 고정한다"고 적었지만, 그 테스트는 실제로는 + # **비어 있지 않은지만** 본다. 아이템 ID 레지스트리를 이미 여기서 모으고 있으므로 + # (여러 소스의 합집합이라 C# 쪽에서 다시 모으면 사본이 생긴다) 검사도 여기 둔다. + boss_rewards = extract_boss_duel_rewards() + bad_boss = [f"{npc}:{item}" for npc, item in boss_rewards if item not in item_ids] + signals.append(("보스 대결 보상 아이템 ID 존재", "0건 미존재", + f"{len(bad_boss)}건 ({bad_boss})" if bad_boss + else f"0건 (대결 {len(boss_rewards)}건 대조)", + "FAIL" if bad_boss else "PASS")) + + # 5. QuestType ↔ 진행 배선 — 배열이 쓰는 각 QuestType이 IncrementProgress에 닿는가. + # q_team류 회귀(이벤트 미등록/호출부 누락으로 퀘스트 영구 정지)를 잡는 핵심 검사. + wiring = game_facts.quest_progress_wiring() + used_types = {q["type"] for q in quests if q["type"]} + unreachable = [] + for qt in sorted(used_types): + paths = wiring.get(qt, []) + reachable = False + for via, wired, detail in paths: + if via == "update": + reachable = True + elif via == "event" and wired: + reachable = True + elif via == "notify" and notify_called_in_gameplay(detail): + reachable = True + if not reachable: + # 왜 안 닿는지 진단 문구 + if not paths: + why = "진행 로직 자체 없음" + elif any(v == "event" and not w for v, w, _ in paths): + why = "이벤트 핸들러 있으나 SubscribeEvents 미등록" + else: + why = "Notify 메서드 있으나 게임플레이 호출부 없음" + unreachable.append(f"{qt}({why})") + signals.append(("QuestType↔진행 배선", "0건 정지", + f"{len(unreachable)}건 ({unreachable})" if unreachable + else f"0건 (사용 타입 {len(used_types)}종 전부 도달 가능)", + "FAIL" if unreachable else "PASS")) + + # 6. 대화 RegionLines 키 → 리전 존재 + region_ids = {r[0] for r in game_facts.region_pools()} + dlg_keys = game_facts.dialogue_region_keys() + orphan = sorted(dlg_keys - region_ids) + signals.append(("대화 리전키 정합성", "0건 고아", + f"{len(orphan)}건 ({orphan})" if orphan + else f"0건 (대화 키 {len(dlg_keys)}개 대조)", + "FAIL" if orphan else "PASS")) + + # 7. 서브 퀘스트 정합 — repeatable은 Side 전용(스토리 선형 체인 오염 방지), + # repeatable이면 targetIncrement>0(그래야 '할 때마다 목표 상승'이 실제로 일어남). + # 필드 생략 시 category=Story/repeatable=false라 기존 20개는 자동 통과. + side_bad = [] + for q in quests: + cat = q.get("category", "Story") + rep = q.get("repeatable", False) + inc = q.get("target_increment", 0) + if rep and cat != "Side": + side_bad.append(f"{q['questId']}(repeatable인데 category={cat})") + if rep and inc <= 0: + side_bad.append(f"{q['questId']}(repeatable인데 targetIncrement={inc}=목표상승 없음)") + side_count = sum(1 for q in quests if q.get("category") == "Side") + signals.append(("서브 퀘스트 정합 (반복/카테고리)", "0건", + f"{len(side_bad)}건 ({side_bad})" if side_bad + else f"0건 (Side {side_count}개 정합)", + "FAIL" if side_bad else "PASS")) + + # 8. 전투 퀘스트가 팀 편성 퀘스트보다 **앞서면** 팀을 자동으로 채우는 경로가 있어야 한다. + # + # `CaptureChoiceUI`의 [B] 배틀은 `BattleTeamManager.HasAnyInsect()`만 보고 버튼과 키 + # 입력을 함께 막는다 — 컬렉션 폴백이 없다(`NpcDuelController.FindPlayerLeader`와 다르다). + # 그래서 팀이 빈 채로 Battle 타입 퀘스트에 도달하면 **튜토리얼이 거기서 영구 정지**하고, + # 팀 편성을 가르치는 q_team은 그보다 뒤라 안내조차 없다. 스토리도 함께 멈춘다 + # (ch1_first_battle·ch1_guardian_call이 BattleWin). + # + # 실제로 났다: 첫 파트너 지급이 퀘스트 보상에서 `ch1_intro` 비트로 옮겨가며 + # `TutorialQuestManager.CompleteQuest` 안의 1번 슬롯 자동 배치가 **죽은 코드**가 됐다 + # (튜토리얼 퀘스트에 rewardInsectId가 하나도 안 남았다). 지금은 `BattleTeamManager`가 + # `InsectCaptured`를 구독해 지급 경로 전체를 한 번에 덮는다 — 그 구독이 사라지면 여기서 잡는다. + story_order = [q for q in quests if q.get("category", "Story") != "Side"] + battle_at = next((i for i, q in enumerate(story_order) if q.get("type") == "Battle"), None) + setteam_at = next((i for i, q in enumerate(story_order) if q.get("type") == "SetTeam"), None) + + if battle_at is None or setteam_at is None or battle_at > setteam_at: + # 팀 편성을 먼저 가르치므로 자동 편성이 없어도 막히지 않는다. + signals.append(( + "팀 자동 편성 경로 (전투 퀘스트가 팀 편성보다 앞설 때)", + "필요 시 존재", + "해당 없음 (팀 편성 퀘스트가 전투보다 앞서거나 없음)", + "PASS", + )) + else: + team_path = os.path.join( + os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), + "Assets", "Scripts", "Core", "BattleTeamManager.cs") + try: + with open(team_path, encoding="utf-8") as fh: + team_src = fh.read() + except OSError as exc: + raise ExtractorBroken(f"BattleTeamManager.cs를 읽지 못했다: {exc}") + + subscribes = "InsectCaptured +=" in team_src + fills = re.search(r"HasAnyInsect\(\)\s*\)\s*return;[\s\S]{0,200}?SetSlot\(0", team_src) is not None + missing = [] + if not subscribes: + missing.append("InsectCaptured 미구독") + if not fills: + missing.append("빈 팀 → SetSlot(0) 경로 없음") + + signals.append(( + "팀 자동 편성 경로 (전투 퀘스트가 팀 편성보다 앞설 때)", + "존재", + f"없음 ({missing}) — 전투 퀘스트 #{battle_at + 1} < 팀 편성 #{setteam_at + 1}" if missing + else f"있음 (BattleTeamManager가 InsectCaptured 구독, 전투 #{battle_at + 1} < 편성 #{setteam_at + 1})", + "FAIL" if missing else "PASS", + )) + + # 9. 모든 prerequisiteQuestId는 배열에서 **자기보다 앞**을 가리켜야 한다. + # + # ActivateNextQuest가 배열을 앞에서부터 훑어 첫 미완료·prereq충족을 고르므로, 이 전제가 + # 성립할 때만 **완료 순서 = 배열 순서**다. 그리고 그 등식 위에 + # TutorialQuestManager.BackfillSkippedStoryQuests(배열 중간 삽입분의 소급 완료)가 서 있다 — + # 뒤엣것이 완료됐으면 앞엣것도 완료됐어야 한다는 판정이다. + # + # 전제가 깨지면(prereq가 배열 뒤를 가리키면) 그 퀘스트는 prereq 미충족으로 건너뛰어질 수 + # 있고, 그러면 소급이 **아직 할 차례인 퀘스트를 완료 처리해 버린다** — 보상 없이 사라지고 + # 플레이어는 그 단계를 영영 못 한다. 런타임엔 아무 경고도 없다. + story_seq = [q for q in quests if q.get("category", "Story") != "Side"] + pos = {q["questId"]: i for i, q in enumerate(story_seq)} + backward = [] + for i, q in enumerate(story_seq): + # game_facts는 prereq 키로 준다 — prerequisiteQuestId로 읽으면 항상 None이라 + # 검사가 통째로 껍데기가 된다(실제로 그렇게 만들었다가 검출력 실측에서 잡았다). + pre = q.get("prereq") or None + if not pre: + continue + if pre not in pos: + continue # 존재하지 않는 prereq는 검사 2가 잡는다 + if pos[pre] > i: + backward.append(f"{q['questId']}(#{i + 1}) → {pre}(#{pos[pre] + 1})") + signals.append(( + "prereq 방향 (배열 앞을 가리켜야 소급 완료가 안전)", + "0건 역방향", + f"{len(backward)}건 ({backward})" if backward + else f"0건 (스토리 {len(story_seq)}개, 완료 순서 = 배열 순서)", + "FAIL" if backward else "PASS", + )) + + # 11. questId 구분자 금지 — 세이브 병합 포맷(QuestSaveMerge)이 ","와 ":"를 구분자로 쓴다. + # 그 문자가 든 ID는 병합에서 조용히 버려지고, ApplyQuestField의 write-back이 그 손실을 + # PlayerPrefs에 영구 반영한다(예외도 경고도 없다). 공백도 Trim에 먹혀 ID가 달라진다. + sep_bad = [q["questId"] for q in quests + if any(ch in q["questId"] for ch in ",:") or q["questId"] != q["questId"].strip()] + signals.append(("questId 구분자 금지 (, : 공백 — 세이브 병합 포맷)", "0건", + f"{len(sep_bad)}건 ({sep_bad})" if sep_bad else f"0건 ({len(ids)}개)", + "FAIL" if sep_bad else "PASS")) + + return signals + + +def render(signals) -> str: + out = ["| 항목 | 임계값 | 측정값 | 판정 |", "|------|--------|--------|------|"] + for name, thr, val, judge in signals: + out.append(f"| {name} | {thr} | {val} | **{judge}** |") + fail = sum(1 for s in signals if s[3] == "FAIL") + warn = sum(1 for s in signals if s[3] == "WARN") + passn = sum(1 for s in signals if s[3] == "PASS") + out.append("") + out.append(f"요약: **{fail} FAIL** / {warn} WARN / {passn} PASS") + if fail: + out.append("→ FAIL 1건 이상. PASS 보고 금지. 즉시 정정 필요.") + return "\n".join(out) + + +def main(): + print("# quest-lint — 곤충게임 퀘스트 정합성 검증\n") + print("## 위험 신호 표") + signals = evaluate_signals() + print(render(signals)) + print() + print("## 가정 / 한계") + print("- 퀘스트는 TutorialQuestManager 배열에서 읽는다(SO/JSON 아님).") + print("- 보상 곤충은 필드+가챠전용 ID로 대조. InsectDatabase .asset의 개체별 편차는 미반영.") + print("- 검사 5는 update/event(구독)/notify(게임플레이 호출) 경로 중 하나라도 닿으면 통과.") + print("- 검사 8은 배열 순서로 전투/팀편성 선후를 보고, 앞설 때만 BattleTeamManager의") + print(" InsectCaptured 구독 + 빈 팀 SetSlot(0) 경로를 소스에서 확인한다.") + print("- 검사 9는 소급 완료(BackfillSkippedStoryQuests)의 전제인 '완료 순서 = 배열 순서'를") + print(" 고정한다. prereq가 배열 뒤를 가리키면 소급이 아직 할 차례인 퀘스트를 건너뛴다.") + fail = sum(1 for s in signals if s[3] == "FAIL") + return 1 if fail else 0 + + +if __name__ == "__main__": + # 0=정상, 1=데이터 결함, 2=추출기 고장. 1과 2를 가른다(관통 원칙). + try: + sys.exit(main()) + except ExtractorBroken as e: + print(f"\n## 추출기 고장\n\n**{e}**\n") + print("퀘스트 데이터 결함이 아니라 이 스크립트가 코드를 못 따라간 것이다.") + print("검증 결과는 신뢰할 수 없다 — 추출기를 먼저 고칠 것.") + sys.exit(2) diff --git a/.claude/scripts/singleton_lint.py b/.claude/scripts/singleton_lint.py new file mode 100644 index 0000000..fc84f18 --- /dev/null +++ b/.claude/scripts/singleton_lint.py @@ -0,0 +1,93 @@ +"""singleton-lint — 파기된 싱글턴이 static에 남는 것을 검출. + +이 저장소의 매니저 9종은 `public static T Instance`를 들고 있고, 전부 `PlaySceneBootstrap`이 +`EnsureComponent("World/…")`로 만든다 — **경로에 부모가 있어 씬 스코프다.** 로그아웃과 +계정 삭제가 씬을 통째로 재로드하므로(`AccountSettingsUI.ReloadScene`) 이들은 실제로 파기된다. + +파기됐는데 static이 그 참조를 붙들고 있으면 **두 관용구가 서로 다른 답을 낸다**: + + if (X.Instance != null) X.Instance.Foo(); // UnityEngine.Object의 오버로드된 == → 안 부른다 + X.Instance?.Foo(); // 진짜 null 검사 → **부른다** → MissingReferenceException + +저장소 안에 `Instance?.`가 19곳 있다. 그래서 호출부를 하나씩 고치는 대신 뿌리에서 막는다 — +싱글턴은 `OnDestroy`에서 자기 static을 비운다. + + private void OnDestroy() + { + if (ReferenceEquals(Instance, this)) Instance = null; + } + +`ReferenceEquals`를 쓰는 이유: `Instance == this`는 파괴 검사를 타서 파기 중에는 false가 될 수 +있고, 그러면 정작 비워야 할 때 안 비운다. + +2026-08-23 감사 시점엔 9종 중 **1종(WorldChannelManager)만** 이 처리를 하고 있었다. + +exit 0 = 통과, 1 = 위반, 2 = 파싱 실패(구조가 바뀌었으니 파서부터 확인할 것). +""" +import glob +import io +import os +import re +import sys + +ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +# 싱글턴이지만 MonoBehaviour가 아니라 파기 대상이 아닌 것 — 근거와 함께 적는다. +EXEMPT = { + # UITheme: ScriptableObject 기반 테마 홀더. 씬 오브젝트가 아니라 OnDestroy 수명이 다르다. + "UITheme", +} + + +def main() -> int: + files = sorted(glob.glob(os.path.join(ROOT, "Assets/Scripts/**/*.cs"), recursive=True)) + if not files: + print("Assets/Scripts에서 .cs를 하나도 못 찾았다 — 경로가 바뀌었는가?", file=sys.stderr) + return 2 + + singletons = [] # (클래스, 파일, 해제하는가) + for path in files: + src = io.open(path, encoding="utf-8", errors="replace").read() + # `public static Foo Instance { get; private set; }` / `public static Foo Instance;` + decl = re.search(r"public\s+static\s+\w+\s+Instance\s*[{;=]", src) + if not decl: + continue + # **Instance를 선언한 클래스**를 고른다. 파일 첫 `public class`를 집으면 헬퍼 타입이 + # 먼저 선언된 파일에서 엉뚱한 이름을 보고한다(GachaBoxManager.cs가 `GachaResult`로 나왔다). + before = [m.group(1) for m in + re.finditer(r"public\s+(?:sealed\s+)?class\s+(\w+)", src[:decl.start()])] + if not before: + continue + cls = before[-1] + if cls in EXEMPT: + continue + if ": MonoBehaviour" not in src and ":MonoBehaviour" not in src: + continue # 씬 오브젝트가 아니면 OnDestroy가 없다 + clears = re.search(r"Instance\s*=\s*null", src) is not None + singletons.append((cls, os.path.relpath(path, ROOT).replace("\\", "/"), clears)) + + if not singletons: + print("MonoBehaviour 싱글턴을 하나도 못 찾았다 — `public static T Instance` 형태가 바뀌었는가?", + file=sys.stderr) + return 2 + + violations = [(c, p) for c, p, ok in singletons if not ok] + + print("# singleton-lint — 파기된 싱글턴이 static에 남는가\n") + print(f"검사 대상: MonoBehaviour 싱글턴 {len(singletons)}개") + if not violations: + print("\n결과: **PASS** (위반 0건)") + return 0 + + print(f"\n결과: **FAIL** — {len(violations)}건\n") + print("| 클래스 | 파일 |") + print("|---|---|") + for cls, path in violations: + print(f"| {cls} | `{path}` |") + print("\n고치는 법: `OnDestroy`에 `if (ReferenceEquals(Instance, this)) Instance = null;`") + print("안 그러면 `Instance != null`과 `Instance?.`가 서로 다른 답을 낸다(위 모듈 주석 참조).") + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.claude/scripts/story_lint.py b/.claude/scripts/story_lint.py new file mode 100644 index 0000000..9391eb1 --- /dev/null +++ b/.claude/scripts/story_lint.py @@ -0,0 +1,915 @@ +"""스토리 정합성 검증. 비트 ID·prerequisite·트리거 대상·분기 도달성·보상·트리거 배선 검사. + +quest_lint의 형제다. 스토리 비트는 Story.json(json.load)이라 퀘스트 코드 배열의 정규식 +파싱보다 견고하다 — 설계(Docs/StorySystemDesign.md)가 데이터 모델을 JSON으로 정한 이유. + +가장 중요한 검사는 트리거 배선(검사 6)이다. 새 trigger.type을 JSON에 넣고 StoryDirector의 +EvaluateTriggers switch 케이스나 이벤트 구독을 빠뜨리면, 그 타입을 쓰는 비트가 영영 발화하지 +않는다. quest_lint의 "QuestType↔진행 배선"과 정확히 같은 구조 — q_team 회귀의 스토리 등가물. + +종료 코드: 0 정상 / 1 데이터 결함 / 2 추출기 고장 (관통 원칙). +""" +import io +import os +import re +import sys + +# stdout 재설정은 하위 모듈 import보다 먼저 — data_lint 등이 자기 stdout을 재설정하면서 +# 원본 핸들을 닫으면 여기서 "closed file" 오류가 난다. +try: + sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace") +except Exception: + pass + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import game_facts # noqa: E402 +from game_facts import ExtractorBroken # noqa: E402 +import data_lint # noqa: E402 + + +def _beat_prereq_cycle(beats) -> str: + """prerequisiteBeatId 체인 사이클 시작 beatId, 없으면 None. quest_lint와 동형.""" + prereq = {b["beatId"]: (b.get("prerequisiteBeatId") or None) for b in beats} + for start in prereq: + seen, cur = set(), start + while cur: + if cur in seen: + return start + seen.add(cur) + cur = prereq.get(cur) + return None + + +def _beat_gate_cycle(beats) -> str: + """prerequisiteBeatId + requiredBeatId 두 간선을 **함께** 따라간 사이클 시작 beatId (없으면 None). + + 둘은 AND라 어느 쪽으로든 자기 자신에게 돌아오면 그 비트는 영영 열리지 않는다. + 검사 2는 prereq 한 축만 보므로 게이트를 섞은 순환은 거기서 안 잡힌다. + """ + edges = {} + for b in beats: + out = [] + for key in ("prerequisiteBeatId", "requiredBeatId"): + v = b.get(key) or None + if v: + out.append(v) + edges[b["beatId"]] = out + + WHITE, GREY, BLACK = 0, 1, 2 + color = {k: WHITE for k in edges} + + def dfs(node): + color[node] = GREY + for nxt in edges.get(node, ()): + if nxt not in color: # 미존재 대상 — 검사 2/15가 따로 잡는다 + continue + if color[nxt] == GREY: + return True + if color[nxt] == WHITE and dfs(nxt): + return True + color[node] = BLACK + return False + + for start in edges: + if color[start] == WHITE and dfs(start): + return start + return None + + +def _chapter_ordinal(chapter_id: str): + """본편 챕터의 진행 순번. ch1..ch12 → 1..12, fin → 13, side/npc → None(본편 아님). + + story_lint 안에서만 쓰는 값이다. StoryObjectiveResolver.ChapterRank는 정렬용이라 + fin=1000 / side=2000 같은 센티넬을 쓰는데, 여기서는 "한 챕터 앞"을 빼야 해서 + 산술이 되는 연속 번호가 필요하다. + """ + if not chapter_id: + return None + if chapter_id.startswith("ch") and chapter_id[2:].isdigit(): + return int(chapter_id[2:]) + if chapter_id == "fin": + return 13 + return None + + +def _max_ancestor_chapter(beat_id: str, by_id: dict, seen=None) -> int: + """prerequisiteBeatId ∪ requiredBeatId를 거슬러 올라가 만나는 **본편 챕터 최대 순번**. + + 선행이 없거나 전부 side/npc면 0(캠페인 시작점과 같은 취급). + 사이클은 검사 2·15가 따로 잡으므로 여기서는 방문 표시로 끊기만 한다. + """ + if seen is None: + seen = set() + if beat_id in seen: + return 0 + seen.add(beat_id) + beat = by_id.get(beat_id) + if beat is None: + return 0 + + best = 0 + for key in ("prerequisiteBeatId", "requiredBeatId"): + target = beat.get(key) or None + if not target: + continue + parent = by_id.get(target) + if parent is not None: + ordinal = _chapter_ordinal(parent.get("chapterId")) + if ordinal: + best = max(best, ordinal) + best = max(best, _max_ancestor_chapter(target, by_id, seen)) + return best + + +def evaluate_signals() -> list: + signals = [] + beats = game_facts.story_beats() + ids = [b["beatId"] for b in beats] + idset = set(ids) + + # 1. beatId 유일성 + dups = sorted({x for x in ids if ids.count(x) > 1}) + signals.append(("beatId 중복", "0건", + f"{len(dups)}건 ({dups})" if dups else f"0건 ({len(ids)}개 비트)", + "FAIL" if dups else "PASS")) + + # 2. prerequisiteBeatId 무결성 (끊김 / 자기참조 / 순환) + broken = [] + for b in beats: + p = b.get("prerequisiteBeatId") or None + if p and p not in idset: + broken.append(f"{b['beatId']}→{p}(없음)") + if p and p == b["beatId"]: + broken.append(f"{b['beatId']} 자기참조") + cyc = _beat_prereq_cycle(beats) + if cyc: + broken.append(f"순환({cyc})") + signals.append(("prerequisite 무결성", "0건", + f"{len(broken)}건 ({broken})" if broken else "0건", + "FAIL" if broken else "PASS")) + + # 3. 트리거 param 대상 존재 — RegionEnter/SubAreaEnter→리전, QuestComplete→퀘스트, + # CaptureInsect→곤충, LevelReach→정수. param 비었으면(있어도 되는 타입) 통과. + region_ids = {r[0] for r in game_facts.region_pools()} + quest_ids = {q["questId"] for q in game_facts.quest_defs()} + insect_ids = game_facts.all_insect_ids() + npc_ids = game_facts.story_npc_ids() + bad_target = [] + for b in beats: + t = b.get("trigger") or {} + ttype, param = t.get("type"), (t.get("param") or "") + if not param: + continue + if ttype == "RegionEnter" and param not in region_ids: + bad_target.append(f"{b['beatId']}:RegionEnter({param})") + elif ttype == "SubAreaEnter" and param not in region_ids: + # 서브에리어는 리전에 속하나 별도 ID다. region_pools에 없으면 경고 수준이나, + # 확실한 리전 ID 대조만 가능하므로 미존재만 잡는다(서브에리어 ID는 리전 밖일 수 있어 완화). + pass + elif ttype == "QuestComplete" and param not in quest_ids: + bad_target.append(f"{b['beatId']}:QuestComplete({param})") + elif ttype == "CaptureInsect" and param not in insect_ids: + bad_target.append(f"{b['beatId']}:CaptureInsect({param})") + elif ttype == "LevelReach" and not param.isdigit(): + bad_target.append(f"{b['beatId']}:LevelReach({param}=비정수)") + elif ttype == "NpcTalk" and param not in npc_ids: + bad_target.append(f"{b['beatId']}:NpcTalk({param}=월드 미배치)") + elif ttype == "GuardianDefeat" and param not in region_ids: + # param은 수문장을 가진 리전 ID다. 오타면 그 비트는 조용히 영영 미발화한다. + bad_target.append(f"{b['beatId']}:GuardianDefeat({param})") + elif ttype == "DexProgress" and not param.isdigit(): + # param은 "이름을 새긴 종 수" 임계값(정수). LevelReach와 같은 형태다. + bad_target.append(f"{b['beatId']}:DexProgress({param}=비정수)") + signals.append(("트리거 param 대상 존재", "0건 미존재", + f"{len(bad_target)}건 ({bad_target})" if bad_target else "0건", + "FAIL" if bad_target else "PASS")) + + # 4. 분기 도달성 — choices[].nextBeatId 존재 + 고아 비트(prereq도 choice 대상도 아님) 없음 + branch_bad = [] + reachable_by_choice = set() + for b in beats: + for c in (b.get("choices") or []): + nxt = c.get("nextBeatId") + if nxt: + reachable_by_choice.add(nxt) + if nxt not in idset: + branch_bad.append(f"{b['beatId']}→choice({nxt}=없음)") + # 고아: prereq도 없고(체인 시작) 어떤 choice의 대상도 아니면서, 트리거도 Immediate가 아닌 비트는 + # 발화 경로가 트리거뿐이라 정상. 여기선 choice 끊김만 FAIL로 본다(고아는 트리거로 열릴 수 있음). + signals.append(("분기 도달성", "0건 끊김", + f"{len(branch_bad)}건 ({branch_bad})" if branch_bad else "0건", + "FAIL" if branch_bad else "PASS")) + + # 5. onComplete 보상 ID 존재 + unlockQuestId → 퀘스트 존재 + # 아이템 존재 집합 = capture item ∪ shop 진열ID ∪ shop 지급ID ∪ ItemDatabase 레지스트리. + # (예전엔 shop 지급ID(rewards)를 _로 버려 exp_boost처럼 ItemDatabase에만 있고 채집망이 아닌 + # 아이템이 오탐으로 걸렸다. game_facts.item_ids()가 런타임 레지스트리 단일 출처.) + shop_items, shop_rewards = data_lint.extract_cashshop_items() + item_ids = (data_lint.extract_capture_items() | shop_items | shop_rewards + | game_facts.item_ids()) + reward_bad = [] + for b in beats: + oc = b.get("onComplete") or {} + ri, it, uq = oc.get("rewardInsectId"), oc.get("rewardItemId"), oc.get("unlockQuestId") + if ri and ri not in insect_ids: + reward_bad.append(f"{b['beatId']}:곤충({ri})") + if it and it not in item_ids: + reward_bad.append(f"{b['beatId']}:아이템({it})") + if uq and uq not in quest_ids: + reward_bad.append(f"{b['beatId']}:unlockQuest({uq})") + signals.append(("onComplete 보상/unlock ID 존재", "0건 미존재", + f"{len(reward_bad)}건 ({reward_bad})" if reward_bad else "0건", + "FAIL" if reward_bad else "PASS")) + + # 6. 트리거 배선 정합 — JSON이 쓰는 각 trigger.type이 StoryDirector switch + 이벤트 소스에 + # 존재하는가. 누락 시 그 타입 비트가 영구 미발화. 스토리 하네스의 급소. + wiring = game_facts.story_trigger_wiring() + used = {(b.get("trigger") or {}).get("type") for b in beats if (b.get("trigger") or {}).get("type")} + unwired = [] + for tt in sorted(used): + sw, ev = wiring.get(tt, (False, False)) + if not sw: + unwired.append(f"{tt}(switch case 없음)") + elif not ev: + unwired.append(f"{tt}(이벤트 발화 지점 없음)") + signals.append(("트리거 배선 정합 (JSON↔StoryDirector)", "0건 미발화", + f"{len(unwired)}건 ({unwired})" if unwired + else f"0건 (사용 트리거 {len(used)}종 전부 배선됨)", + "FAIL" if unwired else "PASS")) + + # 7. requiredRegionId 정합 — 채워진 값은 리전 존재(FAIL), 무param 트리거 무가드는 권고(WARN). + # 무param CaptureInsect(param 공백)/BattleWin은 위치 무관 발화라 requiredRegionId로 리전을 + # 잠그지 않으면 '늦발화 얼룩'(엉뚱한 리전에서 옛 비트 발화)에 취약(StoryDirector + # RegionGateSatisfied가 게이트). requiredRegionId 오타는 런타임엔 조용히 미발화 → 여기서 잡는다. + # region_ids는 검사 3에서 계산한 것을 재사용(game_facts 변경 불필요). + bad_region = [] + unguarded = [] + for b in beats: + rr = b.get("requiredRegionId") or "" + t = b.get("trigger") or {} + ttype, param = t.get("type"), (t.get("param") or "") + if rr and rr not in region_ids: + bad_region.append(f"{b['beatId']}:requiredRegion({rr})") + paramless = ttype == "BattleWin" or (ttype == "CaptureInsect" and not param) + if paramless and not rr: + unguarded.append(b["beatId"]) + if bad_region: + judge7, val7 = "FAIL", f"{len(bad_region)}건 미존재 ({bad_region})" + elif unguarded: + judge7, val7 = "WARN", f"{len(unguarded)}건 무가드 권고 ({unguarded})" + else: + judge7, val7 = "PASS", "0건 (requiredRegion 실존·무param 전부 가드)" + signals.append(("requiredRegionId 정합 (리전 게이트)", "0건 미존재·무가드", val7, judge7)) + + # 8. 일생 1회 트리거의 스파인 사용 — GuardianDefeat는 leaf 전용이어야 한다. + # RegionManager.DefeatGuardian이 idempotent 가드로 리전당 정확히 1회만 이벤트를 쏜다. + # 그 순간 prereq가 미충족이면 그 비트는 영영 안 열리고, 뒤 비트가 그걸 prereq로 삼고 + # 있으면 캠페인이 거기서 영구 정지한다. QuestComplete와 같은 부류다. + # + # QuestComplete도 같은 부류다. 1막 ch1 체인(ch1_first_capture→…→ch1_guardian_call)이 + # 한때 그렇게 엮여 있어 튜토리얼을 마친 세이브는 라온 소개를 포함한 5비트를 영영 못 봤다. + # 2026-08-07에 전부 재발화 트리거(CaptureInsect/LevelReach/BattleWin + requiredRegionId)로 + # 옮겨 부채를 청산했다. 지금은 0건이며, 다시 늘면 측정값에 드러난다. + # FAIL로 승격하지 않는 이유: leaf로 쓰는 QuestComplete는 정상이고(놓쳐도 체인이 안 끊긴다), + # 이 검사는 "prereq로 쓰였는가"만 본다 — 그 조건이면 GuardianDefeat와 달리 즉시 위험하진 않다. + prereq_targets = {b.get("prerequisiteBeatId") for b in beats if b.get("prerequisiteBeatId")} + once_only = [] + grandfathered = 0 + for b in beats: + ttype = (b.get("trigger") or {}).get("type") + if b["beatId"] not in prereq_targets: + continue + if ttype == "GuardianDefeat": + once_only.append(f"{b['beatId']}({ttype})") + elif ttype == "QuestComplete": + grandfathered += 1 + note = f" / QuestComplete 기존 {grandfathered}건은 1막 유예" if grandfathered else "" + signals.append(( + "일생 1회 트리거의 스파인 사용 (GuardianDefeat leaf 강제)", + "0건", + (f"{len(once_only)}건 ({once_only})" if once_only else "0건") + note, + "FAIL" if once_only else "PASS", + )) + + # 9. cutsceneId 실재성 — 오타는 런타임에 LogWarning만 찍고 컷신이 조용히 안 나온다. + # 연출 누락은 화면상 티가 안 나서(원래 없던 것처럼 보인다) 배포까지 살아남기 쉽다. + # CutsceneLibrary의 const 문자열을 소스에서 읽어 대조한다 — C#에 사본을 만들면 + # 그쪽이 낡는다(이 저장소의 하드코딩 목록이 세 번 어긋난 것과 같은 이유). + lib_path = os.path.join( + os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), + "Assets", "Scripts", "Story", "CutsceneLibrary.cs") + try: + with open(lib_path, encoding="utf-8") as fh: + lib_src = fh.read() + except OSError as exc: + raise ExtractorBroken(f"CutsceneLibrary.cs를 읽지 못했다: {exc}") + + known_cutscenes = set(re.findall(r'public const string \w+\s*=\s*"([a-z_0-9]+)"', lib_src)) + if not known_cutscenes: + raise ExtractorBroken( + "CutsceneLibrary.cs에서 컷신 ID 상수를 하나도 찾지 못했다 — 추출기가 낡았다") + + # 상수로 선언만 하고 TryGet switch에 case가 없으면 역시 발화하지 않는다(같은 무증상 결함). + dispatched = set(re.findall(r'case (\w+):\s*shots\s*=', lib_src)) + declared_names = dict(re.findall( + r'public const string (\w+)\s*=\s*"([a-z_0-9]+)"', lib_src)) + undispatched = sorted(v for k, v in declared_names.items() if k not in dispatched) + + missing_cutscene = sorted( + f"{b['beatId']}→{b['cutsceneId']}" + for b in beats + if b.get("cutsceneId") and b["cutsceneId"] not in known_cutscenes) + + problems = missing_cutscene + [f"{c}(switch 미배선)" for c in undispatched] + used = sum(1 for b in beats if b.get("cutsceneId")) + signals.append(( + "cutsceneId 실재성 (JSON↔CutsceneLibrary)", + "0건 미존재", + f"{len(problems)}건 ({problems})" if problems + else f"0건 (사용 {used}건 / 정의 {len(known_cutscenes)}종)", + "FAIL" if problems else "PASS", + )) + + # 10. requiredQuestId 실재성 — 존재하지 않는 questId를 물면 게이트가 영영 안 열려 + # 그 비트와 뒤 체인 전체가 도달 불가가 된다. 런타임엔 조용히 false만 돌아온다. + # ch1_intro가 이걸 써서 튜토리얼과 스토리를 가르므로, 오타 하나면 캠페인이 시작되지 않는다. + known_quests = {q["questId"] for q in game_facts.quest_defs() if q.get("questId")} + if not known_quests: + raise ExtractorBroken("TutorialQuestManager에서 questId를 하나도 찾지 못했다 — 추출기가 낡았다") + + missing_quest = sorted( + f"{b['beatId']}→{b['requiredQuestId']}" + for b in beats + if b.get("requiredQuestId") and b["requiredQuestId"] not in known_quests) + + gated = sum(1 for b in beats if b.get("requiredQuestId")) + signals.append(( + "requiredQuestId 실재성 (퀘스트 게이트)", + "0건 미존재", + f"{len(missing_quest)}건 ({missing_quest})" if missing_quest + else f"0건 (게이트 {gated}건 / 퀘스트 {len(known_quests)}개)", + "FAIL" if missing_quest else "PASS", + )) + + # 11. 특정 곤충 목표(포획·전투)가 그 리전에서 실제로 만나지는가. + # CaptureInsect/BattleWin에 param을 주면 "그 종을 잡아야/이겨야" 발화한다. 그런데 + # requiredRegionId까지 걸려 있으면 **그 리전 풀에 그 종이 없을 때 영영 발화하지 않는다** — + # 런타임엔 아무 신호도 없고 플레이어는 "왜 다음 이야기가 안 나오지"만 겪는다. + # 1막 확장이 포획에서 이 형태를 5건 썼다. + # + # **BattleWin은 뒤늦게 종 지정이 생겼다.** 그전엔 `fin_seal`(엔딩)이 무param이라 + # 무명과 대면한 직후 그 리전에서 아무 야생 곤충이나 이기면 엔딩이 터졌다. 종을 달면서 + # 이 검사가 그쪽까지 봐야 한다 — 게다가 `fin_seal`은 **스파인**이라(ch12_clash의 prereq) + # 서식하지 않는 종을 물리면 캠페인이 그 자리에서 영구 정지한다. + pool_by_region = {rid: set(ids) for rid, _level, ids in game_facts.region_pools()} + # 서브에리어 전용 종도 그 리전에서 잡히므로 함께 센다. + region_src = game_facts._read("region_defs") + for m in re.finditer(r'regionId\s*=\s*"([a-z_]+)"(.*?)(?=regionId\s*=\s*"|\Z)', region_src, re.S): + rid, body = m.group(1), m.group(2) + if rid not in pool_by_region: + continue + for block in re.findall(r'exclusiveInsectIds\s*=\s*new\[\]\s*\{(.*?)\}', body, re.S): + pool_by_region[rid].update(re.findall(r'"([a-z_0-9]+)"', block)) + + SPECIES_TRIGGERS = ("CaptureInsect", "BattleWin") + unreachable_target = [] + for b in beats: + trig = b.get("trigger") or {} + if trig.get("type") not in SPECIES_TRIGGERS: + continue + species = trig.get("param") + region = b.get("requiredRegionId") + if not species or not region: + continue + if species not in pool_by_region.get(region, set()): + unreachable_target.append( + f"{b['beatId']}:{trig['type']} {species}@{region}") + + targeted = sum(1 for b in beats + if (b.get("trigger") or {}).get("type") in SPECIES_TRIGGERS + and (b.get("trigger") or {}).get("param")) + signals.append(( + "특정 곤충 목표의 서식 정합 (포획·전투)", + "0건 미서식", + f"{len(unreachable_target)}건 ({unreachable_target})" if unreachable_target + else f"0건 (종 지정 {targeted}건)", + "FAIL" if unreachable_target else "PASS", + )) + + # 12. stageEnterId/stageExitId 실재성 — 검사 9(cutsceneId)와 같은 무증상 결함이다. + # 오타면 런타임에 LogWarning만 찍고 NPC가 그냥 안 움직인다. 특히 stageEnterId는 + # "라온이 뛰어 들어온 다음 말한다"의 순서를 만드는 장치라, 빠지면 지도 반대편에 있는 + # 인물의 대사만 허공에서 뜬다 — 화면이 조용해서 배포까지 살아남는다. + stage_path = os.path.join( + os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), + "Assets", "Scripts", "Story", "StoryStageLibrary.cs") + try: + with open(stage_path, encoding="utf-8") as fh: + stage_src = fh.read() + except OSError as exc: + raise ExtractorBroken(f"StoryStageLibrary.cs를 읽지 못했다: {exc}") + + known_stages = set(re.findall(r'public const string \w+\s*=\s*"([a-z_0-9]+)"', stage_src)) + if not known_stages: + raise ExtractorBroken( + "StoryStageLibrary.cs에서 연출 ID 상수를 하나도 찾지 못했다 — 추출기가 낡았다") + + # 상수만 선언하고 TryGet switch에 case가 없으면 역시 발화하지 않는다(같은 무증상 결함). + stage_dispatched = set(re.findall(r'case (\w+):\s*steps\s*=', stage_src)) + stage_declared = dict(re.findall( + r'public const string (\w+)\s*=\s*"([a-z_0-9]+)"', stage_src)) + stage_undispatched = sorted(v for k, v in stage_declared.items() if k not in stage_dispatched) + + missing_stage = sorted( + f"{b['beatId']}→{b[field]}" + for b in beats + for field in ("stageEnterId", "stageExitId") + if b.get(field) and b[field] not in known_stages) + + # 대사 없는 비트의 stageEnterId는 영영 안 돈다 — 모달이 안 뜨므로 게이트가 걸리지 않는다. + enter_without_lines = sorted( + b["beatId"] for b in beats + if b.get("stageEnterId") and not b.get("lines")) + + stage_problems = (missing_stage + + [f"{s}(switch 미배선)" for s in stage_undispatched] + + [f"{b}(대사 없음)" for b in enter_without_lines]) + stage_used = sum(1 for b in beats if b.get("stageEnterId") or b.get("stageExitId")) + signals.append(( + "stageId 실재성 (JSON↔StoryStageLibrary)", + "0건 미존재", + f"{len(stage_problems)}건 ({stage_problems})" if stage_problems + else f"0건 (사용 {stage_used}건 / 정의 {len(known_stages)}종)", + "FAIL" if stage_problems else "PASS", + )) + + # 13. stageExitId와 cutsceneId 동시 사용 금지 — 둘 다 StoryBeatCompleted를 구독해 + # 조작(SetFrozen)과 모달 스택을 뺏는다. 함께 걸면 서로의 복구를 덮어써서 + # 조작이 안 돌아오거나 카메라가 컷신 마지막 구도로 굳는다. + # 런타임에도 StoryStageDirector가 컷신에 양보하지만, 저작 단계에서 막는 편이 낫다. + both_slots = sorted( + b["beatId"] for b in beats + if b.get("stageExitId") and b.get("cutsceneId")) + signals.append(( + "stageExitId ↔ cutsceneId 배타", + "0건 동시 사용", + f"{len(both_slots)}건 ({both_slots})" if both_slots else "0건", + "FAIL" if both_slots else "PASS", + )) + + # 14. Story.json의 키가 StoryBeat.cs에 실재하는가. + # **JsonUtility는 모르는 키를 조용히 무시한다** — 그래서 오타 하나("stageEnterID")나 + # 이미 없어진 필드("oneShot")를 저작하면 아무 경고 없이 그 저작이 통째로 증발한다. + # 화면에는 "연출이 원래 없는 것"처럼 보여서 배포까지 살아남는다. + # 실제로 `oneShot`이 읽는 코드 0인 채 82비트에 남아 있었다(2026-08-17 audit). + # + # 필드 목록은 소스에서 읽는다 — 여기에 사본을 만들면 그쪽이 늘 때 이 검사가 낡는다. + beat_path = os.path.join( + os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), + "Assets", "Scripts", "Story", "StoryBeat.cs") + try: + with open(beat_path, encoding="utf-8") as fh: + beat_src = fh.read() + except OSError as exc: + raise ExtractorBroken(f"StoryBeat.cs를 읽지 못했다: {exc}") + + # 클래스별 {필드명: 타입}. List는 원소 타입 T로 눕혀 중첩 검사에 그대로 쓴다. + class_fields = {} + parts = re.split(r"public class (\w+)", beat_src) + for i in range(1, len(parts) - 1, 2): + cls_name, body = parts[i], parts[i + 1] + fields = {} + for fm in re.finditer(r"^\s*public\s+([\w\.<>\[\]]+)\s+(\w+)\s*(?:=[^;]*)?;", body, re.M): + ftype, fname = fm.group(1), fm.group(2) + elem = re.match(r"List<(\w+)>$", ftype) + fields[fname] = elem.group(1) if elem else ftype + class_fields[cls_name] = fields + + if not class_fields.get("StoryBeat"): + raise ExtractorBroken("StoryBeat.cs에서 StoryBeat의 public 필드를 찾지 못했다 — 추출기가 낡았다") + + def _walk_keys(node, cls_name, path, out): + known = class_fields.get(cls_name) + if known is None: + return # string/int/bool 등 원시 타입 — 더 내려갈 곳이 없다 + for key, value in node.items(): + if key not in known: + out.append(f"{path}.{key}") + continue + child = known[key] + if isinstance(value, dict): + _walk_keys(value, child, f"{path}.{key}", out) + elif isinstance(value, list): + for idx, item in enumerate(value): + if isinstance(item, dict): + _walk_keys(item, child, f"{path}.{key}[{idx}]", out) + + orphan_keys = [] + for b in beats: + _walk_keys(b, "StoryBeat", b.get("beatId") or "(무명)", orphan_keys) + + signals.append(( + "Story.json 키 실재성 (JSON↔StoryBeat 필드)", + "0건 미매핑", + f"{len(orphan_keys)}건 ({orphan_keys[:8]})" if orphan_keys + else f"0건 (비트 {len(beats)}개 / 필드 {len(class_fields['StoryBeat'])}종)", + "FAIL" if orphan_keys else "PASS", + )) + + # 15. requiredBeatId 실재성 + 자기참조 + (prereq와 섞인) 순환. + # 진행 게이트는 여운(echo) 비트의 조기 발화를 막으려고 들어왔다 — 그것들은 "같은 NPC의 + # 직전 여운"만 prereq로 물고 있어서, 시작 지역인 초원에서 라온에게 세 번 말하면 12장 + # 복귀 대사가, 숲에서 세라에게 네 번 말하면 엔딩 에필로그 전문이 보상까지 딸려 나왔다. + # 오타 난 게이트는 런타임에 조용히 "영영 안 열림"이 되므로 여기서 잡는다. + gate_broken = [] + for b in beats: + gate = b.get("requiredBeatId") or None + if not gate: + continue + if gate not in idset: + gate_broken.append(f"{b['beatId']}→{gate}(없음)") + if gate == b["beatId"]: + gate_broken.append(f"{b['beatId']} 자기참조") + gate_cycle = _beat_gate_cycle(beats) + if gate_cycle: + gate_broken.append(f"순환({gate_cycle})") + gated = sum(1 for b in beats if b.get("requiredBeatId")) + signals.append(( + "requiredBeatId 무결성 (진행 게이트)", + "0건", + f"{len(gate_broken)}건 ({gate_broken})" if gate_broken + else f"0건 (게이트 {gated}건)", + "FAIL" if gate_broken else "PASS", + )) + + # 16. 게이트 대상의 재발화성 — 검사 8이 스파인(prereq)에 대해 하는 판정을 게이트에 대해 한다. + # GuardianDefeat는 리전당 1회(RegionManager.DefeatGuardian의 idempotent 가드), + # QuestComplete는 퀘스트당 1회다. 그 순간을 놓친 세이브는 게이트가 영영 안 열려 + # 그 비트가 영구 정지한다 — 스파인과 달리 여기는 유예 없이 FAIL이다(새 필드라 + # 물려받은 부채가 없다. 지금 막지 않으면 그대로 부채가 된다). + once_only_triggers = ("GuardianDefeat", "QuestComplete") + beat_by_id = {b["beatId"]: b for b in beats} + bad_gate = [] + for b in beats: + gate = b.get("requiredBeatId") or None + if not gate or gate not in beat_by_id: + continue + gate_trigger = (beat_by_id[gate].get("trigger") or {}).get("type") + if gate_trigger in once_only_triggers: + bad_gate.append(f"{b['beatId']}→{gate}({gate_trigger})") + signals.append(( + "게이트 대상의 재발화성 (requiredBeatId → 일생 1회 트리거 금지)", + "0건", + f"{len(bad_gate)}건 ({bad_gate})" if bad_gate + else f"0건 (게이트 {gated}건)", + "FAIL" if bad_gate else "PASS", + )) + + # 17. 챕터 도달 순서 — N장 비트가 N-1장까지의 진행만으로 열려서는 안 된다. + # + # **이번 결함의 재발 방지기다.** 여운(echo) 비트가 "같은 NPC의 직전 여운"만 prereq로 + # 물고 있어서 진행과 무관하게 발화했다: 시작 지역인 초원에서 라온에게 세 번 말하면 + # 12장 복귀 대사가, 어르신에게 세 번이면 11장의 최대 반전이, 숲에서 세라에게 네 번이면 + # 엔딩 에필로그 전문이 캔디 120 + XP 250과 함께 나왔다. 7건 전부 이 검사에 걸린다. + # + # 선행 최대 챕터가 자기 챕터 **-1** 이상이면 통과다. -1인 것은 도착 비트(chN_arrive)가 + # 바로 그 챕터를 여는 비트라 선행이 N-1장일 수밖에 없기 때문이다. + # side/npc 챕터는 본편 진행 축이 아니므로 대상에서 뺀다(앰비언트는 언제 봐도 된다). + beat_index = {b["beatId"]: b for b in beats} + out_of_order = [] + for b in beats: + ordinal = _chapter_ordinal(b.get("chapterId")) + if ordinal is None: + continue + reached = _max_ancestor_chapter(b["beatId"], beat_index) + if reached < ordinal - 1: + out_of_order.append(f"{b['beatId']}({b['chapterId']}, 선행 최대 ch{reached})") + main_beats = sum(1 for b in beats if _chapter_ordinal(b.get("chapterId")) is not None) + signals.append(( + "챕터 도달 순서 (뒷 챕터 비트의 조기 발화)", + "0건", + f"{len(out_of_order)}건 ({out_of_order})" if out_of_order + else f"0건 (본편 비트 {main_beats}개)", + "FAIL" if out_of_order else "PASS", + )) + + # 18. 캠페인 시작 시 동시에 자격을 갖는 비트 — 1개를 넘지 않아야 한다. + # + # 넘으면 무엇이 먼저 뜰지가 저작이 아니라 우선순위 표(스파인→챕터→order→id)에 맡겨진다. + # 실제로 그렇게 어긋났다: 개막 `ch1_intro`는 `requiredQuestId: q_move`로 잠겨 있는데 + # 앰비언트 `talk_elder`/`talk_rival`/`talk_scholar`는 아무 게이트가 없어, **튜토리얼 중 + # HUD 목표가 "마을 어르신에게 말 걸기"로 잡히고 첫 목표 자동 주행까지 태워 보냈다.** + # 도착해서 말을 걸면 개막이 아니라 잡담이 뜨고 그게 소비됐다. + # + # 앰비언트 비트에 `requiredBeatId: ch1_intro`를 걸어 0건으로 만들었다. 0도 정상이다 — + # 그동안은 튜토리얼 퀘스트 칩이 안내를 맡는다. + unlocked_at_start = [ + b["beatId"] for b in beats + if not (b.get("prerequisiteBeatId") or None) + and not (b.get("requiredBeatId") or None) + and not (b.get("requiredQuestId") or None) + ] + signals.append(( + "캠페인 시작 시 동시 자격 비트", + "1개 이하", + f"{len(unlocked_at_start)}개 ({unlocked_at_start})" if len(unlocked_at_start) > 1 + else f"{len(unlocked_at_start)}개", + "FAIL" if len(unlocked_at_start) > 1 else "PASS", + )) + + # 19. 명부회 간부 보스전 상대는 **소개 비트**를 가져야 한다. + # + # WorldInteractionController가 도전을 여는 조건이 "그 인물과 이야기를 나눈 적이 있는가" + # (StoryObjectiveResolver.HasMetNpc)로 바뀌었다. 예전엔 "이번 대화에서 비트가 안 떴다"만 + # 봐서, 집게·저울·관장처럼 소개가 서브에리어 대치 비트에 걸린 인물은 **리전에 도착해 + # 본진에서 말만 걸면 이름도 모르는 채 보스전이 시작됐다**(최종 보스 포함). + # + # 그 대가로 새 잠금 위험이 생겼다: 표에 보스를 추가하면서 스토리 비트를 안 만들면 + # `HasMetNpc`가 영영 false라 **그 보스와 싸울 수 없다**. 런타임엔 아무 로그도 안 나온다. + duel_path = os.path.join( + os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), + "Assets", "Scripts", "NPC", "NpcBossDuels.cs") + try: + with open(duel_path, encoding="utf-8") as fh: + duel_src = fh.read() + except OSError as exc: + raise ExtractorBroken(f"NpcBossDuels.cs를 읽지 못했다: {exc}") + + duel_npcs = re.findall(r'storyNpcId\s*=\s*"([a-z_0-9]+)"', duel_src) + if not duel_npcs: + raise ExtractorBroken( + "NpcBossDuels.cs에서 보스 storyNpcId를 하나도 찾지 못했다 — 추출기가 낡았다") + + introduced = set() + for b in beats: + if b.get("speakerNpcId"): + introduced.add(b["speakerNpcId"]) + trig = b.get("trigger") or {} + if trig.get("type") == "NpcTalk" and trig.get("param"): + introduced.add(trig["param"]) + + unintroduced = sorted(set(duel_npcs) - introduced) + signals.append(( + "보스 대결 상대의 소개 비트 (없으면 도전 불가)", + "0건 미소개", + f"{len(unintroduced)}건 ({unintroduced})" if unintroduced + else f"0건 (보스 {len(set(duel_npcs))}명)", + "FAIL" if unintroduced else "PASS", + )) + + # 20. 월드에 배치된 스토리 NPC는 **전용 앰비언트 대사**를 가져야 한다. + # + # 비트가 없을 때(아직 차례가 아니거나 전부 본 뒤) NpcDialogueUI는 마을 주민 풀로 + # 떨어진다. 그래서 명부회 간부가 "산책하기 딱 좋은 날씨예요"를 말했다 — 최종 보스인 + # 관장까지. 예외도 경고도 없고 그럴듯한 대사가 나오므로 눈으로만 잡힌다. + # + # 인물을 새로 배치할 때 `StoryNpcLines`에 두 줄 넣는 걸 빠뜨리기 쉬워서 여기서 잡는다. + world_npcs = game_facts.story_npc_ids() + ambient_npcs = game_facts.story_npc_ambient_ids() + voiceless = sorted(world_npcs - ambient_npcs) + orphan_lines = sorted(ambient_npcs - world_npcs) + + npc_problems = ([f"{n}(앰비언트 없음)" for n in voiceless] + + [f"{n}(월드에 없는 인물)" for n in orphan_lines]) + signals.append(( + "스토리 인물 앰비언트 대사 (없으면 주민 잡담으로 떨어짐)", + "0건 누락", + f"{len(npc_problems)}건 ({npc_problems})" if npc_problems + else f"0건 (스토리 인물 {len(world_npcs)}명 전원 전용 대사)", + "FAIL" if npc_problems else "PASS", + )) + + # 21. 서브에리어 연출 좌표가 **방 안**인가. + # + # 서브에리어는 벽으로 봉해진 축정렬 정사각 방이고(CreateBoundaryWalls), 진입하면 + # 플레이어가 남쪽 입구에 고정 배치된다(FindSafeSpawnPosition). 연출 오프셋도 월드축이라 + # 워프 지점의 방 로컬 좌표는 (offset.x, offset.z + 입구z)로 **저작 시점에 계산된다**. + # + # 그 점이 벽 밖이면 배우가 벽 뒤에 떨어지고, 걸어 들어오려다 IsBlockedAhead에 막힌다. + # Scripted 이동은 막혀도 포기하지 않으므로 8초를 밀다 타임아웃으로 "완료" 처리되고, + # SnapToFinalPose는 지나간 이동 스텝을 소급하지 않는다 → **대사만 뜨고 화자는 화면에 + # 없다.** 예외도 경고도 없다. + # + # 1막의 "뒤쪽 9m" 관용구(벽 없는 메인 필드에서 온 값)를 방에 옮겨 심으면 바로 이 함정이다. + # 2026-08-23에 ch7(남벽 밖 1m)·ch12(남벽 밖 6m, 바닥 밖)·ch11(벽 안쪽 면에 낌)이 그랬다. + stage_offsets = game_facts.stage_offsets() + room_bounds = game_facts.subarea_room_bounds() + entry_x, entry_z = game_facts.subarea_entry_offset() + # 배우 캡슐 반경 + 벽 두께(1m의 절반) — 벽에 닿는 자리도 낀 것으로 본다. + ROOM_MARGIN = 1.0 + + outside_room = [] + for b in beats: + trigger = b.get("trigger") or {} + if trigger.get("type") != "SubAreaEnter": + continue + sub_id = trigger.get("param") + half = room_bounds.get(sub_id) + if half is None: + continue + for field in ("stageEnterId", "stageExitId"): + stage_id = b.get(field) + for action, ox, _oy, oz in stage_offsets.get(stage_id) or []: + lx, lz = entry_x + ox, entry_z + oz + if max(abs(lx), abs(lz)) <= half - ROOM_MARGIN: + continue + outside_room.append( + f"{stage_id}:{action}({lx:g},{lz:g}) {sub_id} 벽±{half:g}") + + signals.append(( + "서브에리어 연출 좌표 (방 밖이면 배우가 벽에 막혀 안 나온다)", + "0건 벽 밖", + f"{len(outside_room)}건 ({outside_room})" if outside_room + else f"0건 (방 {len(room_bounds)}개 · 입구 z={entry_z:g})", + "FAIL" if outside_room else "PASS", + )) + + # 22. 스토리 NPC가 **표시명·외형 switch에 등록돼 있는가.** + # + # 검사 20이 앰비언트 대사를 보듯, 이건 그 인물의 **이름과 얼굴**을 본다. + # 두 switch 모두 `default`가 마을 어르신이라 빠뜨려도 예외·경고가 없다 — + # 검은 옷의 하수가 "마을 어르신"이라는 이름표를 달고 백발에 모자를 쓰고 서 있어도 + # 게임은 아무 말도 하지 않는다. 눈으로만 잡히던 자리다. + # + # 신규 스토리 NPC는 여섯 곳에 등록해야 하고(StoryBible 7-4) 나머지 넷은 + # 이미 검사 중이었다: 앵커(검사 3)·앰비언트(검사 20)·소개 비트(검사 19)· + # 대결 표(blight_lint 검사 1). 남은 둘이 여기다. + # + # village_elder는 두 switch의 `default` 가지 그 자체라 case가 없는 게 정상이다. + DEFAULT_NPC = "village_elder" + display_ids = game_facts.story_npc_display_ids() + appearance_ids = game_facts.story_npc_appearance_ids() + need = world_npcs - {DEFAULT_NPC} + + switch_problems = ([f"{n}(표시명 없음 → \"마을 어르신\"으로 뜬다)" + for n in sorted(need - display_ids)] + + [f"{n}(외형 없음 → 마을 어르신 얼굴로 뜬다)" + for n in sorted(need - appearance_ids)] + + [f"{n}(월드에 없는 인물)" + for n in sorted((display_ids | appearance_ids) - world_npcs)]) + signals.append(( + "스토리 인물 표시명·외형 switch (없으면 마을 어르신으로 뜬다)", + "0건 누락", + f"{len(switch_problems)}건 ({switch_problems})" if switch_problems + else f"0건 (인물 {len(need)}명 × 2 switch, default={DEFAULT_NPC})", + "FAIL" if switch_problems else "PASS", + )) + + # 23. **레이드 승리가 `BattleWin`을 흘리는가** — Epic·Legendary는 그 길밖에 없다. + # + # `CaptureChoiceUI.IsRaidTarget`은 등급만 본다: Epic·Legendary면 **포획도 1v1도 막고** + # 레이드만 연다. 그런데 레이드 승리는 `AddCapturedInsect`(→ CaptureInsect 트리거)만 + # 흘리고 `InsectBattleController.BattleEnded`는 울리지 않는다. 그래서 오랫동안 + # **CaptureInsect 비트는 그 등급에 도달하는데 BattleWin 비트만 도달하지 못했다.** + # + # 최종장 `fin_seal`이 정확히 그 함정에 빠졌다 — `BattleWin mantis_unnamed`인데 + # 그 종이 Legendary라 1v1로 만날 방법이 아예 없었고, 게다가 스파인이라 + # (`ch12_clash`의 prereq · `fin_epilogue`의 게이트) 캠페인이 엔딩 직전에서 영구 정지했다. + # 예외도 경고도 안 난다. 배치 걸음조차 1v1을 **시뮬레이션**해서 통과시켰다. + # + # 두 지점 다 있어야 한다 — 구독(StoryDirector)과 주입(Bootstrap). 하나만 빠져도 무증상이다. + raid_wiring = [] + try: + director_src = game_facts._read("story_director") + except Exception: + director_src = _read_repo("Assets/Scripts/Story/StoryDirector.cs") + boot_src = _read_repo("Assets/Scripts/Core/PlaySceneBootstrap.cs") + + if "RaidEnded += " not in director_src: + raid_wiring.append("StoryDirector가 RaidEnded를 구독하지 않는다") + if "RaidEnded -= " not in director_src: + raid_wiring.append("StoryDirector가 RaidEnded를 해제하지 않는다") + if not re.search(r"storyDirector\.AutoWire\(\s*raidController\s*\)", boot_src): + raid_wiring.append("Bootstrap이 storyDirector.AutoWire(raidController)를 안 부른다") + + # 종을 지정한 BattleWin 비트 중 Epic·Legendary를 문 것 — 배선이 끊기면 그것들이 먼저 죽는다. + at_risk = sorted( + b["beatId"] for b in beats + if (b.get("trigger") or {}).get("type") == "BattleWin" + and (b.get("trigger") or {}).get("param")) + signals.append(( + "레이드 승리 → BattleWin 배선 (Epic·Legendary는 1v1이 없다)", + "0건 누락", + f"{len(raid_wiring)}건 ({raid_wiring})" if raid_wiring + else f"0건 (구독·해제·주입 3지점 · 종 지정 BattleWin {len(at_risk)}건: {at_risk})", + "FAIL" if raid_wiring else "PASS", + )) + + # 24. 선택지 무결 — 선택은 **표현이지 분기가 아니다**(StoryBible 6장 「선택지 규칙」). + # 결과 비트(choices[].nextBeatId)는 플레이어가 고른 순간 `QueueChoice`가 + # `Immediate `로 큐 맨 앞에 거는 방식이라, 아래 넷 중 하나라도 어긋나면 무증상으로 죽는다: + # ① 트리거가 Immediate가 아니면 그 param 경로로 영영 안 뜬다. + # ② 어떤 비트의 prereq/requiredBeatId면 안 고른 세이브의 캠페인이 거기서 영구 정지한다(leaf 강제). + # ③ prereq가 선택지를 단 비트가 아니면 시작 시 일괄 Immediate 평가에서 새어 나갈 수 있다. + # ④ 보상이 붙으면 어느 쪽을 고르느냐로 경제가 갈린다(표현이므로 보상 0). + # 선택지를 단 비트 자신은 lines ≥ 1이어야 한다(대사 없는 비트는 즉시 완료라 버튼이 안 뜬다). + by_id = {b["beatId"]: b for b in beats} + prereq_targets = {b.get("prerequisiteBeatId") for b in beats if b.get("prerequisiteBeatId")} + gate_targets = {b.get("requiredBeatId") for b in beats if b.get("requiredBeatId")} + choice_bad = [] + choice_points = 0 + choice_results = 0 + for b in beats: + choices = b.get("choices") or [] + if not choices: + continue + choice_points += 1 + if not (b.get("lines") or []): + choice_bad.append(f"{b['beatId']}: 선택지가 있는데 lines가 비어 있다(버튼이 안 뜬다)") + if len(choices) < 2: + choice_bad.append(f"{b['beatId']}: 선택지가 1개다(고를 것이 없다)") + for c in choices: + nxt = c.get("nextBeatId") or "" + if not (c.get("text") or "").strip(): + choice_bad.append(f"{b['beatId']}: 선택지 문구가 비어 있다") + r = by_id.get(nxt) + if r is None: + continue # 존재는 검사 4가 잡는다 + choice_results += 1 + trig = (r.get("trigger") or {}).get("type") + if trig != "Immediate": + choice_bad.append(f"{nxt}: 선택 결과인데 trigger.type={trig} (Immediate여야 한다)") + if nxt in prereq_targets or nxt in gate_targets: + choice_bad.append(f"{nxt}: 선택 결과가 스파인/게이트 대상이다(안 고른 세이브가 영구 정지)") + if r.get("prerequisiteBeatId") != b["beatId"]: + choice_bad.append(f"{nxt}: prereq가 선택지를 단 비트({b['beatId']})가 아니다") + rw = r.get("onComplete") or {} + if any(rw.get(k) for k in ("rewardCandy", "rewardExp", "rewardItemId", "rewardInsectId")): + choice_bad.append(f"{nxt}: 선택 결과에 보상이 붙어 있다(표현이지 분기가 아니다 — 0이어야)") + signals.append(( + "선택지 무결 (결과=Immediate leaf · prereq=선택 비트 · 보상 0)", + "0건", + f"{len(choice_bad)}건 ({choice_bad})" if choice_bad + else f"0건 (선택점 {choice_points} · 결과 {choice_results})", + "FAIL" if choice_bad else "PASS", + )) + + return signals + + +def _read_repo(rel_path: str) -> str: + """저장소 루트 기준 파일 읽기 — game_facts에 별칭이 없는 파일용.""" + root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + full = os.path.join(root, rel_path.replace("/", os.sep)) + try: + with open(full, encoding="utf-8") as fh: + return fh.read() + except OSError as exc: + raise ExtractorBroken(f"{rel_path}를 읽지 못했다: {exc}") + + +def render(signals) -> str: + out = ["| 항목 | 임계값 | 측정값 | 판정 |", "|------|--------|--------|------|"] + for name, thr, val, judge in signals: + out.append(f"| {name} | {thr} | {val} | **{judge}** |") + fail = sum(1 for s in signals if s[3] == "FAIL") + warn = sum(1 for s in signals if s[3] == "WARN") + passn = sum(1 for s in signals if s[3] == "PASS") + out.append("") + out.append(f"요약: **{fail} FAIL** / {warn} WARN / {passn} PASS") + if fail: + out.append("→ FAIL 1건 이상. PASS 보고 금지. 즉시 정정 필요.") + return "\n".join(out) + + +def main(): + print("# story-lint — 곤충게임 스토리 정합성 검증\n") + print("## 위험 신호 표") + signals = evaluate_signals() + print(render(signals)) + print() + print("## 가정 / 한계") + print("- 스토리 비트는 Assets/Resources/Story.json에서 json.load로 읽는다(정규식 아님).") + print(" 지연 발화(DeferTrigger)도 배선으로 세되, 큐를 흘리는 지점이 있을 때만 센다.") + print("- 검사 20은 VillageBuilder의 storyNpcId(주석 제외)와 NpcDialogueDatabase.StoryNpcLines") + print(" 키를 양방향 대조한다. 한쪽에만 있으면 그 인물이 주민 잡담을 하거나 사문화된 대사다.") + print("- 트리거 배선(검사 6)은 StoryDirector의 Trigger 상수·switch case·EvaluateTriggers") + print(" 발화 지점을 교차검사. 새 trigger.type의 배선 누락(영구 미발화)을 잡는다.") + print("- SubAreaEnter param은 리전 밖 서브에리어 ID일 수 있어 완화(미존재만 잡지 않음).") + print("- 진행 게이트(검사 15·16)는 requiredBeatId를 본다. prerequisiteBeatId가 체인의") + print(" '순서'라면 이쪽은 '단계'이고 둘은 AND다 — 발화(StoryDirector.BeatGateSatisfied)와") + print(" 목표 도출(StoryObjectiveResolver.SelectObjectiveBeat) 양쪽에 같은 게이트가 걸려 있다.") + print("- 챕터 도달 순서(검사 17)는 prereq∪requiredBeatId를 거슬러 올라가 만나는 본편 챕터") + print(" 최대치를 본다. side/npc 챕터 비트는 진행 축이 아니라 대상에서 뺀다.") + print("- 검사 18은 세이브 0(진행·퀘스트 모두 비어 있음)에서 자격을 갖는 비트를 센다.") + print(" 2개 이상이면 개막 순서가 저작이 아니라 우선순위 표에 맡겨진다.") + print("- 검사 21은 SubAreaWorldBuilder의 CreateBoundaryWalls 크기와 FindSafeSpawnPosition의") + print(" 입구 좌표를 읽어 연출 워프 지점이 방 안인지 본다. 방은 축정렬 정사각이고 오프셋도") + print(" 월드축이라 좌표가 결정적이다 — 벽 밖이면 배우가 막혀 대사만 뜬다(무증상).") + print("- 검사 22는 NpcManager.StoryNpcDisplayName / NpcVisualBuilder.StoryNpcAppearance의") + print(" case를 읽어 월드 배치 인물과 대조한다. 둘 다 default가 마을 어르신이라") + print(" 누락이 무증상이다 — village_elder만 그 default 가지라서 면제한다.") + print("- 검사 19는 NpcBossDuels.cs의 storyNpcId를 정규식으로 읽어 Story.json의") + print(" speakerNpcId ∪ NpcTalk param과 대조한다(소개 없는 보스 = 영구 도전 불가).") + fail = sum(1 for s in signals if s[3] == "FAIL") + return 1 if fail else 0 + + +if __name__ == "__main__": + try: + sys.exit(main()) + except ExtractorBroken as e: + print(f"\n## 추출기 고장\n\n**{e}**\n") + print("스토리 데이터 결함이 아니라 이 스크립트가 코드/JSON을 못 따라간 것이다.") + print("검증 결과는 신뢰할 수 없다 — 추출기를 먼저 고칠 것.") + sys.exit(2) diff --git a/.claude/scripts/subscription_lint.py b/.claude/scripts/subscription_lint.py new file mode 100644 index 0000000..16acb61 --- /dev/null +++ b/.claude/scripts/subscription_lint.py @@ -0,0 +1,115 @@ +"""subscription-lint — UI 루트 토글로 소실되는 이벤트 구독 검출. + +`OpeningReplayCoordinator`가 오프닝 다시보기 중 `playUiRoot.SetActive(false/true)`로 +UI 루트를 통째로 껐다 켠다. 그래서 UI 루트 아래 컴포넌트가 `OnDisable`에서 `-=`로 +해지한 구독을 `OnEnable`에서 되살리지 않으면, **다시보기 한 번에 그 기능이 영구히 죽는다.** + +실제로 세 번 났다: + - HospitalUI : InsectUpdated 해지만 있고 재구독 없음 (2026-07-19 audit, 후속 수정) + - BattleScreenUI : OnEnable이 빈 메서드 → 다시보기 후 **배틀 화면이 안 열림** (2026-08-03) + - RaidBattleUI : 같은 형태 (2026-08-03) + - RegionMapUI : OnEnable 자체가 없음 → 레이드 보스 마커 소실 (2026-08-03) + +검사 대상은 `PlaySceneBootstrap`이 `EnsureComponent("UI/...")`로 만드는 컴포넌트다 — +그것들이 UI 루트의 자식이라 토글에 휘말린다. + +exit 0 = 통과, 1 = 위반, 2 = 파싱 실패(구조가 바뀌었으니 파서부터 확인할 것). +""" +import glob +import io +import os +import re +import sys + +ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +BOOTSTRAP = os.path.join(ROOT, "Assets/Scripts/Core/PlaySceneBootstrap.cs") + +# 해지해도 무해한 이벤트 — 재구독이 없어도 기능이 죽지 않는 경우만 여기 적는다. +# 늘릴 때는 "왜 무해한가"를 반드시 함께 남길 것. +EXEMPT = { + # (클래스, "대상.핸들러") +} + + +def method_body(src, name): + """void () { ... } 본문을 중괄호 깊이로 떠낸다. 없으면 None.""" + m = re.search(r"\bvoid\s+" + name + r"\s*\(\s*\)\s*\{", src) + if not m: + return None + depth, i = 1, m.end() + while i < len(src) and depth: + if src[i] == "{": + depth += 1 + elif src[i] == "}": + depth -= 1 + i += 1 + return src[m.end():i] + + +def ui_root_components(): + """Bootstrap이 "UI/..." 경로로 만드는 컴포넌트 이름 집합.""" + if not os.path.exists(BOOTSTRAP): + print(f"ERROR: {BOOTSTRAP} 없음 — 경로가 바뀌었는지 확인", file=sys.stderr) + sys.exit(2) + src = io.open(BOOTSTRAP, encoding="utf-8").read() + names = {m.group(1).split(".")[-1] + for m in re.finditer(r'EnsureComponent<([\w\.]+)>\("UI/', src)} + if not names: + print("ERROR: UI 루트 컴포넌트를 하나도 못 찾음 — 정규식이 낡았다", file=sys.stderr) + sys.exit(2) + return names + + +def main(): + targets = ui_root_components() + violations = [] + + for path in glob.glob(os.path.join(ROOT, "Assets/Scripts/**/*.cs"), recursive=True): + cls = os.path.basename(path)[:-3] + if cls not in targets: + continue + + src = io.open(path, encoding="utf-8").read() + # 주석 제거 — 주석 처리된 `+=`를 재구독으로 오인하면 검사 자체가 무의미해진다. + src = re.sub(r"//.*", "", src) + + disable = method_body(src, "OnDisable") + if not disable: + continue + unsubscribed = set(re.findall(r"(\w+)\s*-=\s*(\w+)", disable)) + if not unsubscribed: + continue + + # OnEnable 본문 + 그 안에서 인자 없이 부르는 메서드 본문까지 훑는다 + # (Subscribe() 같은 헬퍼로 빼는 게 이 저장소의 관례라 한 겹 따라간다). + enable = method_body(src, "OnEnable") or "" + extra = "" + for called in re.findall(r"\b(\w+)\s*\(\s*\)\s*;", enable): + body = method_body(src, called) + if body: + extra += body + subscribed = set(re.findall(r"(\w+)\s*\+=\s*(\w+)", enable + extra)) + + for owner, handler in sorted(unsubscribed - subscribed): + if (cls, f"{owner}.{handler}") in EXEMPT: + continue + violations.append((cls, owner, handler, "OnEnable" if enable else "OnEnable 없음")) + + print("# subscription-lint — UI 루트 토글 시 구독 소실 검사\n") + print(f"검사 대상: UI 루트 하위 컴포넌트 {len(targets)}개") + if not violations: + print("\n결과: **PASS** (위반 0건)") + return 0 + + print(f"\n결과: **FAIL** — {len(violations)}건\n") + print("| 클래스 | 해지했으나 재구독 없음 | 상태 |") + print("|---|---|---|") + for cls, owner, handler, state in violations: + print(f"| {cls} | `{owner} -= {handler}` | {state} |") + print("\n고치는 법: 구독을 `Subscribe___()` 메서드로 빼고 AutoWire와 OnEnable이 함께 부른다.") + print("`-=` 뒤 `+=` 형태면 중복 구독이 되지 않는다.") + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.claude/scripts/sync_codex.py b/.claude/scripts/sync_codex.py new file mode 100644 index 0000000..cb00f48 --- /dev/null +++ b/.claude/scripts/sync_codex.py @@ -0,0 +1,183 @@ +"""Codex 미러(.codex/, AGENTS.md)를 .claude/ 원본과 동기화한다. + +배경: AGENTS.md는 CLAUDE.md의 사본이고 .codex/hooks/*.py는 .claude/hooks/*.py의 +사본이다. 손으로 유지하다 보니 반드시 어긋났다 — 실측 시점에 훅 7개 중 6개가 +드리프트했고, AGENTS.md는 CLAUDE.md의 경로를 `.Codex/`로 바꿔 적었는데 그런 +디렉토리는 존재하지도 않았다(정작 .codex/hooks는 `.claude/audit-progress.md`를 +읽는다). 그래서 변환하지 않고 그대로 복사한다 — .claude/가 단일 출처. + +사용법: + python -X utf8 .claude/scripts/sync_codex.py --check # 드리프트만 보고, 있으면 exit 1 + python -X utf8 .claude/scripts/sync_codex.py --write # 동기화 수행 + python -X utf8 .claude/scripts/sync_codex.py --hook # PostToolUse 훅 모드 (stdin JSON) +""" + +import io +import json +import os +import shutil +import sys +from glob import glob + +try: + sys.stdin = io.TextIOWrapper(sys.stdin.buffer, encoding="utf-8", errors="replace") + sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace") +except Exception: + pass + +ROOT = os.environ.get("CLAUDE_PROJECT_DIR") or os.getcwd() + + +def pairs(): + """(원본, 미러) 목록. 전부 무변환 복사 대상.""" + out = [(os.path.join(ROOT, "CLAUDE.md"), os.path.join(ROOT, "AGENTS.md"))] + for src in sorted(glob(os.path.join(ROOT, ".claude", "hooks", "*.py"))): + out.append((src, os.path.join(ROOT, ".codex", "hooks", os.path.basename(src)))) + return out + + +def read(path): + try: + with open(path, "r", encoding="utf-8", errors="replace") as fh: + return fh.read() + except Exception: + return None + + +def drifted(): + """동기화가 필요한 (원본, 미러) 목록.""" + out = [] + for src, dst in pairs(): + s = read(src) + if s is None: + continue # 원본이 없으면 미러도 관리 대상 아님 + if read(dst) != s: + out.append((src, dst)) + return out + + +def sync(targets): + done = [] + for src, dst in targets: + os.makedirs(os.path.dirname(dst), exist_ok=True) + shutil.copyfile(src, dst) + done.append(os.path.relpath(dst, ROOT).replace("\\", "/")) + return done + + +def unregistered_hooks(): + """.claude/settings.json에 등록됐지만 .codex/hooks.json에 없는 훅 목록. + + pairs()는 .py 파일만 복사한다. 훅 **등록**은 못 옮긴다 — 두 파일의 형식이 달라서다 + (.codex/hooks.json은 절대경로를 박은 별도 스키마). 그래서 신규 훅을 추가하면 .py는 + 자동으로 미러되는데 codex 쪽 등록은 사람이 손으로 해야 하고, 그 손이 다음 드리프트의 + 씨앗이다. 자동으로 못 고치면 최소한 시끄럽게 알린다. + """ + def _load(p): + try: + with open(p, encoding="utf-8") as fh: + return json.load(fh) + except Exception: + return None + + claude = _load(os.path.join(ROOT, ".claude", "settings.json")) + codex = _load(os.path.join(ROOT, ".codex", "hooks.json")) + if claude is None or codex is None: + return [] + + def _names(cfg): + found = set() + for entries in (cfg.get("hooks", {}) or {}).values(): + for entry in entries: + for h in entry.get("hooks", []): + cmd = h.get("command", "") + for tok in cmd.replace("\\", "/").split(): + # 따옴표를 **먼저** 벗긴다. .codex/hooks.json은 절대경로를 작은따옴표로 + # 감싸므로("python -X utf8 'C:/…/warn_monolith.py'") 벗기기 전에 + # endswith(".py")를 보면 전부 놓친다. + tok = tok.strip("'\"") + if tok.endswith(".py"): + found.add(os.path.basename(tok)) + return found + + missing = _names(claude) - _names(codex) + # scripts/ 의 것(sync_codex 자신 등)은 .codex가 자체 등록하지 않아도 무방하다. + hook_files = {os.path.basename(p) for p in glob(os.path.join(ROOT, ".claude", "hooks", "*.py"))} + return sorted(missing & hook_files) + + +def main(): + argv = sys.argv[1:] + + if "--hook" in argv: + # PostToolUse: CLAUDE.md나 .claude/hooks/*.py를 건드렸을 때만 동작. + try: + d = json.loads(sys.stdin.read()) + except Exception: + print(json.dumps({"suppressOutput": True})) + return 0 + + fp = ( + d.get("tool_response", {}).get("filePath", "") + or d.get("tool_input", {}).get("file_path", "") + ).replace("\\", "/") + + watched = fp.endswith("/CLAUDE.md") or fp.endswith("CLAUDE.md") or ( + "/.claude/hooks/" in fp and fp.endswith(".py") + ) + if not watched: + print(json.dumps({"suppressOutput": True})) + return 0 + + targets = drifted() + if not targets: + print(json.dumps({"suppressOutput": True})) + return 0 + + done = sync(targets) + print(json.dumps({ + "hookSpecificOutput": { + "hookEventName": "PostToolUse", + "additionalContext": ( + "SYNC-CODEX: Codex 미러를 자동 동기화했습니다 — " + + ", ".join(done) + + ". 커밋 시 함께 포함하세요." + ), + } + })) + return 0 + + targets = drifted() + + if "--check" in argv: + unreg = unregistered_hooks() + if not targets and not unreg: + print("codex 미러 동기 상태 — 드리프트 없음") + return 0 + if targets: + print(f"파일 드리프트 {len(targets)}건:") + for src, dst in targets: + print(f" {os.path.relpath(src, ROOT)} -> {os.path.relpath(dst, ROOT)}") + print("\n동기화: python -X utf8 .claude/scripts/sync_codex.py --write") + if unreg: + # .py는 자동 복사되지만 등록은 형식이 달라 못 옮긴다. 손으로 해야 하므로 알린다. + print(f"\n.codex/hooks.json 미등록 훅 {len(unreg)}건:") + for h in unreg: + print(f" {h} — .claude/settings.json엔 있으나 .codex/hooks.json엔 없다") + print("\n두 파일은 스키마가 달라 자동 복사가 불가하다. .codex/hooks.json에 직접 등록할 것.") + return 1 + + if "--write" in argv: + if not targets: + print("이미 동기 상태 — 할 일 없음") + return 0 + for p in sync(targets): + print(f"synced {p}") + return 0 + + print(__doc__) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.claude/scripts/text_fit_lint.py b/.claude/scripts/text_fit_lint.py new file mode 100644 index 0000000..ba2b2c7 --- /dev/null +++ b/.claude/scripts/text_fit_lint.py @@ -0,0 +1,241 @@ +"""text-fit-lint — 고정 상자에 안 들어가는 IMGUI 라벨 검출. + +IMGUI `GUI.Label`은 넘치는 글자를 **조용히 자른다.** 예외도 로그도 없다. 두 방향으로 난다: + + 세로 — `wordWrap = true` 스타일을 고정 높이 Rect에 그리면, 줄바꿈이 일어나는 순간 + 넘치는 줄이 통째로 사라진다. + 가로 — `wordWrap = false`면 줄바꿈이 없으니 높이는 늘 한 줄이고, 대신 넘치는 글자가 + 가로로 잘린다(가운데 정렬이면 앞뒤가 같이 잘려 더 나쁘다). + +한국어는 같은 뜻을 더 긴 글자수로 쓰고 모바일에선 기준 폰트가 커져서, 데스크톱 Game View에서 +멀쩡하던 라벨이 기기에서 잘린다. 2026-08-03에 실제로 7곳이 그렇게 잘리고 있었다 +(도감 설명 144px / 아이템 설명 40px에 34pt / 보유 곤충 설명 84px / NPC 대사 88px / +팀 슬롯·픽커 이름 / 가이드 배너). + +고치는 법은 하나다 — `UIHelper.LabelFit(rect, text, style)`. 상자는 그대로 두고 글자를 +줄여 맞춘다(상자를 키우면 그 아래 요소가 전부 밀린다). 규칙 문서는 `rules/ui-layout.md`. + +무엇을 잡나 +----------- +핵심 조건은 **"텍스트 길이를 코드가 모르는데 상자가 고정"** 하나다. + +처음엔 "wordWrap=true인데 높이 < fontSize×1.6(두 줄 불가)"로 잡아 봤더니 **247건**이 나왔다. +한 줄짜리 라벨은 원래 높이가 fontSize의 1.2배쯤이므로, 그 규칙은 **정상적인 한 줄 라벨을 +전부** 잡는다. 오탐이 그만큼이면 아무도 안 보는 검사가 된다. + +그래서 텍스트 출처를 먼저 좁힌다 — 데이터가 길이를 정하는 것만 본다(UNBOUNDED_TEXT): + + ① 무한정 텍스트 + wordWrap=true + 높이 < fontSize × 1.6 → 두 줄째가 잘린다 + ② 무한정 텍스트 + wordWrap=false → 폭 초과 시 가로로 잘린다 + +`$"Lv {n}"` 같은 보간은 길이가 사실상 묶여 있어 제외한다. 문자열 리터럴도 제외한다 +(코드에 길이가 박혀 있어 사람이 이미 맞춰본 것). + +exit 0 = 통과, 1 = 위반, 2 = 파싱 실패(구조가 바뀌었으니 파서부터 확인할 것). +""" +import glob +import io +import os +import re +import sys + +try: + sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace") +except Exception: + pass + +ROOT = os.environ.get("CLAUDE_PROJECT_DIR") or os.path.dirname( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +) + +# 두 줄이 들어가려면 줄간격까지 쳐서 대략 이 배수는 필요하다. +TWO_LINE_RATIO = 1.6 + +# 길이를 **데이터가** 정하는 텍스트. 여기 없는 건 코드가 길이를 알거나 사실상 묶여 있다. +# 늘릴 때는 "왜 무한정인가"를 함께 남길 것 — 넓히면 곧바로 오탐이 는다. +# description : 종·리전·아이템 설명 프로즈. 가장 길고 가장 잘 잘린다 +# displayName : 종·아이템 이름. 데이터가 정하고 한국어라 폭을 많이 먹는다 +# lines[ : NPC/스토리 대사 배열 +# GetOwnedDisplayName / activeGuidedText : 런타임 조립 표시명·가이드 문구 +# 길이를 데이터가 정하는 텍스트 출처. **넓히면 안 된다** — 한 줄 라벨은 원래 높이가 +# fontSize의 1.2배쯤이라 출처를 안 좁히면 정상 라벨까지 247건이 걸린다(모듈 주석 참조). +# +# LastResultText: NPC 대결 결과 토스트. 상대 이름 + 보상 아이템 + 거점 이름이 붙어 길이가 +# 데이터로 정해지는데 800×44 고정 상자였다 — 2026-08-23 오염 거점 라운드에서 문구가 +# 길어지며 손으로 잡았다. 지금은 LabelFit이라 안 잡히지만 GUI.Label로 되돌아가면 다시 잡는다. +UNBOUNDED_TEXT = re.compile( + r"\.description\b|\.displayName\b|\blines\s*\[|GetOwnedDisplayName\s*\(|activeGuidedText\b" + r"|\.LastResultText\b" + # errorMessage: Firebase가 돌려주는 문구라 길이를 서버가 정한다. LoginUI가 55f 고정 상자에 + # 래핑으로 그려 둘째 줄이 잘렸다 — 2026-08-23 LoginUI 재감사에서 손으로 잡았다. + r"|\berrorMessage\b" +) + +# `이름 = new GUIStyle(...) { ... }` 또는 `이름 = Label(24, ...)` 형태에서 스타일 속성을 읽는다. +STYLE_ASSIGN = re.compile(r"\b(\w+)\s*=\s*(?:new\s+GUIStyle\s*\(|(\w+)\s*\()", re.S) + +# 면제 — 이 파일들은 자체 맞춤 로직이 있어 LabelFit이 오히려 방해다. +EXEMPT_FILES = { + # OpeningSceneController: CalcSize로 폭을 직접 재서 폰트를 맞추는 FitFontSize를 이미 갖고 있고, + # 그 결과가 OpeningSequenceTests로 고정돼 있다. + "OpeningSceneController.cs", +} + + +def style_table(src): + """{스타일 변수명: (fontSize, wordWrap)} — 선언 블록에서 읽는다. + + 두 가지 형태를 덮는다: + inline : x = new GUIStyle(GUI.skin.label) { fontSize = 24, wordWrap = true } + factory: x = Label(24, FontStyle.Bold, TextAnchor.MiddleLeft, col) ← RegionMapUI 관례 + """ + table = {} + + # 팩토리 함수가 wordWrap을 고정하는지 파악(RegionMapUI.Label은 항상 false). + factory_wrap = {} + for m in re.finditer(r"static\s+GUIStyle\s+(\w+)\s*\([^)]*\)\s*\{(.{0,600}?)\n\s*\}", src, re.S): + body = m.group(2) + if "wordWrap = false" in body or "wordWrap=false" in body: + factory_wrap[m.group(1)] = False + elif "wordWrap = true" in body or "wordWrap=true" in body: + factory_wrap[m.group(1)] = True + + for m in re.finditer(r"\b(\w+)\s*=\s*new\s+GUIStyle\s*\([^;]*?;", src, re.S): + blob = m.group(0) + fs = re.search(r"fontSize\s*=\s*(\d+)", blob) + wrap = None + if "wordWrap = true" in blob or "wordWrap=true" in blob: + wrap = True + elif "wordWrap = false" in blob or "wordWrap=false" in blob: + wrap = False + else: + # GUI.skin.label 기본은 wordWrap=true, GUI.skin.button은 false. + wrap = True if "GUI.skin.label" in blob else None + table[m.group(1)] = (int(fs.group(1)) if fs else 0, wrap) + + for m in re.finditer(r"\b(\w+)\s*=\s*(\w+)\s*\(\s*(\d+)\s*,", src): + fname = m.group(2) + if fname in factory_wrap: + table[m.group(1)] = (int(m.group(3)), factory_wrap[fname]) + + return table + + +# GUI.Label(new Rect(...), ,