Skip to content

곤충게임: 스토리 시스템 신설·2막·거점 아크·선택지 + 전투·육성·경제·UI 하네스 (feat/act2-story) - #1

Merged
kimsungsugit merged 199 commits into
mainfrom
feat/act2-story
Sep 8, 2026
Merged

kimsungsugit merged 199 commits into
mainfrom
feat/act2-story

Conversation

@kimsungsugit

Copy link
Copy Markdown
Owner

요약

feat/act2-story는 스토리 시스템 신설부터 2막 확장, 오염 거점 아크, 선택지, 그리고 그 위에서 돌린 audit 수십 라운드까지를 담는다. main 대비 커밋 약 200개, 파일 718개.

무엇이 달라지나

스토리

  • 스토리 시스템 신설(Story.json 144비트 · StoryDirector 트리거 큐 · 저널 · 컷신 · 스토리 NPC 조우)
  • 1막 6장 + 2막 6장(6개 리전) + 명부회 오염 거점 아크 + 종장. 간부 대결(집게·저울·하월)과 최종 대결
  • 선택지 3곳(표현이지 분기가 아니다 — 결과는 Immediate leaf, 보상 0). 최종장 "이름을 주지 않는다"를 플레이어가 고른다
  • HUD 목표 안내 + 자동 주행 + 미니맵 쐐기. 잠긴 리전이면 앞 리전 수문장으로 안내하고 권장 레벨을 병기

전투·경제·육성

  • 수문장 격파를 좌표·종이 아니라 개체 정체성으로 판정(야생 동종을 이기면 리전이 열리던 우회로 차단). 패배·도주 뒤 재도전 보장
  • 레이드 순차 팀 턴, 유나이트 연출, 히트스톱, 전투 길이 3종 상수 재조정
  • 곤충 HP·상태가 전투 간 지속(무료 전체치료 폐지) + 병원·치료 아이템
  • 훈련을 누적 회차로, 기술 디스크 방식 신설, requiredLevel 게이트
  • 가방 화면 신설(살 수만 있고 쓸 수 없던 아이템), 강화 아이템 효과 4종

캐릭터·UI

  • 캐릭터 치비 리워크 + 생성 화면 3단계 + 첫 파트너 선택. 생성한 외형이 필드 캐릭터에 반영
  • 세이프에어리어 하네스(UISafeLayout) · 공통 서피스(UISurface) · 잘림 대응(LabelFit) 전면 적용
  • 월드 지도 재설계, 오프닝, 퀘스트 칩

하네스·검증

  • 검사기 11종(story_lint 24검사 · quest_lint 11검사 · blight_lint 20검사 · data_lint 등)을 ci_check.py가 한 번에 돌린다
  • 걸음 도구 StoryBeatWalkthrough(campaign/blight, -walkChoice)가 비트 실발화를 검증. 배치 캡처 LiveSceneCapture, 지급·의상 프로브
  • audit 자동 플로우 — 큐 소진까지 반복(마지막 3커밋에서 P0 6 · P1 30여 건 처리)

마지막 세 커밋 (2026-09-09)

  • cb48ece 시나리오 공백 27비트 + 선택지 신설. 수문장에게 지면 리전이 영영 잠기던 P0
  • 31be2de 자체 검토 — 외형 재빌드가 포획 트리거까지 지우던 P0
  • a930d88 교차 리뷰 — 간부 대결 승리가 같은 종의 야생 레이드로도 뜨던 결함(DuelWin 트리거 신설) 외 11건

검증

항목 결과
PlayMode 테스트 889 / 889
ci_check.py (검사기 11종) 통과
Unity 배치 컴파일 에러 0
걸음 도구 campaign(기본/last) · blight 73 · 73 · 22 OK, 예외 0
verify_coverage.py 미할당 0

손으로 봐야 할 것

IMGUI라 배치 캡처가 닿지 않는다: 대사창 선택 버튼, 훈련 카드 진행바, 생성 직후 필드 캐릭터 외형·포획, 잠긴 리전 라벨.

🤖 Generated with Claude Code

kimsungsugit and others added 30 commits April 16, 2026 10:12
8-item automated verification: event subscription safety, null guards,
performance patterns (transform.Find caching), Bootstrap registration,
save compatibility, monolith impact, code conventions, data matching.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
캐릭터(필드/포트레이트):
- 필드 캐릭터 ~3.5등신 치비 리디자인 (큰 머리·큰 눈·통통 비례)
- 포트레이트 비례·얼굴 부품 머리 비례화로 필드와 정합
- 숲/유적/산 지면 고도 평탄화로 캐릭터 차폐 버그 해결
- 도구(잠자리채 등) 망 디스크 회전·손 위치 정합, 카메라 줌인

곤충 도감:
- InsectLore 64종 설명 전면 리라이트
- 도감에 3D 곤충 RenderTexture 프리뷰 표시 (신규 InsectModelPreviewRenderer)
- 곤충 3D 모델 셰이딩(PBR)·눈·다리·더듬이·뿔·대표종 심화

※ 작업트리 전체 상태의 체크포인트 (이전 미커밋 WIP 포함, 임시·정크 제외)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
모바일 Play Store 출시 블로커 및 인증/클라우드 시스템 정비 체크포인트.

[모바일 입력]
- 가상 조이스틱(VirtualJoystickUI) — 터치 이동, 멀티터치 fingerId 추적
- 세이프에어리어(SafeArea/SafeAreaPanel) — HUD/퀘스트/퀵바/배틀/레이드/로그인 노치·제스처바 회피

[인증/계정]
- 자동 로그인 UX(폼 깜빡임 제거) + 마스터 로컬 자동 로그인
- 계정 삭제(Play 필수) — Firestore+Auth+로컬 영구 삭제
- 게스트→정식 계정 연동(LinkGuestWithEmail) + UI
- 마스터 자격 증명 소스 제거 → master_config.json(gitignore) + #if 프로덕션 제외
- FirebaseConfig 외부 주입(firebase_config.json)

[클라우드 동기화]
- 저장소 desync 수정 — 레벨/XP/캔디/코인을 실제 파일 시스템과 연결
- 캐릭터 외형 동기 + 인메모리 리로드(ICloudReloadable) + 충돌 선택 UI(SaveConflictUI)

[결제(IAP)]
- Google Play Billing 레이어(IAPManager, #if INSECTGAME_IAP) + IPurchaseProvider
- 프로덕션 무료 지급 차단, 재시작 재전달 안전 지급, 현지화 가격 표시

[보상 무결성]
- 1v1/레이드 종료 가드 + 결제 미지급/이중지급 차단 + 퀘스트 다음단계 알림 수정

[성능/UX]
- 상시 per-frame Find 제거 + 모달 일관성(13개 토글 UI IModalUI 통일)

신규 10파일 + 수정분. IAP/마스터는 #if 격리로 기본 빌드 안전.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
세션 변경분 6개 차원 적대적 리뷰에서 발견한 데이터 손상/오염 엣지 차단.

(A) 계정 삭제 서버 실패 시 로컬 모순 wipe (AuthManager)
- DeleteAccountCoroutine이 서버 결과 무관하게 로컬을 wipe → Auth 삭제 실패(네트워크)인데
  로컬만 소실되는 모순. 성공 시에만 로컬 정리·로그아웃, 실패 시 로컬·세션 유지 + 재시도.

(B) 계정 전환 교차 오염 (CloudSaveManager)
- 로그아웃이 로컬 미삭제 → 다른 계정 로그인 시 충돌 "이 기기" 선택하면 이전 계정 데이터가
  새 계정 클라우드를 덮어씀.
- LocalOwnerUid 추적 추가: 로컬 소유자≠현재 uid면 충돌/로컬유지 건너뛰고 클라우드 적용.
  충돌 UI는 이제 같은 계정 멀티기기에만 표시(의미적 정확). 기본값 "" 하위호환.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- 도감 상세 우측 컬럼: 정보 행 간격 46→52, 설명 박스/라벨 110/56→160/144,
  서식지 값 높이 40→84 + wordWrap → 여러 줄 텍스트 짤림 해소
- 상점 보석 가격: '\2,000' → '2,000원' (현지화 가격 연동 시엔 스토어 가격 우선)
- 게스트 로그인: Firebase 미설정 사전 체크 + ParseFirebaseError로 실제 사유 표시
  (익명 인증 비활성/API 키 오류 명확화) — "게스트 로그인 실패" 모호 메시지 개선

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
이전에 추가한 보상 이중지급 가드(battleEnded)의 회귀.
플레이어 곤충 기절 시 battleEnded를 무조건 true로 설정 → 팀원 교체(SwapSelect)로
배틀을 이어가도 액션 핸들러의 `if (battleEnded) return`에 막혀 새 곤충이 행동 불가
→ 적도 안 움직이고 배틀이 안 끝나는 멈춤 발생.

수정: 기절 분기에서 battleEnded/적 Despawn/BattleEnded를 "교체 핸들러 없음(=실제 패배)"
일 때만 설정. 교체 가능 시 보류 → 새 곤충 정상 행동.
- 부수: 교체 시 적이 사라지던 것 + 승리 후 이중 Despawn 해소
- 레이드는 단일 기절 시 IsActive 안 꺼서 동일 버그 없음(확인)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
전투 멈춤 버그 점검 결과 레이드는 1v1 같은 멈춤 없음(보스턴 후 ActiveSlot 자동 전환 +
IsActive는 보스격파/전멸 시에만 off). 다만 CanUseSkill이 활성 슬롯 생존을 확인하지 않아
ActiveSlot이 사망 멤버를 가리키는 엣지에서 죽은 멤버가 행동/스킬버튼 오활성 가능 →
생존(CurrentHp>0) 방어 체크 추가.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
audit-progress.md를 88KB→10KB로 재구성하기 전 현 상태(84라운드 이력)를
보호하는 스냅샷. 재구성 중 사고가 나도 이 커밋으로 복원 가능.

.gitignore에 __pycache__/, *.pyc 추가 — 훅 실행 시 생성되는 바이트코드가
그동안 무시되지 않아 .pyc 2개가 추적되고 있었음(별도 커밋에서 제거).
/.codex/*.log도 함께 무시.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude Code 2.1.211의 스킬 로더는 .claude/skills/ 안의 디렉토리만 훑고
그 안의 SKILL.md만 읽는다. 평파일 <name>.md는 isDirectory() 검사에서
걸러져 파일을 열지도 않고 조용히 버려진다 — 경고도 에러도 없다.

그 결과 프로젝트 스킬 19개가 하나도 로드되지 않고 있었고, CLAUDE.md와
규칙·훅이 전제하는 /audit, /test, /balance-sim, /impact-analysis가
전부 호출 불가 상태였다.

이 커밋은 순수 이동만 담는다(내용 변경 없음). frontmatter 수정을 섞으면
rename 유사도가 떨어져 git log --follow가 끊기므로 다음 커밋으로 분리.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- args: → argument-hint: (14개). args는 Claude Code가 인식하지 않는 키로,
  argument-hint가 정식 명칭. (설치된 marketplace SKILL.md 29개 실측:
  argument-hint 17회 사용, args는 0회)
- user_invocable: true 삭제 (18개). 기본값이 true라 불필요.
- explore-standard만 user-invocable: false로 유지 (하이픈이 관용 표기).
  이 스킬은 Explore 위임용 가드 문서라 슬래시 노출이 불필요.

이관 직후 세션에 18개 스킬이 즉시 로드되는 것을 확인 — /audit, /test,
/balance-sim 등이 33일 만에 다시 호출 가능해졌다.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- UNITY_EDITOR_PATH: C:/Program Files/Unity/... → D:/Unity/Unity Hub/...
  기존 경로는 디렉토리 자체가 없어 이 변수를 신뢰하는 모든 호출이 실패했다.
  실제 경로는 오늘 APK 빌드로 확인.
- allow "mcp__*" 삭제 — claude doctor가 "Invalid permission rule was
  skipped"로 판정하던 무효 규칙. 와일드카드 툴명은 미지원.
- deny Write(*.meta)/Write(Library|Logs|UserSettings/**) 4건 삭제 —
  매 세션 "Write 규칙은 파일 권한 체크에 매칭되지 않음" 경고를 내던 사문.
  실제 .meta 보호는 Edit(*.meta)가 하고 있고 그건 그대로 유지된다.
- statusline-command.sh: 하드코딩 절대경로 5곳을 dirname $0 유도로 교체.
  프로젝트 이동/클론 시 조용히 깨지던 문제.
  input=$(cat)은 유지 — stdin 미드레인 시 writer가 SIGPIPE로 죽는다.

statusLine의 command 경로는 절대경로를 유지했다. $CLAUDE_PROJECT_DIR로
바꾸려 했으나 실측 결과 이 변수가 셸 환경에 설정되지 않아, 치환 시
상태줄이 통째로 사라진다.

claude doctor: Invalid settings 1건 → 0건.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
quick_compile_check: 미선언 식별자 검사가 사실상 죽어 있었다.
  `name_count >= 2`면 무조건 skip하는 빈도 가드 때문인데, 실제 미선언
  변수도 초기화·증감으로 2회 이상 등장하는 게 보통이라 docstring이 잡겠다고
  명시한 subAreaRespawnTimer 케이스조차 놓쳤다. 빈도 대신 "선언 문맥(타입
  토큰 뒤 식별자)의 부재"를 근거로 교체.
  검증: 코드베이스 142개 .cs 전수 투입 → 오탐 0건. 프로브 파일에서
  미선언 2개(subAreaRespawnTimer, ghostAccumulator)만 정확히 검출하고
  로컬/파라미터/제네릭 필드는 전부 통과.

scan_static_patterns: 모놀리스 검사만 raw text로 중괄호 깊이를 세고 있었다.
  $"{value}" 보간 문자열과 주석 속 { } 가 깊이를 깨뜨리고 유령 메서드
  시그니처까지 매칭했다(BattleScreenUI 실측 158개 → cleaned 132개).
  같은 파일의 다른 검사들이 쓰는 cleaned_full로 통일.
  임계값 4000 → 2500: warn_monolith가 모놀리스로 규정한 3종 중
  PlaySceneBootstrap(5292)만 걸리고 BattleScreenUI(3249)·RaidBattleUI(3093)는
  영구 제외됐던 불일치 해소. RaidBattleUI.DrawAttackEffects(208줄) 경고가
  이제 정상 발화.
  편집과 무관한 메서드는 중괄호 탐색 전에 거르도록 조기 continue 추가.

warn_monolith: 하드코딩 줄 수(4987/2950/2875)가 실제(5292/3249/3093)와
  어긋나 있었다. 숫자를 지우고 런타임에 센다 — 이 훅이 모놀리스 줄 수의
  단일 출처가 되고, 이 오류 유형이 구조적으로 사라진다.

하드코딩 절대경로 3곳(auto_verify_trigger/quick_compile_check/
scan_static_patterns)을 CLAUDE_PROJECT_DIR ?? cwd 유도로 교체.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Uncovered 큐가 2026-05-27 이후 소진된 채 방치돼 자동 플로우가 멈춰 있었다.
훅 2개가 `- [ ]` 개수만 보는데 그게 0이었기 때문. 완료 항목을 옮기는 것만으론
다음 소진 때 같은 일이 반복되므로 큐 보충 자체를 자동화한다.

audit_candidates.py (신규):
  Covered/아카이브에 이름이 없는 .cs를 프레임 할당·미캐싱 조회·구독 해제
  누락·싱글턴 참조로 채점해 후보를 낸다. 기처리 목록을 진척 파일에서 읽으므로
  하드코딩이 없고 stale해지지 않는다.
  실측 상위: WorldFieldMultiplayerUI(160), AccountLinkUI(146),
  SubAreaEnvironment(140), KeyGuideHUD(138), SceneAutoWire(find 40).

audit-progress.md 재구성 (88,370 → 19,139 bytes):
  - Uncovered에 [x]로 잘못 쌓인 59건 + 기존 Covered 25건 = 84건을 압축 인덱스로
    통합. 서술 원문은 audit-archive/covered-detail.md로.
    audit 스킬 Step 2가 Covered 전체를 Explore 프롬프트에 주입하므로, 서술째
    옮겼다면 43KB가 매 라운드 딸려갔을 것이다. 이제 4.4KB.
  - Round Log 62건 중 최근 5건만 본체, 57건은 audit-archive/round-log-2026H1.md
  - Uncovered를 실제 미검토 후보 15건으로 교체 → 훅 트리거 복원

audit/SKILL.md Step 1을 자가치유형으로:
  `- [ ]`가 0건이면 종료하지 말고 audit_candidates.py로 큐를 재생성한 뒤 진행.
  스크립트가 후보 0건을 낼 때만 완료 보고.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
섹션 정규식에 줄 시작 앵커 추가 (두 훅 공통, 진짜 버그):
  `re.search(r"##\s+Uncovered...")`에 ^ 앵커가 없어, 문서 본문이
  `## Uncovered`를 인용만 해도 그 지점이 첫 매칭이 되어 엉뚱한 구간을 세고
  카운트 0으로 침묵했다. 이번에 진척 파일 헤더에 설명 문구를 넣자마자 즉시
  재현됐다 — 큐를 15건 채워놨는데도 두 훅이 침묵. MULTILINE + ^ 로 수정.

audit_reminder — 모델에게 전달되지 않던 문제:
  systemMessage는 사용자에게만 보이고 모델은 읽지 못한다. 즉 "/audit 자동 실행
  권장"이 지금까지 한 번도 모델에 도달한 적이 없다. additionalContext를 추가해
  둘 다 낸다. 코드 주석의 "Stop hook은 additionalContext 미지원"은 사실이 아니었다.

무한 루프 가드 (필수):
  additionalContext를 주면 모델이 이어서 작업하고 그 턴 끝에 Stop이 또 발화한다.
  stop_hook_active로 차단. stdin 파싱 실패 시에도 침묵한다 — 그 경우
  stop_hook_active를 판별할 수 없어 발화하면 루프가 성립하기 때문.
  검증: stop_hook_active:true / 깨진 JSON / 빈 입력 → 전부 침묵,
       {} (정상 첫 Stop) → 발화.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
경계를 정했다 — CLAUDE.md는 "지도"(무엇이 어디에), rules는 "규범"(무엇을 해도
되나), 코드는 "수치", 훅은 "사실"(줄 수), settings는 "강제".

CLAUDE.md에 박혀 있던 수치는 전부 틀려 있었다:
  LOC 35,000 → 실측 57,797 | 파일 115개 → 160개 | 싱글턴 9 → 10
  FindFirstObjectByType 72회 → 87회
  GameObject.Find 486회 → 실측 32회 (15배 과장 — 이 숫자로 성능을 판단하면
  완전히 오도된다)
  모놀리스 4987/2900+ → 5292/3249/3093
손으로 유지하는 사본은 반드시 썩는다는 걸 그대로 보여준다. 그래서 고치지 않고
없앴다.

- 배틀/포획/스폰/세이브/IV 수치 블록 삭제 → 진입점 표 + "수치의 단일 출처는
  코드(GameConstants, 각 Controller), 불일치 시 코드가 옳다"로 대체.
  옛 구조는 balance.md만 그 단서를 달고 CLAUDE.md는 단정형이라, 가장 안 믿을
  사본이 유일하게 자동 로드되는 역전이었다.
- 코딩 컨벤션 ~90% 중복(unity-csharp.md와) 삭제 → 포인터
- `.meta` 금지 3중 → settings.json의 Edit(*.meta) deny가 강제한다고 명시
- 모놀리스 줄 수는 CLAUDE.md/architecture.md 양쪽에서 제거 → warn_monolith 훅이
  런타임 보고 (4/11 커밋이 이걸 가능하게 했다)
- 127줄 → 106줄

rules frontmatter — Windsurf/Cursor 문법이라 Claude Code가 무시하고 있었다:
  trigger: always (architecture, unity-csharp) / trigger: glob + globs
  (save-system, scriptable-objects) → 전부 description으로 통일.
  8개 규칙은 조건과 무관하게 항상 자동 로드된다. "세이브 파일 수정 시에만
  로드"라는 의도는 애초에 동작한 적이 없으므로 포기하고 명시했다.
  frontmatter 없던 2개(agent-coordination, audit-flow)에도 description 추가.

save 서술 충돌 해소: architecture.md "누락 필드를 기본값으로" vs
  save-system.md "누락 필드 무시" → 동작은 하나다. save-system.md로 단일화하고
  "JsonUtility는 JSON에 없는 필드를 건드리지 않으므로 C# 필드 초기값이 남는다"로
  정확히 기술. architecture.md의 세이브 섹션은 포인터로 축소.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
AGENTS.md는 CLAUDE.md의 사본, .codex/hooks/*.py는 .claude/hooks/*.py의 사본인데
손으로 유지하다 보니 실측 시점에 훅 7개 중 6개가 드리프트해 있었다.

AGENTS.md의 경로 재작성부터가 틀렸다: `.Codex/audit-progress.md`와
`.Codex/rules/audit-flow.md`를 가리켰는데 그런 디렉토리는 존재하지 않는다.
정작 .codex/hooks/audit_flow_inject.py:41은 `.claude/audit-progress.md`를 읽는다.
즉 .codex는 이미 .claude를 단일 출처로 보고 있었다 → 변환 없이 그대로 복사한다.

sync_codex.py (신규):
  --check  드리프트 보고, 있으면 exit 1
  --write  동기화
  --hook   PostToolUse 모드 — CLAUDE.md나 .claude/hooks/*.py를 건드렸을 때만
           동작하고 아니면 침묵
  settings.json PostToolUse에 등록 — 수동 동기화는 이번처럼 반드시 어긋난다.

AGENTS.md는 이제 CLAUDE.md의 무변환 복사본이다(diff 무차이).
말미의 빈 "## Imported Claude Cowork project instructions" 헤더도 함께 사라졌다.

.codex/{agents,hooks,config.toml,hooks.json} git 추적 시작 (7/11 커밋에 포함됨).
.codex/config.toml의 죽은 UNITY_EDITOR_PATH도 3/11에서 함께 수정.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
7개 전부 "~담당." 명사구여서 정체만 있고 "언제 부르나"가 없었다. description은
자동 위임 판별기가 읽는 유일한 신호이므로 <역할> + <언제 PROACTIVELY> + <예시> +
<경계>로 통일했다.

ui-dev ↔ visual-dev 경계 (가장 심각했던 충돌):
  양쪽 description에 "UI 레이아웃"이 문구까지 동일해 위임 판별이 불가능했다.
  그런데 agent-coordination.md 표를 보면 visual-dev의 실제 UI 관여는 레이아웃이
  아니라 "쉐이크 효과, HP바 보간, 이펙트 렌더링"이다 — visual-dev의
  "UI 레이아웃"이 오기였다. 진짜 축으로 갈랐다:
    ui-dev     = 2D IMGUI 화면 (무엇을 어디에 그리는가)
    visual-dev = 3D 씬 + 색·연출 (어떻게 보이는가)
  각 description 끝에 대칭 배타 문장을 넣어 한쪽이 확정되게 했다.
  visual-dev 본문의 "OnGUI 레이아웃" 표현도 함께 정정.

architect ↔ game-designer: "의존성 분석" vs "시스템 영향도 분석" 겹침 →
  코드 구조 vs 게임 플레이로 분리.

tools 전문화 (before: 7개가 바이트 단위로 동일):
  구현 5개(ui-dev/visual-dev/battle-dev/capture-dev/data-architect)에서 Agent 제거.
  agent-coordination.md가 "경계 밖 수정은 재위임하지 말고 메인 모델에 보고"로
  규정하므로 서브에이전트가 또 위임할 이유가 없다.
  architect/game-designer는 Agent 유지(분석 시 Explore 위임이 유용).

Edit/Write는 두 에이전트에서도 제거하지 않았다 — agent-coordination.md가
game-designer에게 ItemData 효과값·RegionData insectIds·RegionManager 진행 switch를,
architect에게 CashShopManager gems 동기화를 명시적으로 배정하고 있어서, 제거하면
규정된 역할이 깨진다. 대신 description에 "경계 안에서만"을 명시했다.

model: 은 지정하지 않는다 — 또 하나의 stale 원천이 된다.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- 추적 중이던 .pyc 2개 제거(git rm --cached). 최초 커밋부터 들어와 있었고
  소스보다 35일 낡은 stale 바이트코드였다. .gitignore는 0/11에서 이미 추가.
- __pycache__ 3곳 디스크 정리.
- .claude/_dex_critique.txt, _dex_dexVisual.txt 삭제 — 내용이
  "You've hit your session limit" 에러 문자열 60B. 서브에이전트 출력
  리다이렉트 실패 산출물.
- .claude/_chibi_synth.txt(13KB) 삭제 — 소진 확인 후 제거.
  설계 수치가 코드에 그대로 반영돼 있다: eyeW=headW*0.30f,
  안경 Vector3(-0.072f, 1.20f, 0.21f), 목걸이 1.00f, 배지 0.85f 전부 일치.
  (Covered 등재만으로 판단하면 틀린다 — 등재는 2026-05-20이고 이 설계서는
   2026-06-06 작성이라 시간이 역전된다. 실제 적용 라운드는 Round Log의
   "치비 부작용 감사 적용(1차)" + "치비 잔여 P2 폴리시"다.)
- .claude/scripts/audit_agents.py 제거 — verify_coverage.py와 기능 중복인데
  6개 스크립트 중 유일하게 UTF-8 미설정이라 cp949 콘솔에서 즉사하고,
  subprocess의 find가 System32 find.exe에 잡히면 전 파일을 MISSING으로
  오보고한다. verify_coverage.py는 같은 일을 glob으로 이식성 있게 처리.
- verify_coverage.py 승격 — agent-coordination.md에 실행법 추가로 고아 해소.
  실측 미할당 31개(SocialPvpManager, VillageBuilder, IAPManager, NPC/ 등).
  다만 이 스크립트의 매핑은 하드코딩이라 문서와 따로 논다는 한계를 명시했다.

git clean은 반드시 경로를 좁혀 썼다. `-fdX .claude/ .codex/`를 그대로 돌렸다면
.gitignore 대상인 settings.local.json과 .codex/tools/까지 날아갔을 것이다
(dry-run에서 확인). 둘 다 보존됐다.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
/audit 라운드 1 — WorldFieldMultiplayerUI (P0:1, P1:1)

P0: 채팅 대상이 uid로 고정되지 않았다.
  chatOpen은 bool인데 대상은 매 Update 재계산되는 nearestPlayer였다
  (Update 첫 줄이 무조건 nearestPlayer = null).
  (a) 상대가 대화 범위를 벗어나거나 접속을 끊으면 composer는 사라지는데
      chatOpen이 true로 남아 IsOpen → ModalUIRegistry 등록이 유지되고,
      IsAnyOpen()이 PlayerMovement/VirtualJoystickUI/WorldInteractionController/
      CaptureInputController의 입력을 전부 차단한다 — 화면에 아무것도 없는데
      입력만 잠긴다. 모바일은 조이스틱까지 죽어 ESC/백버튼 외 복구 불가.
  (b) 작성 중 다른 탐험가가 더 가까워지면 DrawChatComposer가 조용히 대상을
      바꿔 SendPrivateChat이 엉뚱한 상대에게 전송한다.
  수정: chatTargetUid로 "대화" 클릭 시점의 상대를 고정. 신설 ResolveChatTarget()이
  매 프레임 remoteAvatars[chatTargetUid]로 해석하고, 없음/범위밖/차단이면 null →
  CloseModal()로 모달 잠금을 푼다. 모달 전환 지점(친구 패널 토글, 차단 확정,
  전송 완료)을 전부 CloseModal() 경유로 통일해 초기화 누락을 구조적으로 차단.

P1: OnGUI 매 프레임 문자열 보간.
  OnGUI는 프레임당 Layout/Repaint/입력 이벤트로 여러 번 호출되는데 월드 타이틀·
  근처 탐험가 라벨·메시지 5줄을 매번 $"..."로 힙 할당했다(struct인 Rect/Color와
  달리 진짜 GC 압박). 내용은 서버 이벤트 시점에만 바뀌므로 cachedWorldTitle
  (HandleWorldState) / messageLines(HandleMessages) / cachedNearbyLabel(대상 uid
  변경 시에만) 캐싱으로 전환. Draw 경로의 문자열 보간 0건.

자체 발견 회귀 1건: messageLines 도입 후 HandleWorldLeft가 messages.Clear()만 하고
  messageLines.Clear()를 빠뜨려 인덱스가 어긋날 수 있었다. 같은 커밋에서 수정.

검증: Unity PlayMode 38/38 통과, error CS 0건, Assembly-CSharp.dll 재컴파일 확인.

Explore 거짓양성 6건 제외: InitStyles는 이미 stylesReady+캐시+GetCachedTex 표준
패턴(자동 채점의 "프레임 할당 51"은 대부분 struct인 new Rect/Color였다), 싱글턴
5곳 전부 null 가드, 이벤트 짝 정상(Subscribe idempotent), AutoWire는 Bootstrap:398에
존재, 200줄+ 메서드 없음, 세이브 표면 없음.

P2 5건은 미처리(보고만).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
P2 처리 라운드 (사용자 명시 요청). 이 중 UIHelper 건은 P2가 아니라 P1이었다.

UIHelper — 동적 색이 텍스처 캐시를 넘겨 UI 배경을 영구 손상시킬 수 있었다:
  DrawRarityBorder는 pulse로 RGB를, DrawRarityGlow는 breathe로 alpha를 매 프레임
  연속 변조한 뒤 그 색을 GetCachedTex에 넣었다. 프레임마다 새 Color32 키가 쌓여
  MaxCacheSize(256)를 실제로 넘긴다. 넘치면 캐시 텍스처를 전부 Destroy하는데,
  UIHelper가 무효화할 수 있는 건 자기 styleCache뿐이다 — 각 UI가 자기 필드로 들고
  있는 GUIStyle.normal.background는 파괴된 텍스처를 계속 가리키고, stylesReady 가드
  때문에 재생성도 안 돼 그 UI 배경이 영구히 깨진다. 기존 주석이 이 위험을 알고
  styleCache.Clear()를 넣었지만 UIHelper 바깥 스타일까지는 닿지 못했다.
  수정: 동적 색은 텍스처를 만들지 않고 빌트인 whiteTexture에 GUI.color로 입히는
  DrawTinted로 전환(DrawBorder/DrawProgressBar/DrawDimOverlay/DrawRarityGlow).
  GetCachedTex는 GUIStyle.normal.background용 정적 색 전용으로 한정하고 오버플로 시
  Destroy를 제거해 stale 참조 가능성 자체를 없앴다.

  중요 — GUI.color는 대입이 아니라 곱셈이어야 한다. AccountLinkUI:124와
  AccountSettingsUI:105가 딤 오버레이로 GUI.color를 미리 설정하고, 색 텍스처를
  그리던 옛 동작도 그 값과 곱해졌다. 대입했다면 그 페이드가 전부 사라지는
  회귀였다(실제로 대입으로 짰다가 검증에서 잡았다).

WorldFieldMultiplayerUI:
  - Shader.Find가 둘 다 null이면(빌드 셰이더 스트리핑) new Material(null)이 예외라
    아바타 생성 자체가 실패했다 → ApplyAvatarMaterial 폴백으로 프리미티브 기본
    머티리얼에 색만 입힌다. 정리 경로는 material null을 이미 가드하고 있었다.
  - color * 1.12f / * 0.82f가 알파까지 곱해 눌림 버튼이 반투명해졌다 → RGB만 조절.

미처리 P2 2건: DrawInvitePopup 모달 미등록(초대 팝업이 플레이 중 입력을 막아야
하는지는 게임 디자인 판단이라 보류), 메시지 8개 보관 대비 5개 렌더(h 계산이
min(190)으로 클램프돼 실질 영향 없음).

검증: Unity PlayMode 38/38, error CS 0건.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
/audit 라운드 2 — AccountLinkUI (P1:3, P2:2)

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은 IsAnyOpen() 가드로 자신을 숨기는데
  여기만 빠져 있었다. GUI.depth도 미설정(기본 0)이라 DexScreenUI(-10)/
  SaveConflictUI(-50)에 가려 보이지 않으면서 입력만 먹는 상태가 가능했다.
  → 배지에 IsAnyOpen() 가드, DrawForm에 GUI.depth = -20.

P1 — isProcessing 리셋 경로 부재:
  false로 되돌리는 곳이 OnLinkCompleted 하나뿐이라, 요청 중 GameObject가 토글되면
  OnDisable이 구독을 끊어 응답을 놓치고 "연동하기"가 세션 내내 비활성이 된다.
  → OnEnable에 초기화.

P2 — 닉네임 무검증으로 이메일이 공개 표시명이 됨:
  ValidateCredentials는 email/password만 보고, 닉네임이 비면 AuthManager가
  DisplayName = email로 채운다 → 월드/친구 목록에 이메일이 그대로 노출.
  → Submit()에 공백 검증 추가.
P2 — 버튼 눌림 알파 곱셈 → RGB만 조절.

Explore 거짓양성 제외: MakeTex 11개는 stylesReady로 1회만 호출(WorldLobbyUI
MakeTex 누수 라운드가 이미 동일 판정), msgStyle textColor 매 프레임 갱신은 프로젝트
표준 해법, 이벤트 짝/Bootstrap 등록(:156)/싱글턴 8건 가드 전부 정상. 연동은 uid를
보존하므로 CloudSaveManager의 LocalOwnerKey/targetUid 캡처와 충돌 없고,
LinkGuestWithEmail 실패 경로 3개가 전부 LinkCompleted를 발화해 데이터 유실 벡터 없음.

검증: Unity PlayMode 38/38, error CS 0건, Assembly-CSharp.dll 재컴파일 확인.
audit 큐: 15 → 13 (다음 대상 SubAreaEnvironment).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
/audit 라운드 3 — SubAreaEnvironment (P1:1)

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로 전환하는
기존 패턴과 정확히 같은 이유(그 모드에선 ambientColor가 무시됨)이고 같은 자리다.
defaultCameraBg에 빠져 있던 필드 초기자도 형제 필드와 맞춰 추가.

전환 자연스러움 확인: PlaySceneBootstrap:566이 backgroundColor를 하늘색(0.5,0.8,1)으로
설정해두므로 진입 첫 프레임의 solid 색이 skybox와 사실상 같아 깜빡임이 없고, 이후
0.5초간 어두운색으로 페이드된다. 이탈은 즉시 Skybox 복원으로 기존 동작과 동일.

자동 채점 46건은 전부 거짓양성이었다 — EnvironmentProfile이 struct라 프리셋의
new Color/Quaternion.Euler가 전부 스택 할당이고, Update()는 전환 0.5초 동안만 돌며
그 경로에 힙 할당이 0건이다. 미캐싱 조회 1건도 initialized 래치로 Start에서 1회만.
진입/이탈 정합도 clean(구독 순서상 RestoreLastSubArea가 Start 전에 발화해도
if (!initialized) CaptureDefaults()가 서브지역 빌드 전 상태를 정확히 캡처).

미처리 P2 4건(전부 현재 도달 불가로 격하, 잠재 위험으로만 기록): fogMode 미복원,
OnEnable 재구독 부재, FindFirstObjectByType<Light>에 Directional 필터 없음
(PlaySceneBootstrap:592는 같은 호출에 가드가 있는데 여기만 없음).

검증: Unity PlayMode 38/38, error CS 0건.
audit 큐: 13 → 12 (다음 대상 KeyGuideHUD).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
/audit 라운드 4~6 병렬 — AccountSettingsUI(P0:1,P1:2) / SaveConflictUI(P0:1) /
KeyGuideHUD(P1:2)

■ AccountSettingsUI P0 — 게스트 로그아웃 = 계정 영구 소실, 확인 0단계
  게스트(익명)는 refresh token이 유일한 재진입 수단인데 Logout() → ClearAuth()가
  TokenKey/UidKey를 PlayerPrefs에서 지운다. 이메일이 없어 재로그인이 불가능하고
  LoginAsGuest()는 매번 새 uid를 발급하므로, 곤충·레벨·재화가 인증 불가능한 uid에
  묶여 영구 고아화된다 — SaveScope가 users/<uid>/로 스코핑하니 파일은 디스크에
  남지만 접근 경로가 사라진다. "삭제"도 "복구 가능"도 아닌 최악의 상태.

  정황 둘이 결정적이었다: (1) 같은 파일 163줄이 "게스트 (이메일 미연동) / 앱 삭제 시
  데이터 복구 불가"라고 직접 경고해놓고, 124줄에서 동일한 피해를 주는 원클릭 버튼을
  제공했다. (2) 안전장치가 역전돼 있었다 — 파괴가 의도인 계정 삭제는 2단계 확인 +
  명시 경고를 받는데, 되돌릴 수 없기는 마찬가지인 게스트 로그아웃은 무방비였다.

  수정: confirmLogout 확인 단계 신설. 게스트일 때만 경고 화면을 거치고, 정식 계정은
  이메일로 재로그인 가능하므로 즉시 로그아웃한다.

■ AccountSettingsUI P1:2 — AccountLinkUI와 동일 패턴
  - GUI.enabled = !processing이 "취소"까지 덮어 confirmDelete=false가 죽은 코드였다.
    Instance null이거나 삭제 중 GameObject 토글로 AccountDeleted를 놓치면 "삭제 중..."과
    "취소"가 모두 비활성인 채 굳어 앱 재시작 전까지 계정 삭제가 불가능해진다
    (스토어 정책 위반 소지). prevEnabled 보존/복원 + CloseModal/DrawOpenButton에서 리셋.
  - IsAnyOpen() 가드와 GUI.depth가 둘 다 없어 "계정" 버튼이 전체화면 모달 위에
    그려지고 클릭을 가로챘다 → 가드 + depth -20.

■ SaveConflictUI P0 — 로컬을 항상 "더 오래됨"으로 표시해 최신 데이터를 버리게 유도
  요약의 lastSaveUnix는 PlayerPrefs LastSaveTs = 마지막 "클라우드 푸시 성공" 시각이지
  로컬 저장 시각이 아니다. 그런데 충돌 프롬프트 자체가 `클라우드 ts > 로컬 ts`일 때만
  뜨므로, 이 값을 쓰면 로컬이 100% 더 오래된 것으로 렌더된다. 오프라인으로만 진행해
  푸시 이력이 없는 기기는 localTs=0 → "기록 없음"으로 표시되는데, 정작 그 로컬에는
  실제 진행이 있다(HasMeaningfulLocalProgress가 보장해야 프롬프트가 뜬다).
  "선택하지 않은 쪽은 덮어쓰여집니다" 문구와 겹쳐 플레이어가 클라우드를 고르면
  ResolveConflict(true) → DeleteLocalFile이 곤충/팀/도감/아이템을 하드 삭제한다
  (AtomicFileWriter의 File.Replace도 3번째 인자가 null이라 백업본이 없다).

  수정: 세이브 파일 7개의 File.GetLastWriteTimeUtc 중 최신값을 쓰는
  ComputeLocalSaveUnix() 신설. OnConflict에서 1회만 계산해 캐시한다(OnGUI에서 파일
  I/O를 하면 매 프레임 디스크를 읽는다). DrawCard에 isLocal을 추가해 로컬 카드만 적용.

  P1(2단계 확인/백업)은 보류 — P0 수정으로 오도가 제거됐고, 확인 단계 추가는 UX 판단.

■ KeyGuideHUD P1:2
  - 컬렉션 안내가 "TAB"인데 실제 키는 C다(QuickAccessBarUI:58,74,97). KeyCode.Tab에
    컬렉션 바인딩은 존재하지 않고, Tab은 미니게임 confirm과 로비 오버레이 토글에
    쓰이며 IMGUI에선 포커스 이동 키다 — 안내대로 누르면 엉뚱한 게 동작했다.
  - 모달 가드/depth 부재로 불투명 패널 3개가 모달 위로 오버드로 가능.
    UIScale.Begin() 앞에서 return해 Begin/End 균형 유지(MinimapUI:52 관례).
    단 GUI.Button이 없어 클릭은 가로채지 않으므로 P0이 아닌 P1.

채점 반증: AccountSettingsUI의 싱글턴 11건은 전건 가드 정상이라 채점 1위 근거가
무효였고, "프레임 할당"은 4라운드 연속 전부 new Rect/new Color struct 거짓양성이었다.
scan_static_patterns 훅도 같은 struct를 P0로 보고 중 — 훅 개선 후보.

검증: Unity PlayMode 38/38, error CS 0건.
audit 큐: 12 → 9 (다음 대상 SocialPvpUI).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24에 git 프로세스가 크래시하며 남긴 0바이트 index.lock이 33일간 모든
git add/commit을 막고 있었다(오늘 제거). 그동안의 작업이 워킹트리에만 쌓여 있어
일괄 커밋한다. 아래는 파일 구성에서 확인되는 범위이며, 개별 기능의 설계 의도는
각 파일과 Docs/를 참조.

신규 시스템:
- NPC — CatcherKidNpc, VillagerNpc, NpcManager, NpcCatchRules,
  NpcDialogueDatabase, NpcVisualBuilder, NpcWalkAnimator
- 소셜/PvP — SocialPvpManager, SocialPvpUI
- 월드 상호작용 — WorldInteractionTypes, WorldInteractionController, VillageBuilder
- 미니맵 — MinimapUI
- 계정 — SaveScope(계정별 세이브 스코핑), MasterAccount, ICloudReloadable,
  AccountLinkUI(게스트→정식 전환), AccountSettingsUI, SaveConflictUI(동기화 충돌)
- 인앱결제 — IAPManager, IPurchaseProvider
- 모바일 입력/레이아웃 — VirtualJoystickUI, FieldHudInput, SafeArea, SafeAreaPanel
- 데이터 — InsectExpansionDefinitions
- 테스트 3종 — InsectExpansionDefinitionsTests, NpcCatchRulesTests,
  RegionDefinitionsScaleTests

수정 67개는 위 시스템을 기존 코드에 연결한 결과(PlaySceneBootstrap 등록,
CloudSaveManager 스코핑 연동, UI 흐름 편입 등)와 그간의 버그 수정을 포함한다.

이번 세션의 audit 라운드 6건(WorldFieldMultiplayerUI/AccountLinkUI/
AccountSettingsUI/SaveConflictUI/SubAreaEnvironment/KeyGuideHUD/UIHelper)은
앞선 커밋들에 이미 분리돼 있다.

검증: Unity PlayMode 38/38, error CS 0건.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
index.lock으로 33일간 커밋되지 못한 에셋 일괄 커밋(게임 코드는 직전 커밋).

- Assets/TextMesh Pro/ (81개) — TMP Essential Resources.
  이게 없으면 런타임에 TMP_Settings가 null이라 TextMeshProUGUI 생성마다 NRE가
  터지고 PlaySceneBootstrap이 BuildUI 지점에서 멈춘다(2026-06 갤럭시 S25 Ultra
  실기기에서 확인된 증상: Development Console NRE 홍수 + 메인필드 회색).
  저장소에 포함돼야 빌드에 들어간다.
- Assets/Editor/ — AndroidReleaseBuilder(APK/AAB 빌드 진입점. 오늘 APK 빌드에
  실제로 사용), TMPEssentialsImporter(위 리소스 임포트 자동화)
- Assets/AppIcon/, Assets/Resources/UI/ — 앱 아이콘, UI 리소스
- Assets/Resources/BillingMode.json — Unity IAP 설정
- Assets/Plugins/ (21K)

제외한 것:
- Assets/_Recovery/ 신규 10개 — Unity 크래시 복구 잔재. 0 (1)~0 (8).unity가 전부
  3486바이트로 동일하고 정식 씬은 Assets/Scenes/PlayScene.unity에 있다.
  (기존 18개가 이미 추적 중이라 일관성이 깨져 있으나, 추적 해제는 별도 판단이
   필요해 이번엔 건드리지 않았다.)
- KakaoTalk_*.jpg 3개(기기 테스트 스크린샷), /.utmp/ — .gitignore에 추가.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
index.lock으로 33일간 커밋되지 못한 설정/인프라 일괄 커밋.

■ ProjectSettings/ProjectSettings.asset — stripEngineCode: 1 → 0 (중요)
  IL2CPP 엔진 코드 스트리핑이 켜져 있으면 MeshCollider 네이티브 클래스가 제거된다
  (게임 코드가 MeshCollider를 직접 참조하지 않으므로). 그런데
  GameObject.CreatePrimitive는 내부에서 항상 MeshCollider를 AddComponent하므로
  런타임에 "Can't add component because class 'MeshCollider' doesn't exist!"가 터진다.
  증상 연쇄(2026-06-25 갤럭시 S25 Ultra): 모든 Plane 지형에 콜라이더 미부착 →
  PlayerMovement의 Physics.Raycast 지면체크 실패 → 플레이어 바닥 관통 무한 추락 →
  CameraFollower가 따라가 카메라 Y≈-27950 → 화면은 회색 스카이박스만
  (녹색 필드는 28km 위에 멀쩡히 존재). 에디터엔 스트리핑이 없어 재현 안 되는
  기기 전용 버그였다.
  직전 커밋의 Assets/link.xml(UnityEngine.PhysicsModule preserve="all")이
  스트리핑 재활성화에 대비한 영구 방어다.

■ Firebase Functions — 서버 검증 로직
  purchase-validation(영수증 검증), social-api / social-pvp(소셜·PvP),
  emulator/social-pvp-flow, 테스트 2종. node_modules는 .gitignore로 제외됨.
- firebase.json / firestore.rules / .firebaserc — Firestore 규칙 및 프로젝트 설정
- Tools/Test-SocialPvpEmulator.ps1 — 에뮬레이터 테스트 스크립트
- ProjectSettings/GraphicsSettings.asset, SceneTemplateSettings.json
- .gitignore — KakaoTalk_*.jpg(기기 테스트 스크린샷), /.utmp/ 추가

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
index.lock으로 커밋되지 못한 Packages/manifest.json, packages-lock.json.
IAP·TextMeshPro 등 이번 기간에 추가된 기능의 패키지 의존성이라, 이게 없으면
다른 환경에서 클론 시 컴파일이 깨진다.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Assets/_Recovery/는 Unity Editor가 크래시 복구 시 자동 생성한 기본 템플릿 씬
모음이다. 추적분 8개 + 고아 meta 1개를 제거하고 .gitignore에 등록한다.

내용 확인 결과(삭제 근거):
- 0 (4)~(8), 0.unity — GameObject가 Directional Light와 Main Camera 둘뿐이고
  MonoBehaviour는 0개. 게임 로직이 전혀 없는 Unity 기본 씬이다.
- 0 (1)~(3) — GameObject조차 없음(3486B).
- 정식 씬 Assets/Scenes/PlayScene.unity는 MonoBehaviour 1개(PlaySceneBootstrap)를
  들고 있고, 이 프로젝트는 그 하나가 전체 시스템을 런타임에 프로시저럴 생성한다.
  정식 씬(5003B)이 _Recovery(8187B)보다 작은 건 그 때문이지 손실이 아니다.

Assets/_Recovery.meta는 대상 디렉토리가 사라진 고아라 함께 제거했다(남기면
Unity가 매번 경고). 프로젝트 규칙의 ".meta 수정 금지"는 내용 편집에 대한 것이고,
대상이 없어진 meta의 제거는 Unity 관례다. 저장소 전체를 훑어 다른 고아 meta는
없음을 확인했다.

.gitignore에 /[Aa]ssets/_Recovery/ 와 .meta를 등록해 Editor가 다시 만들어도
추적되지 않는다(재생성 후 git status에 뜨지 않음을 실제로 검증).

기존 18개는 과거에 실수로 커밋돼 있었고 새 10개는 미추적이라 저장소 상태가
일관되지 않았는데, 이번에 양쪽 다 정리됐다.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
/audit 라운드 7~9 병렬 — QuickAccessBarUI(P0:1) / SocialPvpUI(P0:1,P1:2) /
SceneAutoWire(clean)

■ QuickAccessBarUI P0 — 핫키가 채팅·친구코드 입력을 망가뜨림
  이 파일은 그동안 모달 가드 관례의 근거(:113)로 인용돼왔는데, 정작 그 가드는
  모바일 렌더 경로에만 있었다. 핫키 경로는 IsInputBlocked()(battle/raid/frozen)만
  거치는데, 코드 주석의 전제 "모든 모달이 SetFrozen을 거니 frozen 하나로 커버된다"가
  깨져 있었다 — SocialPvpUI(:53)와 WorldFieldMultiplayerUI(:422)는 모달로 등록만
  하고 SetFrozen을 부르지 않는다.
  결과 (1) 친구코드·채팅 입력에 N/T/G/C/Q/M/V/P를 타이핑하면 글자마다 화면이
  토글되고, (2) 반대로 e.Use()가 그 키를 전역 소비해 텍스트필드가 8글자를 입력조차
  받지 못했다.
  수정: TryHotkey(index)→bool 신설. 막힌 키는 false를 반환해 e.Use()를 건너뛰므로
  텍스트필드가 글자를 받는다. 열린 화면 자신의 키는 통과시켜 "N으로 도감 닫기"
  토글은 유지.
  렌더 경로에는 가드를 넣지 않았다 — IsInputBlocked()에 IsAnyOpen()을 통째로 넣으면
  OnGUI가 조기 return해 데스크톱 바가 사라지고 active 하이라이트 설계가 죽는다.

■ SocialPvpUI P0 — 매칭 큐에서 못 빠져나옴 (동일 패턴 3번째)
  GUI.enabled = !IsBusy && teamReady가 "매칭 취소"까지 덮어, 큐 대기 중 곤충을
  방출하거나 편성을 풀면 teamReady=false가 되면서 취소가 죽었다. CancelQueue()는
  팀을 쓰지도 않는다. state.queued는 서버 권위값이라 재접속해도 유지되므로 영구
  감금이다. AccountLinkUI·AccountSettingsUI에 이어 세 번째로 확인된 같은 형태.

■ SocialPvpUI P1:2
  - OnStateChanged가 level-trigger라 status=="active"이면 매번 page를 Battle로
    되돌렸다. 매치 중 폴링이 2.5초마다 StateChanged를 발화하므로 친구/랭크 탭에
    머무를 수 없었다(친구 요청 수락·랭킹 확인 불가) → lastMatchId/lastMatchStatus로
    edge-trigger화.
  - HasValidTeam을 OnGUI 이벤트마다 호출해 BuildTeamSnapshot이 매번 List·배열을
    할당하고(전부 class = 진짜 힙 할당) 팀 미완성 시 예외까지 반복해 던졌다
    → 0.5초 스로틀 캐싱(RefreshTeamState).

■ SceneAutoWire — clean, 그러나 파일 전체가 dead code
  "미캐싱 조회 40건"은 이중 거짓양성이었다: 40건 전부 Awake() 하나에 있고(파일이
  통째로 Awake), 더구나 이 컴포넌트는 어디에도 붙어있지 않다(GUID 참조 0건, 씬의
  MonoBehaviour는 PlaySceneBootstrap 하나, AddComponent 0건). Awake가 실행되지
  않으므로 런타임 비용은 0이고, 모든 배선은 Bootstrap:165-235가 흡수했다.
  삭제는 P2로 보고만 — 누가 이 컴포넌트를 붙이면 Bootstrap과 이중 배선되는 잠복
  지뢰이고, verify_coverage.py:12 / architect.md:20 / architect.toml:10이 활성
  아키텍처 파일로 등재 중이라 동반 정리가 필요하다.

채점기 실패 모드 확정: "프레임 할당"(6라운드 연속)과 "미캐싱 조회"(이번) 둘 다
실행 컨텍스트를 보지 않고 구문만 세는 같은 병이다. Awake 스코프·씬 참조 필터가
없으면 dead code와 부트스트랩 파일이 계속 큐 상위를 점유한다.

검증: Unity PlayMode 38/38, error CS 0건.
audit 큐: 9 → 6 (다음 대상 CaptureInputController).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
9라운드 동안 audit이 드러낸 자기 자신의 문제를 정리한다. 채점 근거는 매번 틀렸는데
지목된 파일마다 진짜 P0가 나오는 상태였다 — 발굴은 되는데 이유를 못 믿는 도구였다.

■ scan_static_patterns — struct를 GC 압박으로 보고하던 문제
  perf_patterns에 new Color/new Rect가 있었다. 둘 다 struct라 스택 할당이고 GC를
  유발하지 않는다. audit 라운드들이 이걸 매번 "struct라 거짓양성"으로 기각해왔는데
  (2026-07-17까지 6라운드 연속) 훅은 계속 보고했다. 알려진 오탐을 반복하면 진짜
  경고까지 무시당한다.
  대신 실제 힙 할당인 class만 본다: GUIStyle, GUIContent, Texture2D, Material,
  List/Dictionary/HashSet. 검증: 같은 메서드에 struct와 class를 섞은 프로브에서
  class 3종만 잡고 struct 3종은 침묵.
  (한계: 문자열 보간 $"..."도 진짜 힙 할당이지만 cleaned_full이 문자열을 지워서
   검출 불가 — 주석에 명시.)

■ audit_candidates — 실행 컨텍스트를 보지 않던 문제
  "프레임 할당"(6라운드 연속)과 "미캐싱 조회"(SceneAutoWire) 둘 다 구문만 세고
  실행 컨텍스트를 보지 않는 같은 병이었다. 셋을 고쳤다:
  - hot: struct 제외, class만. 이걸로 옛 1위들의 근거가 사라진다
    (WorldFieldMultiplayerUI 51 / SubAreaEnvironment 46 / AccountSettingsUI 37 → 전부 오탐)
  - find: Awake/Start/OnEnable/AutoWire 본문을 제외하고 센다. 부트스트랩 시점 조회는
    이 프로젝트의 정상 패턴이다(SceneAutoWire가 이것 때문에 40건으로 1위였다).
  - dead code: MonoBehaviour인데 씬 GUID에도 없고 다른 코드가 언급하지도 않으면 제외.
    씬 .unity의 m_Script guid와 .meta guid를 대조한다.
  또 score 0이어도 미검토 파일은 후보에 넣는다 — 채점은 우선순위 도구이지 clean 판정
  도구가 아니다. 점수로 거르면 9라운드가 증명한 "근거는 틀렸지만 문제는 있던" 파일을
  통째로 놓친다.
  자체 버그 1건도 잡았다: code_mentions가 경로 구분자(os.walk의 백슬래시 vs 슬래시)를
  정규화하지 않아 자기 자신을 "다른 파일"로 세서 항상 True를 반환했다.
  결과: 큐 20건 재생성. NpcDialogueUI가 48점(struct 근거) → 24점(실제 힙 할당)으로
  재평가되는 등 점수가 정직해졌다.

■ SceneAutoWire 삭제 — dead code이자 잠복 지뢰
  씬 배치 0 / 코드 참조 0 / 프리팹 참조 0 / AddComponent 0을 전부 확인하고 제거.
  Awake가 실행되지 않으므로 런타임 영향은 없었지만, 누가 이 컴포넌트를 붙이는 순간
  PlaySceneBootstrap과 이중 배선된다. 모든 배선은 Bootstrap:165-235가 흡수했다.
  이 파일을 활성 아키텍처로 등재하던 3곳(verify_coverage.py, architect.md,
  .codex/agents/architect.toml)도 함께 정리.

■ 에이전트 문서의 stale 수치 제거
  architect.md/toml이 "GameObject.Find 486회"라고 적고 있었다 — 실측 32회로 15배
  과장이다. CLAUDE.md에서 걷어낸 그 수치의 원본으로 보인다. 모놀리스 줄 수 3종
  (4,987/2,950/2,875)도 ui-dev 문서까지 퍼져 있었고 전부 실제와 달랐다.
  숫자를 지우고 "그 자리에서 세라"는 지침과 출처(warn_monolith 훅, audit_candidates)를
  남겼다.

■ audit SKILL.md — 채점 근거를 Explore에 주지 말라는 지침 추가
  "점수는 열어볼 순서이지 무엇이 문제인지가 아니다." 대신 실측으로 확인된 거짓양성
  가드(struct/Awake 스코프/stylesReady/단일 스레드)를 프롬프트에 넣도록 명시.

검증: Unity PlayMode 38/38, error CS 0건. stale 수치 0건.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
kimsungsugit and others added 28 commits August 23, 2026 11:58
앞 라운드가 주석 하나를 의심한 데서 시작했는데, 파 보니 호출 자체가 죽어 있었다.

## P1 — 죽은 가드 세 줄

`AuthManager` · `CloudSaveManager` · `AudioManager`의 Awake에 이런 줄이 있었다:

    if (transform.parent == null) DontDestroyOnLoad(gameObject);

이 매니저들을 만드는 곳은 `PlaySceneBootstrap` 하나뿐이고, `EnsureObject("World/AuthManager")`가
경로대로 계층을 세워 **부모를 달아** 만든다. 씬 파일에도 `GameBootstrap` 말고는 아무것도 없다.
그래서 저 가드는 **한 번도 통과한 적이 없다.**

죽은 코드가 조용히 있었으면 그만인데, 그걸 근거로 **거짓 주석 둘**이 자랐다. 하나는 실제 손해다:

    // CloudSaveManager는 DontDestroyOnLoad라 씬 리로드 후에도 in-flight 요청이 완료된다.
    if (IsLoggedIn && !IsMasterAccount) CloudSaveManager.Instance?.SaveToCloud();

`Logout()`의 유일한 호출부는 **바로 다음 줄에서 씬을 재로드한다.** 발사한 코루틴은 그 오브젝트와
함께 죽는다. 로컬 세이브는 남으므로 잃는 것은 이번 세션의 클라우드 사본이지만, 다른 기기에서
로그인하면 한 세션 낡은 상태를 본다.

**수명을 바꾸지 않았다.** 루트로 올리면 재로드 때 `EnsureObject`가 중복을 만들고 그 중복이 Awake에서
자기를 `Destroy`해 **부트스트랩이 죽어 가는 컴포넌트를 배선**한다. 대신 죽은 줄을 지우고 수명을
클래스 주석에 명시했고, 로그아웃 재로드를 `IsSaving`이 내려갈 때까지(상한 3초) 미뤘다.

## P1 — `Instance != null`과 `Instance?.`가 다른 답을 낸다

싱글턴이 파기됐는데 static이 그 참조를 붙들고 있으면:

    if (X.Instance != null) X.Instance.Foo();   // 파괴 검사 → 안 부른다
    X.Instance?.Foo();                          // 진짜 null 검사 → **부른다**

저장소에 `Instance?.`가 19곳이다. 그리고 씬 재로드는 실제로 일어난다(로그아웃·계정삭제).
호출부를 하나씩 고치는 대신 뿌리에서 막았다 — 싱글턴 9종 전부 `OnDestroy`에서 static을 비운다.
감사 시점엔 9종 중 **1종**(`WorldChannelManager`)만 하고 있었다.

`Instance == this`가 아니라 `ReferenceEquals`를 쓴다. 앞엣것은 파괴 검사를 타서 파기 중에
false가 될 수 있고, 그러면 정작 비워야 할 때 안 비운다.

## 검사기 — `singleton_lint.py`

그 짝(싱글턴 선언 ↔ OnDestroy의 해제)을 고정한다. `ci_check`에 등록했다.
`Instance = null`을 하나 지워 넣어 FAIL을 확인하고 복원했다.

첫 파서는 파일의 첫 `public class`를 집어 `GachaBoxManager`를 **`GachaResult`로** 보고했다.
Instance 선언 **앞의 마지막** 클래스를 집도록 고쳤다.

검증: 컴파일 error CS 0 / warning CS 0, ci_check 통과(검사 10종), PlayMode 672/672.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
audit 큐 재생성분(후보 1건) — SubAreaWorldBuilder. 이 라운드로 큐가 비고,
`audit_candidates.py`도 더는 후보를 내지 않는다.

## P1 — [E] 삼자 충돌

같은 [E]를 세 시스템이 각자 Update에서 폴링한다.

- `CaptureInputController` — 곤충 잡기 (탐색 반경 8m)
- `WorldInteractionController` — 건물·NPC 상호작용
- `SubAreaWorldBuilder` — 서브에리어 진입 (진입 반경 12m)

앞의 둘은 `HasPriorityTarget`으로 서로 양보한다. **세 번째만 그 사슬 밖에 있었다.**
두 반경이 겹치는 자리는 흔하다 — 입구 근처에서 곤충을 잡으려고 [E]를 누르면 포획과 진입이
**동시에** 발화한다. 어느 쪽이 먼저 도는지는 Update 실행 순서라 정해져 있지 않고, 진입이
먼저면 플레이어가 (2000,0,2000)으로 순간이동한 뒤 2800m 밖 곤충으로 포획이 시작된다.

`CaptureInputController.HasCatchTarget`을 `HasPriorityTarget`과 같은 성격으로 열고,
**서브에리어가 양보한다.** 셋 중 여기가 양보하는 이유는 여기만 큰 전용 버튼이 따로 있기
때문이다(620×100, 하단 중앙) — [E]는 편의지 유일한 진입로가 아니다.

## P1 — `OnDisable`만 있고 `OnEnable`이 없다

`regionManager.SubAreaChanged`를 `OnDisable`에서 해지하는데 되살리는 곳이 없다.
`AutoWire`는 Bootstrap에서 한 번만 불리므로, 이 컴포넌트가 한 번이라도 꺼졌다 켜지면
서브에리어 진입·이탈이 **영구히 죽는다**(HospitalUI·BattleScreenUI가 같은 형태로 겪었다).

지금은 이 컴포넌트를 끄는 경로가 없어 발화하지 않는다. `subscription_lint`가 못 잡은 이유는
그 검사가 **UI 루트 하위만** 보기 때문이다 — 오프닝 다시보기가 `playUiRoot`만 토글하므로
그렇게 좁혔는데, 이 파일은 `World/` 아래다.

## P1 — 토스트가 노치 아래로 들어간다

진입/퇴장 토스트는 픽셀 좌표계인데(`UIScale.Begin()`을 쓰지 않는다) y가 `90f` 고정이었다.
`Mathf.Max(90f, UISafeLayout.Px.ContentTop)` — 인셋이 0인 데스크톱에서는 90이 그대로 이기고,
인셋이 있는 기기에서는 그만큼 내려간다.

머티리얼 정리, Y<-3 안전망, 25m 이탈의 단일 경로(`RequestExit`), 버튼의 클릭-이동 등록은 clean이다.

## 큐 소진

`## Uncovered`가 비었고 `audit_candidates.py`가 "후보 0건 — 미검토 .cs가 없고, 감사 이후 40줄
이상 바뀐 .cs도 없습니다"를 낸다. 빈 큐는 훅 둘을 함께 침묵시키므로(예전에 그렇게 두 달을
흘렸다) 그게 고장이 아니라 **소진**임을 진척 파일에 명시했다.

검증: 컴파일 error CS 0 / warning CS 0, ci_check 통과, PlayMode 672/672.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
마스터 계정은 진행을 건너뛰라고 있는 계정이라 매 로그인마다 전 지역 해금·전 수문장 격파·
재화 999999를 박는다. 그 상태로는 **스토리를 처음부터 검증할 수 없다** — 수문장을 미리
격파로 기록해 버려서 `GuardianDefeated`가 뜨지 않고 `GuardianDefeat` 트리거 비트 6개(`gd_*`)가
통째로 안 나온다. `requiredRegionId` 게이트도 전부 열린 지도 위에서는 무의미하다.

## 스위치 하나

로그인 화면 비밀번호 칸 아래에 체크박스. 마스터 계정 자체가 에디터/개발 빌드에서만
컴파일되므로(`MasterAccount.IsEnabled`) 이 줄도 그때만 그려진다.

    [V]  마스터 특권 없이 (처음부터)
    로그인하면 마스터 세이브를 지우고 처음부터 시작합니다

안내 문구는 세 상태를 구분한다 — 특히 **"지금 누르면 세이브가 지워지는가"를 누르기 전에**
알 수 있어야 한다.

## 켤 때 한 번만 지운다

초기화는 스위치를 **켜는 그 로그인**에서만 돈다. 켠 채로 다시 로그인하면 진행이 이어진다 —
그러지 않으면 로그아웃했다 들어올 때마다 판이 날아가는 함정이 된다.

**자동 로그인도 이 값을 본다.** 안 그러면 다음 콜드 스타트에 `TryAutoLogin`이 특권을 도로
박아 처음부터 하던 판이 조용히 뒤집힌다.

초기화가 지우는 것: 계정별 세이브 8종(`story_progress.json` 포함) + 계정별 PlayerPrefs
(해금·수문장·캐릭터 생성·퀘스트) + **계정 스코프가 아닌 재화 미러**. 마지막 것이 빠지면
`CashShopManager`의 Gems 1회 이전 경로가 남아 있던 999999를 새 지갑으로 빨아들인다.
해금/수문장 키는 지우면 `RegionManager`가 기본값(`meadow` · 수문장 없음)으로 떨어진다.

전역 미러 키 목록은 `ClearAllLocalData`와 공유한다(`ClearLegacyProgressMirrors`) — 두 벌
들면 한쪽만 늘어난다.

## 특권 판정을 둘로 가른다

`MasterPrivilegesActive = IsMasterAccount && !MasterPlainMode`를 새로 두고, **게임플레이
우회만** 그쪽으로 옮겼다.

| 옮김 (`MasterPrivilegesActive`) | 그대로 (`IsMasterAccount`) |
|---|---|
| `RegionManager` 지역 게이트 우회 | `CloudSaveManager` 클라우드 차단 |
| `CashShopManager` 무료 구매 | `WorldChannelManager` 채널 차단 |
| `CharacterOutfitManager` 무료 의상 ×2 | `WorldLobbyUI` 로비 건너뛰기 |
| `PlaySceneBootstrap` 보석 99999 | `LoginUI` 클라우드 로드 생략 · 계정 라벨 |

오른쪽은 특권이 아니라 **마스터 토큰이 Firebase에서 유효하지 않다**는 사실에서 오는 것들이라
스위치와 무관하다.

## 다시 처음부터 하려면

계정 설정 → 계정 삭제(마스터는 로컬만 지운다), 또는 체크 해제 후 로그인 → 다시 체크 후 로그인.

검증: 컴파일 error CS 0 / warning CS 0, ci_check 통과, PlayMode 672/672.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`q_move`("첫 걸음!")의 진행 판정이 `Vector3.Distance(now, lastFrame) > 1f` 하나였다.
한 프레임에 1m 이상이면 60fps에서 **초속 60m**를 요구한다 — 플레이어 이동 속도는
8m/s(의상 보정 최대 ×2)라 프레임당 0.13~0.27m다. 즉 게임이 시키는 대로 걸어서는
**첫 퀘스트가 절대 완료되지 않았다.** 참이 되는 경우는 워프뿐인데(서브에리어 진입
2000m 점프) 그건 "첫 걸음"이 아니다.

누적 거리로 바꾸고 워프를 잘라낸다 — 3m를 걸으면 1카운트, 한 프레임 5m 이상은
이동이 아니라 순간이동으로 보고 세지 않는다. 8m/s가 한 프레임에 5m를 가려면
0.6초짜리 프레임이어야 하므로 정상 이동은 안 잘린다.

판정부를 `MovementProgress`로 떼어 MonoBehaviour 없이 테스트로 고정했다
(`StoryStageTimeline`과 같은 성격). 퀘스트가 바뀔 때 누적을 리셋하는 가드도 넣었다 —
없으면 다른 퀘스트를 하는 동안 쌓인 거리가 다음 이동 퀘스트를 즉시 완료시킨다.

검증: quest_lint 10검사 0 FAIL, PlayMode 이동 진행 테스트 5건 신규.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FaunFXJmM1D21DU68DxLVb
스토리 바이블 3장이 이 대립 구도의 심장을 이렇게 적어 두었다: "그들의 남획이 또 다른
빈칸을 만든다. 종을 살려두지 않고 기록만 채우니 서식지가 죽고, 죽은 서식지에서 종이
사라져 빈칸이 는다." **그런데 그게 대사에만 있었다.** 명부회를 이겨도 세계는 한 픽셀도
변하지 않는다 — 스토리 보상이 캔디/EXP/아이템/곤충 넷뿐이고 스토리가 월드를 바꾸는
경로가 0개였다.

거점이 서 있는 동안 그 리전은 스폰이 10→3으로 줄고 땅이 마른 잿빛으로 탈색되며 독안개가
피어오른다. 지키는 하수를 꺾으면 충격파 → 구조물 붕괴 → 지면색 복귀 → 곤충 귀환이 한
시간축에서 돌고 귀환종 한 마리가 확정 스폰된다. 1막 「잦아듦」(기록이 바래는 천재)과
짝을 이루는 인재(人災)다.

파일럿 2곳: 산(명부회 채집장 · 검은 옷의 여자 Lv.32 · 무쇠사슴벌레) / 유적(명부회 창고 ·
검은 옷의 사내 Lv.34 · 푸른보석딱정벌레). 둘 다 이미 그 리전에 서 있고 대결 표와 소개
비트도 있어 신규 NPC 등록이 0건이다.

## 설계에서 갈린 지점 넷 — 전부 조용히 깨지는 자리다

**정화를 저장한다(파생 아님).** `CanBossDuel`은 이미 이긴 간부에게 무조건 false를 돌려준다.
격파 기록에서 파생하면 두 하수를 이미 이긴 세이브(2막 진행자 대부분)는 산·유적이 처음부터
정화 상태라 **오염을 한 번도 못 본다.** 대신 오염 거점이 살아 있는 리전에 서 있는 동안만
재도전을 열고, 보상은 첫 승리에만 준다(`firstWin`).

**대치를 NpcTalk으로 둔다.** `RegionEnter`로 두면 같은 리전의 본편 도착 비트와 동시에
자격을 얻는데 `ChapterRank("bl")`이 2000이라 언제나 본편이 이긴다 — **첫 진입에 절대
안 뜨고** 리전 밖으로 나갔다 와야 한다. 덤으로 "도착 즉시 보스전"도 막힌다(하수는 1막
비트로 이미 소개돼 있어, 미열람 비트가 없으면 기존 세이브는 첫 [E]에 Lv.32 전투가 뜬다).
그리고 서브에리어를 새로 만들 필요가 없어진다 — 원 겹침·방 기하·환경 분기가 통째로 빠진다.

**「지워진 개체」를 재사용하지 않는다.** 1막 리전에 켜면 같은 리전의 특정 종 포획 비트
(`ch5_apollo`·`ch6_relic`)가 이름표 "???"에 묻혀 사실상 진행 불가가 되고, 포획 피드백이
1막 한복판에서 "이름을 되찾아주었다"를 띄워 2막 반전이 샌다. 오염은 스폰 수로만 표현한다.
`BlightPolicy.MinActive`(=2)가 하한을 든다 — 0으로 내리면 그 리전의 포획·전투 비트가 전부
도달 지점을 잃어 캠페인이 영구 정지한다.

**BlightVfx가 RenderSettings를 안 만진다.** `SubAreaEnvironment`가 Start에 기본값을 1회
스냅샷하고 매 프레임 fog/ambient/카메라 배경을 덮는다 — 나중에 켠 오염 안개는 그 스냅샷에
없어 **서브에리어를 한 번 왕복하는 순간 조용히 사라진다.** 안개도 리전 안에 놓는 반투명
물체다. 전부 프리미티브 + 코루틴(에셋 0, 외부 라이브러리 0).

## 덤으로 고친 기존 버그

`InsectGame.DefeatedLedgerBosses`가 `SaveScope.ScopedStringPrefsKeys`에 빠져 있었다 —
`PrefsKey`를 거치면서 목록에는 없어, 계정을 삭제하고 같은 provider로 다시 로그인하면
간부 격파 기록이 부활한다. 바로 위 5개 키가 정확히 같은 이유로 고쳐진 전례가 그 주석에
있다. `MigrationVersion` 5→6과 함께 넣었다.

## 하네스

`blight_lint.py` 18검사 + `ci_check` 등록. story_lint의 사각지대 둘을 메운다 — 검사 17이
`bl` 챕터를 건너뛰고(`_chapter_ordinal`이 ch{N}/fin만 판다), 검사 3은 `RegionCleansed`
분기가 없어 param을 안 본다. **검출력 실측**: 재도전 예외 제거 / 하한 0 / 귀환종을 다른
리전 종으로 — 셋 다 FAIL로 잡고 원복 후 0 FAIL.

낡아서 따라 하면 FAIL 나는 문서 6건도 함께 고쳤다(add-story의 `oneShot` 템플릿과 트리거
9종·8검사, quest_lint 6검사→10, QuestType 15→20, 테스트 개수, `order`가 발화 순서에
안 쓰인다는 설명 — 지금은 CompareBeatPriority의 타이브레이커로 쓰인다).

스토리 82→92비트(신규 `bl` 챕터) · 퀘스트 31→33 · 트리거 10→11종 · QuestType 19→20종 ·
연출 2종 · 정화 컷신 1종.

검증: 컴파일 error CS 0 / warning CS 0, ci_check 11종 통과, PlayMode 704/704.
**3D 연출은 미검증** — LiveSceneCapture가 플레이어를 따라가는데 시작 지점이 초원이라
산·유적 거점 화면을 배치모드로 잡을 수 없다. 기기 확인 필요.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FaunFXJmM1D21DU68DxLVb
오염 거점 라운드가 만든 신규 파일. 감사 큐를 재생성해(코드가 움직인 뒤라 후보가 다시 나온다)
1순위로 잡았다.

## P1 — 런타임 머티리얼이 하나도 파기되지 않았다

`Mat()`/`Transparent()`가 호출마다 `new Material(shader)`를 만드는데 정리 코드는 GameObject만
지웠다 — 거점 1개당 20~23개, 정화 1회당 11개가 남는다. 루프 안에서 같은 색을 매번 새로
만든 것도 겹쳤다(기둥 4·상자 최대 8·조각 10이 전부 개별 인스턴스).

**이 저장소가 이미 한 번 고친 결함이다.** `SubAreaWorldBuilder.DestroySubAreaWorld` 주석이
"예전엔 Destroy(subAreaRoot)만 해서 오브젝트는 사라지고 머티리얼은 남았다"라고 적어 두었고,
텍스처 쪽도 세 파일에서 같은 이유로 고쳤다(로그아웃이 씬을 재로드해도 런타임 에셋은 남는다).
같은 `runtimeMaterials` 형태로 맞췄다 — `Track()`으로 등록을 강제하고 회수 경로 3곳
(붕괴 완주 / 충격파 종료 / OnDestroy). 색이 변하지 않는 파츠는 머티리얼을 공유해
거점당 23→11개, 충격파 11→2개로 줄었다.

## 자체 발견 2건 — 이쪽이 더 조용했다

**서브에리어 안에서 거점이 지어지면 그 리전은 세션 내내 탈색이 빠진다.** `CurrentRegion`은
서브에리어에 있는 동안에도 부모 리전을 그대로 가리키므로 `Update`가 거점을 세우는데, 그
시점엔 `HideMainWorld`가 `Region_*` 지면을 `SetActive(false)`로 꺼 둔 상태다.
`GameObject.Find`는 비활성 오브젝트를 못 찾아 `TintGround`가 조용히 실패하고, `sites`에 이미
등록돼 다시 시도하지도 않는다. 서브에리어 가드를 앞에 세웠다.

그리고 **그 실패가 로그를 하나도 안 남겼다** — 지면을 못 찾으면 경고를 찍는다.

## P2 5건

- **붕괴 상태 누수**: `sites.Remove`가 코루틴 **시작 전**이라, 코루틴이 완주하지 못하면
  (컴포넌트 비활성·파기) 구조물이 선 채 지면이 탈색된 채 남는다. 그 시점엔 `IsBlighted`가
  이미 false라 `Update`의 재건 경로도 닫혀 **되돌릴 방법이 없다.** 제거를 완주 뒤
  (`FinishCollapse`)로 옮기고 `collapsing` 가드를 뒀다.
- **비활성 시 예외**: `OnRegionCleansed`에 `isActiveAndEnabled` 가드가 없어 `StartCoroutine`이
  던진다(형제 구독자 `InsectSpawner.OnRegionCleansed`는 같은 가드를 이미 둔다). 그때는 연출을
  포기하고 상태만 즉시 정리한다 — 남겨 두는 쪽이 훨씬 나쁘다.
- **프리픽스**: 루트 이름을 `Scenery_BlightSite_*`로 바꿔 `HideMainWorld`의 기존 숨김/복원
  경로에 얹었다. 지금은 서브에리어 원점이 2km 밖이라 화면에 안 잡히지만 거리에 기대지 않는다.
- **안개 코루틴 무제한 지속**: 방문한 거점 수만큼 세션 내내 매 프레임 돌았다 — 보이지도 않는
  채로. 현재 리전이 아니면 0.5초 간격으로 쉰다.
- **문서 주석이 사실이 아니었다**: "정화 상태는 매번 다시 확인한다"라고 적었는데
  `sites.ContainsKey` 조기 반환이 그 앞에 있었다. 다른 기기에서 정화한 진행이 클라우드로
  들어오는 경로를 `Update`가 실제로 따라잡게 고쳤다(`ReloadFromDisk`는 로그인 직후 컷신이
  쏟아지지 않게 의도적으로 이벤트를 안 울린다).

## 거짓양성으로 기각한 것

전역 난수 오염 없음(`System.Random` 사용), 이벤트 대칭 정상, 매 프레임 힙 할당 없음
(`new Vector3`/`new Color`는 struct), `StopCoroutine` 대상 일치.

검증: 컴파일 error CS 0 / warning CS 0, ci_check 11종 통과, blight_lint 18검사 0 FAIL,
PlayMode 704/704. **3D 연출은 여전히 미검증** — 기기 확인 필요.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FaunFXJmM1D21DU68DxLVb
직전 라운드(오염 거점)가 남긴 결함 셋. 전부 무증상이라 검사기도 테스트도 통과하고 있었다.

## 1. q_blight_both가 영영 완료되지 않는다

`NotifyAction`은 **활성 퀘스트 하나만** 올린다(`TutorialQuestManager.cs:699` —
`ActiveQuest.type == type`일 때만 `IncrementProgress`). 그런데 체인을 이렇게 짰다:
`q_blight_first`(목표 1) → `q_blight_both`(목표 2). 거점은 둘뿐이라 첫 정화는 앞 퀘스트가
소비하고, 남은 정화는 하나 — **뒤 퀘스트는 1/2에서 멈춘 채 끝난다.**

목표를 1로 내렸다(체인 전체로 1 + 1 = 거점 2곳). 문구도 "두 곳을 모두"에서 "남은 곳도
마저"로 바꿨다.

`quest_lint`는 ID·배선·prereq 방향만 보고 "달성 가능한가"는 안 본다. `blight_lint`에
검사 18을 넣었다 — CleanseBlight 스토리 퀘스트의 목표 **합계**가 거점 수를 넘으면 FAIL.
검출력 실측: 목표를 2로 되돌려 합계 3/거점 2 → FAIL, 원복 → PASS.

## 2. 첫 정화 승리에서 보상 아이템을 알리지 않았다

정화가 성사되면 `SetResult`를 "거점이 무너진다"로 덮고 early return했다 — 아이템은
`AddItem`으로 실제 들어오는데 그걸 알리는 곳이 아무 데도 없다(AddItem은 토스트를 안 띄운다).
**받은 줄도 모르는 보상**이 된다. 두 문구를 합쳤다.

## 3. 그 토스트가 800×44 고정 상자의 raw GUI.Label이었다

길이를 데이터가 정한다(상대 이름 + 보상 아이템 + 거점 이름). 2번으로 문구가 길어지면서
잘림 위험도 함께 커졌다 — `rules/ui-layout.md`가 정확히 이 경우를 `UIHelper.LabelFit`으로
규정한다. 바꿨다.

`text_fit_lint`가 못 잡은 이유는 텍스트 출처 허용목록(`UNBOUNDED_TEXT`)에 `LastResultText`가
없어서다. 좁게 추가했다 — 목록을 넓히면 정상 라벨까지 247건이 걸린다는 게 그 파일의 교훈이라
한 토큰만 늘렸다. 검출력 실측: `GUI.Label`로 되돌리면 "래핑(fontSize 28) + 데이터 텍스트인데
높이 44 — 둘째 줄이 잘린다"로 잡고, LabelFit이면 통과.

검증: 컴파일 error CS 0 / warning CS 0, ci_check 11종 통과, blight_lint 19검사 0 FAIL,
text_fit_lint 통과, PlayMode 704/704.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FaunFXJmM1D21DU68DxLVb
큐 2순위. **매니저 자체는 clean이었다** — 상태 정합(AutoWire가 Awake 안에서 동기로 끝나
조회가 앞설 수 없다), 이벤트 4구독 전부 재구독 안전, 세이브 8지점, `CanRematchForBlight`와
`CleanseByBoss`가 같은 3중 검사를 써서 "재도전은 열렸는데 정화는 안 되는" 조합이 없음.
XML cref 오타 하나뿐이었다. 결함은 전부 그 **주변**에 있었다.

## P1 — 정화 재배치가 정작 그 리전을 안 채운다

`RepopulateCleansedRegion`의 탈출 조건은 `CountActiveInRegion(regionId)`인데,
`GetAvailableSpawnPoint`는 **선호 리전에 쓸 포인트가 없으면 아무 리전으로 폴백한다.**
스폰될 때마다 `NotifySpawned`가 포인트를 쿨다운에 넣으므로 6회 버스트 후반이 폴백에 걸리기
쉽고, 그러면 정화한 리전은 안 차는 채 이웃 리전에 여섯 마리까지 밀어 넣고도 루프가 안 끊긴다
— 정화의 핵심 연출("곤충이 돌아왔다")이 다른 데서 일어난다. `HasAvailableSpawnPoint(regionId)`
가드를 걸었다.

같은 코루틴이 1.2초 대기 뒤 **서브에리어를 재확인하지 않았다.** 형제 코루틴
`SpawnSubAreaInsectsDelayed`는 정확히 그 자리에 그 가드를 든다. 없으면 `HideMainWorld`가
숨긴 메인 월드 좌표에 곤충이 떠서 보이지도 않는 채 `maxActiveTotal` 예산만 먹는다.

## P2

- `TrySpawnSpecific`이 `maxActiveTotal`만 보고 리전 상한은 안 봤다 — 지금 유일한 호출부는
  상한이 막 넓어진 직후라 무해하지만, 다음 호출부가 조용히 넘긴다.
- `OnDisable`이 `RegionCleansed`를 해지만 하고 되살리는 곳이 없었다(기존 `SubAreaChanged`의
  비대칭을 그대로 복사했다). 둘을 `Subscribe()`로 묶고 `AutoWire`와 `OnEnable`이 함께 부른다
  — `-=` 뒤 `+=`라 중복 구독은 안 된다.
- `RegionBlightManager`의 `<see cref="Cleanse"/>`가 없는 메서드를 가리켰다(실제는
  `CleanseByBoss`). XML doc 생성을 켜는 순간 CS1574.
- `NotifyNpcDuelWon`이 재도전에도 발화하는 것은 **의도**다 — 저 서브 퀘스트가 세는 것은
  "이긴 상대 수"가 아니라 "이긴 대결 수"이고 아이 대결도 쿨다운 뒤 다시 세며, 오염 재도전은
  거점당 한 번뿐이라 파밍이 안 된다. 나중에 "비대칭"으로 보고 고쳐질 일이 없게 근거를 남겼다.

검증: 컴파일 error CS 0 / warning CS 0, ci_check 11종 통과, blight_lint 19검사 0 FAIL,
PlayMode 704/704.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FaunFXJmM1D21DU68DxLVb
사용자 요청 "남은 큐 다 진행해". P0:1, P1:9, P2:5 처리.

## 가장 큰 둘

**서브에리어 전용종이 필드 한복판에 뜨고 있었다** (P0, `InsectSpawner`)
서브에리어 스폰포인트가 **부모 리전 ID를 그대로 달고 있어서**(부트스트랩이 그렇게 만든다)
`RelocateSpawnPoints`의 리전 필터에 함께 걸리고, 8초마다 플레이어 링(10~43m)으로 끌려온다.
게이트 원 바깥 가장자리에만 있어야 할 전용종이 필드에 뜨고, 숲 기준 필드 포인트 5개 대
서브 포인트 8개라 **주변 스폰의 과반**이 그렇게 된다. 한 번 끌려오면 리전을 뜰 때까지
원위치로도 안 돌아온다. `SpawnPoint.isSubAreaPoint` 명시 플래그로 재배치에서 제외했고,
레벨 곡선 판정의 `regionInsectIds.Length <= 5` 휴리스틱도 같은 플래그로 교체했다
(지금은 오판이 없지만 리전 풀이 줄거나 서브에리어에 6번째 종이 붙으면 조용히 뒤집힌다).

**대결 결과 토스트가 한 번도 화면에 뜬 적이 없다** (P1, `NpcDuelController`)
`SetResult`가 `DuelEnded` 시점 — 전투 결과 화면이 **열리는** 프레임 — 에 시각을 찍는데,
결과 화면은 4초를 채우고 닫히고(`resultTimer > 4f`) 토스트 창은 3.5초다. 그 사이는 모달이라
그리지도 않는다. **필드가 보이기 0.5초 전에 토스트가 죽는다.** 아이 대결 보상도, 직전
라운드에 조립한 정화 문구도 도달한 적이 없다. 시각 스탬프를 버리고 1회 소비형
(`TryConsumeResult`)으로 바꿔 **필드가 처음 그릴 때** 창을 시작한다.

## AuthManager — P1 셋

- **로그아웃·계정삭제 뒤 세션이 부활한다.** `ClearAuth()`가 진행 중인 갱신 코루틴을 세우지
  않아, 응답이 오면 성공 분기가 `IsLoggedIn=true`+`SaveTokens()`로 **방금 지운 토큰을 다시
  쓴다.** 호출부가 씬 재로드 전 최대 3초를 기다리므로 창이 실제로 열려 있고, 되살아난
  토큰으로 다음 부팅의 `TryAutoLogin`이 방금 나간 계정으로 재로그인한다. 세대 카운터로 무효화.
- **네트워크 오류와 인증 거절이 같은 처리였다.** 문구만 갈라 놓고 둘 다 `ClearAuth()` —
  타임아웃 한 번에 refresh 토큰이 영구 삭제되고, **게스트는 비밀번호가 없어 복구 불가**라
  uid 스코프 세이브가 통째로 고아가 된다. 400/401/403만 로그아웃, 나머지는 세션 유지 +
  90초 뒤 재시도(실패 시 `idTokenAcquiredAt`를 지금으로 밀면 다음 시도가 50분 뒤가 된다).
- **계정 삭제 요청 둘만 timeout이 없었다**(나머지 7개는 15초). 무제한 대기에 걸리면
  `SetDeletionInProgress(true)`가 실패 분기에서만 풀리므로 세션 내내 클라우드 저장이 막힌 채
  확인 UI도 영영 응답을 못 받는다.
- P2: `Update`의 자동 갱신에 마스터 가드가 없어, `MasterAccount.RefreshToken`이 비어 있지
  않다는 이유로 통과했다가 약 50분 뒤 400을 받고 **플레이 중 강제 로그아웃**됐다.

## LoginUI — P1 셋

- 회원가입 중 **클라이언트 검증 실패가 폼을 통째로 파괴**했다. `RegisterWithEmail`이
  `AuthFailed`를 먼저 쏘는데 `OnAuthFailed`가 무조건 로그인 패널로 되돌려, 비밀번호가 짧다는
  안내를 받으려고 닉네임과 비밀번호 확인을 잃었다. 갱신 실패 경로에서 `isProcessing`이 안
  풀려 모든 버튼이 굳는 것도 함께 고쳤다.
- **캐릭터 생성 패널만 폰트 크기를 안 정했다.** 스타일이 공유 객체라 직전 패널이 남긴 값
  (회원가입 60/35, 로딩 50)을 물려받아 50f·30f 상자에서 제목과 모든 항목 이름이 잘렸다.
  정적 검사기는 설정이 다른 메서드에 있어 못 잡는다. 상자에 맞춘 값으로 고정하고 항목 이름
  상자를 120f로 넓혔다(라디오 행이 다음 줄이라 안전. "이름:"만 오른쪽에 필드가 있어 75f 유지).
- `LoadCompleted` 구독의 유일한 해제가 콜백 안이었는데, 세이브 충돌로 로드가 멈추면 그
  콜백이 영영 안 온다. 대기 플래그 + `OnEnable`/`OnDisable` 대칭으로 바꿨다
  (무조건 재구독하면 남의 로드 완료에 반응하므로 플래그가 필요하다).

## 검사기

`text_fit_lint`에 `errorMessage` 출처를 추가하니 **`RegionMapUI:693`에서 새 위반이 나왔다**
(wordWrap=false + 26pt + 데이터 문구) — 함께 고쳤다.

## 거짓양성으로 기각

LoginUI의 `errorMessage`가 55f 상자에서 둘째 줄이 잘린다는 지적은 과했다 — fontSize 30이라
30×1.6=48 < 55로 두 줄이 들어간다. 규칙 준수 차원에서 `LabelFit`은 적용했으나 증상은 없었다.

검증: 컴파일 error CS 0 / warning CS 0, ci_check 11종 통과, PlayMode 704/704.
**audit 큐 소진** — 코드가 더 움직인 뒤 `audit_candidates.py --emit-md`로 재생성.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FaunFXJmM1D21DU68DxLVb
`verify_coverage.py`가 "모든 .cs에 담당 에이전트가 있을 것"을 강제하는데 9개가 비어 있었다
(이번 세션 전부터). 형제 파일의 기존 배정을 선례로 삼아 채웠다 — 새 규칙을 만들지 않았다.

| 파일 | 담당 | 선례 |
|---|---|---|
| `Story/CutsceneLibrary.cs` | game-designer | `StoryStageLibrary`(연출 저작) |
| `Story/CutsceneDirector.cs` | game-designer | `StoryStageDirector`(연출 재생) |
| `Story/StoryObjective.cs` | game-designer | `StoryDirector`(트리거·진행) |
| `Story/StoryObjectiveTracker.cs` | game-designer | 〃 |
| `Story/CutsceneData.cs` | data-architect | `StoryBeat`(데이터 모델) |
| `Data/InsectExpansion2Definitions.cs` | data-architect | `InsectExpansionDefinitions` |
| `NPC/NpcBossDuels.cs` | game-designer | 고정 상대·레벨·보상은 디자인 수치 |
| `UI/InsectBrowseSort.cs` | ui-dev | `DexBrowseLayout`(UI 순수부) |
| `UI/StoryJournalUI.cs` | ui-dev | UI 렌더 |

셋은 실제로 둘 이상이 손대는 파일이라 `agent-coordination.md` 표에 **수정 경계**도 올렸다
(소유는 `agents/*.md` 불릿, 경계는 표 — `RegionData.cs`·`ItemData.cs`와 같은 모양):

- `CutsceneLibrary.cs` — game-designer(붙일 비트·자막 문구) / visual-dev(카메라 좌표·컷 길이)
- `CutsceneDirector.cs` — game-designer(재생 시점·지연 큐·프리즈) / visual-dev(프레이밍·딤) /
  ui-dev(자막 렌더·IModalUI·건너뛰기)
- `NpcBossDuels.cs` — game-designer(상대·레벨·보상) / battle-dev(`isFinal`의 BGM 분기)

검증: `verify_coverage.py` 216/216 · 미할당 0개, ci_check 11종 통과. 코드 변경 없음(문서만).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FaunFXJmM1D21DU68DxLVb
배치모드 캡처로 오염/정화를 실제로 찍어 확인했다. 코드는 다 맞는데 **화면에서 안 보이는**
것이 둘 있었고, 캡처 도구 자체도 산까지 갈 수 없었다.

## 오염이 안 보였다 — 채도만 뺐기 때문

`TintOf`가 채도만 뺐는데 파일럿 리전인 산은 지면이 이미 (0.35, 0.325, 0.24)인 무채색
갈색이다. 뺄 채도가 없어 결과가 (0.340, 0.319, 0.267) — 사실상 그대로이고 파랑은 오히려
올라간다. 오염과 정화를 나란히 찍었더니 **두 장이 구분되지 않았다.**

밝기를 함께 내린다(`DrainBrightness = 0.62`). 산 기준 (0.35, 0.325, 0.24) →
(0.248, 0.232, 0.191)로 약 30% 어두워진다. 캡처 픽셀 실측으로 다섯 지점 전부
**휘도 16 낮게** 나온다(수정 전이면 0에 가까웠다).

회귀를 테스트로 고정했다 — `TintOf_AlreadyDesaturatedGround_StillDarkensVisibly`(산의 실제
지면색으로 85% 미만 밝기 + 파랑 상승 금지)와 `TintOf_AlwaysDarkens`(4색 파라미터화).

## 안개가 풍선처럼 보였다

7개 × 지름 2.5 × 알파 0.30이라 구체 표면에 조명이 얹혀 덩어리로 읽혔고 구조물을 가려
거점이 무엇인지도 안 보였다. 11개 × 지름 0.42~1.17 × 알파 0.16으로 작고 옅게 흩었다.

## 캡처 도구가 초원 밖으로 못 갔다

`LiveSceneCapture`는 플레이어를 따라가는데 플레이어는 늘 초원에서 시작한다 — 산·유적에
있는 것은 원리적으로 찍을 수 없었다. 넷을 붙였다:

- `-captureRegion` / `-captureRegionAt` — 촬영 전에 그 리전으로 옮긴다. 좌표만 바꾸면
  `RegionManager.Update`가 리전 변경을 잡고 구독자가 따라온다.
- **지면 레이캐스트** — 처음엔 현재 y + 5로 옮겼더니 지형을 뚫고 들어가 카메라가 땅속에서
  갈색 화면만 찍었다. 위에서 아래로 쏴 지면에 붙인다.
- `-captureCleanseAt` — 실제 승리 경로(`CleanseByBoss`)로 정화해 전/후를 한 실행에서 찍는다.
  따로 돌리면 조명·시각·플레이어 위치가 달라 비교가 흐려진다.
- `-captureResetBlight` — 정화는 세이브에 남아서, 안 지우면 **두 번째 실행부터 거점이 아예
  안 선다**(실제로 빈 화면을 찍었다). **플레이 모드에서** 지운다 — 에디터 모드에는 AuthManager가
  없어 전역 키가 잡히는데 실행 중에는 계정 스코프 키(`…BlightCleansed.master_admin_001`)를
  쓴다. 처음엔 엉뚱한 키를 지우고 "초기화했다"고 로그만 남겼다.

## 직접 보기 — `BlightSiteDebugMenu`

`InsectGame/오염 거점/` 메뉴(플레이 중에만 활성): 산/유적으로 이동 · 현재 리전 정화 ·
정화 기록 초기화. 정화는 디버그 전용 뒷문이 아니라 `CleanseByBoss`를 그대로 부른다 —
뒷문을 따로 두면 그 길만 동작하고 진짜 경로는 안 보게 된다.

## `BlightVfx` 진단

거점 보스를 월드에서 못 찾으면 거점이 영영 안 서는데 아무 흔적도 안 남았다 — 리전당 한 번
경고한다(조회한 스토리 NPC 수를 함께 찍는다).

## 실화면으로 확인된 것

거점 구조물(기둥·가로대·그물·상자·장부대) 생성 / 오염 안개 / 지면 탈색 / `CleanseByBoss`
성공 / 붕괴 후 구조물 소멸 / **귀환종(무쇠사슴벌레) 스폰** / 곤충 개체 수 복구 /
정화 상태가 세이브에 남아 다음 실행까지 유지.

검증: 컴파일 error CS 0 / warning CS 0, ci_check 11종 통과, PlayMode 709/709(신규 5건).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FaunFXJmM1D21DU68DxLVb
`Assets/` 아래라 위의 `/Library`·`/Temp` 규칙에 안 걸리는데 커밋 대상은 아닌 것들이다.
실제로 이번 세션에서 `git add -A`에 쓸려 한 번 커밋됐다가 되돌렸다.

- `InitTestScene<guid>.unity` — PlayMode 테스트 러너가 실행마다 새 GUID로 만들고 지운다.
  테스트가 도는 중에 커밋하면 남의 실행 흔적이 섞인다.
- `Resources/GDKEditionAutoGen/` — `com.unity.microsoft.gdk`가 임포트 때 스스로 생성한다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FaunFXJmM1D21DU68DxLVb
`story_lint`는 Story.json을 **정적으로** 읽어 게이트·참조 무결성만 본다. 비트가 정말로
뜨는지는 못 본다 — 발화는 트리거 이벤트·prereq 열람·리전 게이트·우선순위 비교·
pendingBeatId 잠금·미뤄 둔 트리거 큐가 런타임에 맞물린 결과라, 하나만 어긋나도
예외도 경고도 없이 대사가 그냥 안 뜬다.

`LiveSceneCapture`도 이 자리를 못 덮는다. 대사창은 IMGUI라 카메라에 안 잡힌다 —
그 도구의 첫 번째 한계가 정확히 스토리 대사를 덮고 있었다.

그래서 화면 대신 **발화 자체**를 본다. `StoryBeatTriggered`를 구독한 채 게임의 실제
진입점을 순서대로 두드린다: `OnNpcTalked`(WorldInteractionController가 부르는 그것) ·
`AddCapturedInsect`(→InsectCaptured) · `BattleEnded` 구독 대리자 · `CleanseByBoss`.
뜬 대사는 `NpcDialogueUI.CloseModal`로 닫는다 — 안 닫으면 `DrainPendingTriggers`가
모달 가드에 막혀 다음 비트가 영영 안 온다.

거점 목록은 박아두지 않는다. `RegionData.HasBlightSite`를 런타임에 훑어 거점이 늘면
걸음도 저절로 는다. 선행 비트만 `CompleteBeat`로 채우고(검증 대상이 아니다) 무엇을
채웠는지 보고서에 적는다.

실행 결과 `bl_*` 10비트 전부 발화, 22검사 대신 실제 순서까지 남았다. 겸사겸사 드러난 것:
같은 트리거에 본편 비트가 함께 자격을 가지면 챕터 우선순위가 이겨 먼저 나간다
(산에서 포획하면 `ch5_thesis`가, 유적에서 이기면 `ch6_approach`가 먼저다).
그 자리 대사가 보스 격파가 아니라 "돌아오는 게 늘었다"는 관찰이라 순서가 뒤집혀도
문장이 성립한다 — 결함이 아니라 저작 순서다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FaunFXJmM1D21DU68DxLVb
거점을 셋으로 늘린다. 숲(Lv.12) 「명부회 그물터」 — 치는 현장 → 걷는 현장(산) →
쌓는 현장(유적) 순서가 그대로 리전 진행 순서가 된다.

**하수를 새로 둔 이유는 레벨이다.** 숲 입장이 Lv.12인데 여자·사내는 32/34라, 재사용하면
1막 초입에서 진행이 선다. 그래서 `ledger_thug_pin`(검은 옷의 청년, Lv.16)을 새로 세웠고
— 셋 중 유일하게 **변명한다**. "나는 치라니까 친 거야, 그 이상은 몰라." 여자는 방식을
주장하고 사내는 대답을 그만두는데, 말단인 청년만 책임을 위로 밀어 올린다. 세라의
"당신 몫이 아니면 누구 몫이죠?"가 플레이어가 조직을 처음 올려다보는 자리다.

**신규 스토리 NPC는 4지점이 아니라 6지점 등록이었다.** 앵커·대결 표·앰비언트·소개 비트
넷은 검사기가 보고 있었는데, `NpcManager.StoryNpcDisplayName`과
`NpcVisualBuilder.StoryNpcAppearance` **두 switch는 아무도 안 보고 있었다.** 둘 다
default가 마을 어르신이라, 빠뜨리면 검은 옷의 하수가 마을 어르신 이름표를 달고 백발에
모자를 쓰고 서 있어도 예외도 경고도 안 난다. 눈으로만 잡히는 자리였다.

→ `story_lint` 검사 22 추가(22검사). 등록 2곳을 지우고 돌려 FAIL 2건을 확인한 뒤 복원했다.
`game_facts`에 두 switch 추출기를 붙였다 — **선언**을 정규식으로 찾는다. 이름만으로 찾으면
같은 파일 위쪽 **호출부**가 먼저 걸려 본문이 아니라 빈 구간을 읽고 죽는다(실제로 그랬다).

앵커 각도는 손으로 골랐다(40°/0.34R). 숲엔 이미 셋이 서 있고, 서브에리어 진입 반경 안에
서면 말을 걸려다 구역에 빨려 들어간다 — 깊은 숲 36.8m·동굴 33.6m·세라 21.4m로 전부 밖이다.

귀환종은 로살리아하늘소. 도감에 이름은 있어도 실물을 본 사람이 거의 없는 종이라
정화 직후 눈앞에 나타나는 것 자체가 사건이 된다.

검증:
- 걸음 하네스 — `bl_*` **15비트 전부 실제 발화**, 순서대로 (거점 3개 × 5)
- 실화면 A/B(같은 카메라) — 그물·기둥·상자·연무가 서고, 정화 후 통째로 사라졌다.
  빈 지면 26000px 평균 휘도 **48.5 → 62.4(+29%)**
- ci_check 11검사 통과 · story_lint 22 PASS · blight_lint 19 PASS(거점 3개)
- PlayMode 709/709 · 컴파일 에러 0

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FaunFXJmM1D21DU68DxLVb
## 서사 판단

간부(집게·저울)에게도 오염 거점을 하나씩 주는 게 자연스러운 다음 수처럼 보였다.
**안 하기로 했다.** 근거 셋은 StoryBible이 이미 적어 둔 것에서 나온다:

1. **원인이 이미 둘인데 셋이 되면 아무것도 안 읽힌다.** 2막 리전의 황폐는 「빈칸/무명」이
   만든 것이다 — hollow는 이름을 빼앗겨 소리가 없는 땅, emberfall은 기록이 불탄 땅.
   거기에 명부회 오염을 얹으면 **눈앞의 마른 땅이 누구 탓인지 구분할 수 없다.**
   1막 거점이 성립한 이유가 정확히 그 구분이었다(천재 옆에 인재를 나란히).
2. **명부회는 2막에서 무너지는 중이다.** 먹이 ch10에서 이탈하고 관장이 ch12에서 자백한다.
   거점을 하나씩 부수는 반복 구조는 조직이 건재하다는 전제 위에서만 선다.
3. **거점 셋이 이미 완결형이다.** 치고(숲) → 걷어 가고(산) → 쌓는다(유적). 넷째는 반복이다.

## 대신 전투 안에서 만난다 — 「장부」

명부회의 강령이 "모든 종을 지금 당장 장부에 올려라"이니, 싸우는 방식도 **적는 것**이어야
한다. 스탯을 부풀려 벽을 만드는 대신 **같은 행동을 되풀이하는 것을 적어 두었다가 그 자리를
친다.** 반복 +2 / 변경 −1, 임계에서 그 턴 공격 ×1.6, 두 칸 전부터 게이지가 붉어진다.

**해제법이 곧 주제다** — 명부회는 붙잡아 가두며 적고 플레이어는 만나고 놓아주며 새긴다.
되풀이하지 않는 쪽이 장부에 안 잡힌다.

**임계가 곧 계급이다**(9/7/7/6/5/4). 값은 `NpcBossDuels` 표가 들고 순수부
`LedgerPressure`에는 인물 ID가 **하나도 없다** — `BlightPolicy`가 리전 ID를 안 두는 것과
같은 이유다. 신원의 단일 출처가 둘이 되면 조용히 어긋난다.

## 실측이 결함을 하나 잡았다

배선은 테스트로 못 본다(`ArmLedger`를 안 부르면 **증상 없이** 평범한 전투가 된다). 그래서
`LedgerDuelProbe`로 실제 보스전을 열어 24턴을 두드려 봤고, 거기서 드러났다 —
버프·회복·독·기절처럼 **곱할 피해가 없는 턴에 터지면 게이지만 비워지고 아무 일도 안 난다.**
정독 4회 중 1회가 그렇게 낭비돼 평균 배율이 1.19로 희석됐다. 경고를 보고 각오했는데 그냥
넘어가면 압박이 실력이 아니라 운으로 읽힌다.

→ **칠 것이 없으면 장부를 든 채 기다린다.** 고친 뒤 실측 **1.59배**(설계 1.60),
정독 4회 전부 피해 턴에 착지. 게이지가 9/9로 한 턴 머무는 것이 로그에 그대로 남는다.

프로브 자체도 두 번 틀렸다가 고쳤다: 발동은 게이지가 즉시 0으로 돌아가 밖에서 안 보여
누적 카운터(`LedgerReadCount`)를 뚫었고, 회복을 턴 끝에 두었더니 `Heal`이 0 HP를 못 살려
(기절은 병원 전용) 한 번 쓰러진 뒤 표본이 전부 0이 됐다 — 턴 시작으로 옮겼다.

검증: ci_check 11검사 · PlayMode **737/737**(+28) · 컴파일 에러 0 · 커버리지 220/220

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FaunFXJmM1D21DU68DxLVb
지난 라운드에 넣은 「장부」 압박을 다시 읽어 결함 셋을 찾았다. 하나는 그 라운드의
수정이 스스로 만든 것이다.

1. **빗나간 정독이 낭비됐다.** "때리는 턴에만 쓴다"를 스킬 효과 종류를 **열거해**
   ApplySkill 전에 예측했는데, 그 예측은 명중 판정을 못 본다. mantis_green(검은 옷의
   여자)·hornet_asian(검은 옷의 사내) — 하필 거점 보스 둘 — 은 Lv.32/34라 주력기가
   storm이고 그 명중이 0.9다. 나머지 넷은 시그니처기(명중 1.0)라 증상이 안 났다.
   이제 열거하지 않는다: GetDamage가 배율을 실제로 곱한 사실을 남기고 UseEnemyTurn이
   결과를 보고 장부를 비운다. 관찰이지 예측이 아니라 스킬이 늘어도 낡지 않는다.

2. **가장 위험한 순간이 안전색으로 보였다.** IsWarning은 정의상 IsFull일 때 false다.
   발동이 같은 턴에 곧바로 일어나던 동안에는 그 상태가 한 프레임도 안 보였는데,
   1번 수정으로 정독을 들고 기다리게 되면서 여러 턴 이어지는 상태가 됐다 — 그때
   게이지가 붉은색에서 평상색으로 되돌아갔다. LedgerAlert 3단계로 가르고 단조성을
   테스트로 고정했다.

3. **기절로 건너뛴 턴도 장부에 적혔다.** 손 못 댄 턴에 장부가 차고, 반대로 아무 키나
   눌러 완화를 공짜로 얻는 길도 함께 열려 있었다.

검사 20을 blight_lint에 더했다(배율 곱셈이 GetDamage 한 곳 / 소모가 ApplySkill 뒤).
결함 둘을 되살려 FAIL이 나는 것까지 확인했다 — 이 회귀는 테스트로는 안 잡힌다.

프로브도 다섯 군데 고쳤다. 거짓 숫자를 두 번 냈다: 표본이 최대 HP에 잘렸는데
평균을 내 "1.00배"를, 정독이 매번 storm 턴에만 떨어지는 걸 모르고 기본타와 나눠
"1.83배"를 냈다. 이제 잘린 표본은 빼고, 성한 표본이 없으면 숫자를 내지 않으며,
배율은 **같은 적 행동끼리만** 대조한다. 실측 1.60~1.62배(설계 1.60).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FaunFXJmM1D21DU68DxLVb
잠긴 리전 경계에서 튕길 때 뜨던 배너가 "○○ 진입에 레벨이 부족합니다!"였다.
그런데 RegionManager.IsRegionAccessible은 레벨을 전혀 보지 않는다 — unlockedRegions
집합만 본다. requiredLevel은 그 리전의 스폰 레벨과 지도 난이도 라벨을 정하는 값이라,
문구를 믿은 플레이어는 아무리 레벨을 올려도 열리지 않는 문 앞에서 레벨업을 하러 간다.

같은 상황에서 지도(RegionMapUI)는 이미 "이전 지역의 수문장 격파"라고 옳게 말하고
있었다. 두 화면이 같은 잠금에 서로 다른 이유를 대고 있었던 셈이다.

- PlayerMovement.BuildBlockedMessage: "○○ — △△의 수문장 □□에게 이겨야 열립니다".
  열쇠는 잠긴 리전 안이 아니라 **바로 앞 리전**에 있으므로(DefeatGuardian이 다음
  리전을 여는 구조) 앞 리전의 수문장을 짚어 준다. 조사는 "…에게"로 잇는다 —
  이름 받침이 갈려서("사마귀"/"장수말벌") 을/를 어느 쪽으로 고정해도 절반은 어색해진다.
- RegionManager.GetGatekeeperRegion: 그 앞 리전을 주는 public 조회.
  GetPreviousRegionId는 private으로 남긴다 — RegionProgressionTests가 그 시그니처
  문자열로 본문을 찾아 진행 체인을 검사한다.
- 문구를 리전당 1회만 만든다. IsBlockedPosition은 벽에 붙어 있는 동안 매 프레임
  2회 들어오므로 캐시 키 없이 보간하면 걷는 내내 문자열이 쌓인다(필드 주석이 이미
  "설정 시 1회만 만든다"고 적어 두고 지키지 않고 있었다).
- RegionMapUI 정보 패널의 "요구 레벨" → "권장 레벨". 같은 거짓말의 다른 자리다.

ci_check 12검사 통과. Unity 배치 컴파일은 실행 권한이 거부되어 정적 점검으로 확인.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PeZ8JgSDn46GK6Ei7QEfwA
이 게임의 진행은 리전 13개와 서브에리어 26개를 거치는 이동이 전부인데, 비트 97개가
그 지형에 고르게 얹혀 있지 않았다. 실측하면 세 군데가 끊겨 있다.

- 스토리가 붙은 서브에리어는 26개 중 11개. 초원·연못·습지·꽃밭 8개는 전부 0이라
  플레이어는 첫 20레벨 동안 "서브에리어 = 선택 사항인 스폰 창고"를 학습하고 나서
  숲부터 갑자기 필수 진행로를 만난다(진입이 [E] 명시 선택이라 더 놓치기 쉽다).
- ch2·ch5에 격전 비트가 없고 ch2~ch6에 여운 비트가 0건이었다. 저작을 안 해서가 아니라
  **말 걸 상대가 없어서**다 — 1막 동행자 앵커는 초원 2·숲 1뿐이라 연못·습지·산·유적에서
  세라와 라온은 대사만 있고 몸이 없는 목소리였다.
- 꽃밭은 비트 1개·NPC 0명·서브에리어 스토리 0. 1막 유일한 떡밥("왜 꽃밭만 무사한
  걸까?")의 무대인데 답은 ch11(Lv.58)에 있고 꽃밭은 Lv.13쯤 열린다(requiredLevel은
  게이트가 아니라 스폰 레벨 라벨이다 — PlayerMovement가 해금 집합만 본다).
  게다가 진행 사슬 밖이라 안 가도 클리어된다.

신규 20비트 + 동행자 앵커 5곳으로 메웠다. 새 trigger.type도 새 인물도 만들지 않았다 —
기존 3인만 재배치했으므로 표시명·외형 switch의 무증상 함정(story_lint 검사 22)을
애초에 안 건드린다.

- 꽃밭 5비트. garden_fence가 떡밥 절반을 회수하고("누가 심고, 누가 다듬었어요")
  ch11_confront가 나머지를 닫는다. 세라가 화자인 셋은 requiredBeatId: ch3_reach_forest로
  잠근다 — 세라는 숲에서 합류하는데 꽃밭이 훨씬 먼저 열려서, 안 잠그면 만난 적 없는
  사람이 말한다. gd_garden이 지금까지 아무 의미 없던 꽃밭 수문장에 값을 준다
  (GetNextRegionId("garden")이 null이라 격파해도 해금이 없다).
- 서브에리어 학습 3비트(초원 동굴·갈대 밀림·안개 습지). 연못·습지의 빈 대치 슬롯도
  함께 채운다. ch1_cave의 "도감의 빈칸은 대개 이런 데 숨어 있어"가 2막 핵심 용어의 씨앗.
- 격전 2 + 여운 5. 여운 체인에 1막 다섯 칸을 끼우면서 기존 ch7_echo·ch8_echo의
  prerequisiteBeatId **두 필드만** 바꿨다(대사는 한 줄도 안 고쳤다 — 이미 본 세이브는
  seenBeatIds에 박혀 갱신이 안 닿는다). 다섯 칸 전부 그 챕터 도착 비트를
  requiredBeatId로 물어 검사 16·17을 통과한다.
- 수문장 5비트(연못·숲·습지·산·이름없는자리). 전부 leaf. GuardianDefeat는 리전당
  일생 1회라 이미 격파한 세이브에는 안 보이지만 진행 정지는 없다.
- VillageBuilder 앵커 5곳. 서브에리어 진입 반경 밖으로 잡았다(가장 가까운 곳까지
  +8.0~11.8m). 반경 안에 서면 말을 걸려다 구역에 빨려 들어간다.
- st_garden_scholar_glass 연출. 「앞서 가는 연출」은 예비 울타리 두 곳(꽃밭·우듬지)에만
  쓰는 문법으로 정리했다.

지도와 땅도 진행 사슬에 맞췄다. 1막 사슬 6링크 중 pond→forest와 swamp→mountain은
지도 간선에도 월드 길에도 없었다 — 다음 목적지로 가는 길만 골라서 안 보였다. 공교롭게
공간상 가장 가까운 쌍이 meadow―swamp(101m)이고 사슬인 pond→forest는 280m라, 지도를
훑으면 사슬이 아닌 쪽이 오히려 길처럼 보인다. 그때 길이 경사면 아래에 깔려 있던 것도
같이 고쳤다(지면 장식 y: 0.00 베이스 / 0.08 리전 평면 / 0.15 경사면 / 0.18 길).
경사면은 두 중심 사이 가운데 40%를 덮으므로 길이 한복판에서 끊겨 보였다.

--- 최종장이 도달 불가였다 (P0) ---

fin_seal은 BattleWin 무param이라 무명과 대면한 직후 nameless에서 아무 야생 곤충이나
이기면 엔딩 대사·컷신·보상이 한꺼번에 터졌다. 원인은 저작이 아니라 트리거였다 —
EvaluateTriggers의 BattleWin이 param을 아예 안 봤다("곤충 지정은 미지원"). CaptureInsect는
처음부터 봤는데 전투 쪽만 빠져 있었다. 같은 규약으로 맞추고 fin_seal에 mantis_unnamed를 물렸다.

그러자 더 깊은 곳이 드러났다. **mantis_unnamed는 Legendary**이고
CaptureChoiceUI.IsRaidTarget은 등급만 보고 Epic·Legendary면 포획도 1v1도 막고 레이드만
연다. 그런데 레이드 승리는 AddCapturedInsect(→ CaptureInsect 트리거)만 흘리고
BattleEnded는 울리지 않았다. 그래서 ch2_emperor·ch4_firefly(둘 다 Epic 포획)는 멀쩡한데
BattleWin만 도달 불가였고, fin_seal은 스파인이라(ch12_clash의 prereq · fin_epilogue의
게이트) 캠페인이 엔딩 직전에서 영구 정지한다.

이 결함이 무서운 건 **앞선 검증을 그대로 통과했다는 것**이다. 걸음 도구가 1v1을
시뮬레이션해 BattleEnded를 직접 쏘았기 때문에 저작·트리거·발화 순서가 전부 맞다고
나왔다 — 플레이어가 그 자리에 도달할 수 있는가만 아무도 안 봤다.

- StoryDirector가 RaidBattleController.RaidEnded를 구독해 보스 종과 함께 BattleWin을
  흘린다. 무param 비트에도 이롭다 — 레이드만 이기고 넘어간 플레이어가 "그곳에서 전투
  승리"를 통째로 건너뛰던 것도 함께 닫혔다.
- BattleEnded가 Action<bool>이라 종을 못 싣는다. 시그니처를 바꾸면 구독자 셋과 배치
  도구의 리플렉션이 따라오므로 InsectBattleController.EnemyInsectId로 노출하고
  **미룰 때 param에 실었다** — 발화는 결과 화면 뒤로 미뤄지는데 그때 다시 물으면
  이미 다음 전투가 시작됐을 수 있다.
- DescribeActionObjective가 종 이름을 실어 "이름 없는 자리에서 이름 없는 사마귀
  쓰러뜨리기"로 안내한다. 옛 "…에서 전투 승리"는 아무거나 이기면 되던 시절의 정확한 안내였다.

--- 검사기·검증 도구 ---

- story_lint 검사 11을 포획 전용에서 포획·전투 양쪽으로 넓혔다. 서식하지 않는 종을
  BattleWin에 물리면 스파인이 끊긴다.
- story_lint 검사 23 신설 — 레이드→BattleWin 배선의 구독·해제·주입 3지점. 하나만
  빠져도 무증상이다(수정 전 소스에 걸어 보면 3건 FAIL).
- game_facts가 CreateGlassWall도 방 크기로 읽는다. 온실만 그 함수로 봉해져 있어
  subarea_room_bounds에서 통째로 빠졌고, 검사 21이 half is None을 건너뛰는 탓에
  그 방의 연출 워프 좌표는 아무도 검사하지 않았다(검사 대상 방 25 → 26개).
- StoryBeatWalkthrough에 -walkMode campaign 추가. 거점 아크만 걷던 탓에 SubAreaEnter와
  GuardianDefeat는 구동부가 아예 없었다 — 본편에서 가장 많이 쓰는 두 트리거인데도.
  최종장 구간에는 잡몹을 이겨도 엔딩이 안 뜨는 걸음을 먼저 두고, 그다음 레이드로
  이겨 fin_seal이 뜨는 걸음을 둔다.

검증: PlayMode 733/733, 컴파일 error CS 0, ci_check 통과, story_lint 23 PASS(0 FAIL/0 WARN),
blight_lint 20 PASS. 걸음 campaign(신규 20비트 + 최종장 3걸음)·blight 모두 전 걸음 통과.
길 연결은 조감 캡처로 확인.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PeZ8JgSDn46GK6Ei7QEfwA
`MaxActiveFor`가 하한을 **보장 수량**으로 쓰고 있었다.

    return Mathf.Max(MinActive, baseMax / ScarcityDivisor);

`baseMax=1`이면 `Max(2, 0)` = **2**다. 멀쩡한 리전이 1인데 오염된 리전이 2 —
줄이는 정책이 늘리는 정책이 된다. 오염이 게임플레이로 드러나는 유일한 수단이
정반대로 뒤집힌다.

도달 가능한 값이다. `GameplayTuningProfile.maxActivePerRegion`이 `[Range(1, 15)]`라
인스펙터에서 1을 넣을 수 있고, `InsectSpawner.ApplyTuning`이 `Mathf.Max(1, …)`로
그대로 받는다. 기본값이 10이라 라이브에서는 안 났을 뿐이다.

- BlightPolicy.MaxActiveFor: `Mathf.Min(baseMax, …)`로 가둔다.
  1→1 / 2→2 / 3→2 / 10→3 / 15→5. 정의역 전체에서 오염 ≤ 정상이 성립한다.

--- 테스트가 결함을 통과시키고 있었다 ---

`MaxActiveFor_Blighted_IsScarcerThanClean`이 `baseMax=10` 하나만 봤다. 튜닝 Range
전체(1~15)를 도는 검사를 더했더니 **기존 테스트와 정면으로 부딪혔다**:
`NeverDropsBelowMinActive(1)`이 "어떤 기본값에서도 MinActive(2) 이상"을 요구해,
`baseMax=1`에서 '2 이상'과 '1 이하'가 동시에 성립할 수 없었다.

판정은 그 테스트의 **자기 문서**가 줬다 — "오염이 스폰을 0으로 만들면 그 리전의
포획·전투 비트가 영영 발화하지 못한다". 지키려는 건 **0이 아님**이고 MinActive는
그 수단이다. 수단을 목적으로 적어 둔 탓에 결함이 통과하고 있었다.

- 하한을 `min(MinActive, baseMax)`로 정확히 다시 적고, 절대 불변식(**1 이상**)은
  따로 못 박았다. MinActive는 도달 가능한 곳(baseMax >= 2)에서 그대로 강제된다.
- 정의역 전체를 도는 `NeverExceedsClean_AcrossTuningRange` 추가.

`TintOf`의 누적 탈색 의심은 거짓양성이었다 — `BlightVfx`가 세 호출부 모두
보관된 `site.groundOriginal`을 넘기므로 반복 적용되지 않는다.

검증: PlayMode 734/734, 컴파일 error CS 0, ci_check 통과, story_lint 23 PASS, blight_lint 20 PASS.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PeZ8JgSDn46GK6Ei7QEfwA
VillageBuilder는 체크리스트로는 clean이었다. 생명주기 메서드도 조회도 이벤트도
싱글턴 참조도 없는 순수 빌더다. 결함은 머티리얼 수명에 있었다.

    private readonly Dictionary<Color, Material> materialCache = ...

색상당 1개씩 `new Material(...)`을 만들어 담아 두는데 `OnDestroy`가 **아예 없었다.**
GameObject를 지워도 머티리얼은 안 지워지고, 이 게임은 로그아웃·계정삭제가 씬을
재로드하므로(그래서 싱글턴 9종이 OnDestroy에서 static을 비운다) 재로드마다
마을 한 벌 분량이 통째로 샌다.

형제 빌더 둘은 더 나빴다 — **캐시조차 없다.**

  RegionTerrainBuilder  Mat()        호출부 88   추적 없음   회수 없음
  WorldTerrainBuilder   CreateMat()  호출부 16   추적 없음   회수 없음

직전 감사 라운드가 UI 텍스처에서 "같은 누수를 형제 둘에서도 발견해 함께 고쳤다"고
적은 그 형태다. 이번엔 월드 빌더 쪽이었다.

--- 셋을 고치되 방식이 갈린다 ---

- VillageBuilder: `OnDestroy`가 `materialCache.Values`를 회수한다. 색상 캐시가
  성립하는 이유는 `Mat()`이 생성 시 색만 설정하고 그 뒤 아무도 변형하지 않아서다.
- RegionTerrainBuilder / WorldTerrainBuilder: `runtimeMaterials` 목록으로 추적하고
  `OnDestroy`에서 회수한다. **여기는 색상 캐시를 쓸 수 없다** — `SetTransparent`가
  물·얼음 머티리얼을 뒤에서 변형하므로, 색으로 공유하면 같은 색의 불투명 지형까지
  함께 투명해진다.

회수 형태는 SubAreaWorldBuilder·BlightVfx가 이미 쓰는 것과 같다(null 가드 후 Destroy,
그다음 Clear). 셋 다 Bootstrap에서 1회만 불리므로 목록은 유한하다.

--- 거짓양성 2건 ---

- `BlightPolicy.TintOf`의 누적 탈색: BlightVfx가 세 호출부 모두 보관된
  `site.groundOriginal`을 넘기므로 반복 적용되지 않는다.
- `SubAreaWorldBuilder` 미회수: OnDestroy가 `DestroySubAreaWorld()`를 거쳐 회수한다.

--- 검사기는 두지 않았다 (P2 보고) ---

저장소 전수로 `new Material(`/`new Texture2D(`를 세면 26파일 중 18이 "미회수"로
잡히는데, 그 상당수가 static 캐시(UIHelper.texCache 등)이거나 헬퍼를 거쳐 회수하는
경우다. 규칙이 크리스프하지 않아 검사기로 만들면 거짓양성이 크다 —
`singleton_lint`처럼 급소가 또렷한 자리와 다르다. 별도 라운드로 다룰 값어치는 있다.

검증: PlayMode 734/734, 컴파일 error CS 0, ci_check 통과.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PeZ8JgSDn46GK6Ei7QEfwA
…clean)

큐 재생성분 6건 중 남은 셋을 돌렸다. **세 파일 다 P0/P1 0건**이고,
그 사실 자체가 이번 라운드에서 가장 쓸모 있는 결과다.

- **BattleScreenUI**(3851줄, 큐 최고점 score 372): 방어가 촘촘했다. `OnGUI` 매프레임
  할당 0, `FindFirstObjectByType`는 null 캐싱, 과거 P0 둘(`OnEnable` 재구독 ·
  `OnDisable` 슬로우모션 복구)도 그대로다. 그리기 시점에 공유 `GUIStyle`을 변형하는
  곳은 장부 게이지 하나뿐이고 색을 정확히 되돌린다.
  **스킬 문서가 적은 그대로였다 — 채점은 "열어볼 순서"이지 "무엇이 문제인지"가 아니다.**

- **RegionManager**(403줄): 리전 원 겹침 2쌍이 나왔지만(meadow―swamp 41.9m ·
  swamp―mountain 0.15m) `SecondActRegions_DoNotOverlapAnyRegion`이 주석으로 **명시
  제외**한 기존 배치다. swamp 중심이 meadow 밖 25.6m라 `RegionEnter swamp`가 도달
  가능해 진행 정지도 없다 — 발견되지 않은 결함이 아니라 문서화된 수용 부채다.

- **StoryDirector**(849줄): 이번 세션에 바뀐 67줄이 대상이었다. 미뤄 둔 트리거 체계는
  견고하다 — 중복 제거, 12초 안전망(`Time.unscaledDeltaTime`), 모달/컷신 대기,
  `RaidEnded` 구독↔해제 짝. 종 param이 붙으며 중복 제거가 덜 뭉치게 된 것은 회귀가
  아니다: 승리 둘이 각각 평가되고 `IsSeen`이 이중 발화를 막는다.

--- P2 보고 (결정 위임) ---

`OnInsectCaptured`만 **즉시** 발화한다. `BattleWin`·`GuardianDefeat`·`RegionCleansed`는
전부 결과 화면 뒤로 미루는데 포획만 그 바깥에 있다.

전투 중 포획과 레이드 승리 포획은 결과 화면 **직전**에 일어나고(`OnRaidVictory` →
`RaidEnded`), `BattleScreenUI`는 자기 그리기를 모달로 억제하지 않으며 `NpcDialogueUI`는
`IModalUI`로 등록된다 — 대사창이 획득 EXP·캔디 패널 **위로** 열린다. 미루기를 도입한
그 이유("무엇을 얻었는지 못 본 채 대사를 읽게 된다") 그대로다.

고치려면 "결과 화면이 **열렸다**"를 알리는 짝 API가 필요하다(지금은 닫힘 통지만 있다).
UI 2파일 + StoryDirector에 걸치는 설계 변경이고 IMGUI라 배치 캡처로 결과를 눈으로 볼 수
없어, 감사 자동 수정 대상이 아니다.

`GUI.DrawTexture(whiteTexture)` 617곳(BattleScreenUI 327 · RaidBattleUI.Draw 290)도
같은 이유로 P2다 — `ui-layout.md`의 `UISurface` 규칙과 어긋나지만 전투 화면 2종의
렌더 방식 자체다.

--- 큐 상태 ---

마지막 라운드가 만든 변경까지 다시 돌린 뒤 `audit_candidates.py`가 **후보 0건**을 냈다
(미검토 `.cs` 없음 · 감사 이후 40줄 이상 바뀐 `.cs` 없음). Uncovered 절에 소진을
표기하면서, `--emit-md`가 stdout으로만 낸다는 것도 함께 적었다 — 이번에 그걸 몰라
출력만 보고 넘어갈 뻔했다.

검증: ci_check 통과, story_lint 23 PASS(0 FAIL/0 WARN), PlayMode 734/734.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PeZ8JgSDn46GK6Ei7QEfwA
`BattleWin`·`GuardianDefeat`·`RegionCleansed`는 전부 전투 결과 화면 뒤로 미루는데
`OnInsectCaptured`만 그 바깥에서 **즉시** 쏘고 있었다. 전투 중 포획과 레이드 승리 포획은
결과 화면 **직전**에 일어나고(`OnRaidVictory` → `RaidEnded`), `BattleScreenUI`는 자기
그리기를 모달로 억제하지 않으며 `NpcDialogueUI`는 `IModalUI`로 등록된다 — 대사창이
획득 EXP·캔디 패널 위로 열린다. 미루기를 도입한 그 이유 그대로다.

--- 왜 컨트롤러에 묻지 않았나 ---

"지금 전투 중인가"를 물을 수 있는 신호가 **양쪽 다 못 쓴다**:

  InsectBattleController.IsBattleInProgress()  playerStats/enemyStats가 한 번도
                                               null이 되지 않아 첫 전투 이후 늘 true
  RaidBattleController.IsActive                포획이 일어나는 OnRaidVictory보다
                                               **먼저** false가 된다

그래서 상태를 묻지 않고 **한 프레임 안의 순서**를 쓴다. 포획은 `Update` 중에 들어오고
전투 종료(`BattleEnded`/`RaidEnded`)는 **같은 프레임 뒤에** 온다 — `LateUpdate`까지
오면 큐를 보고 "전투 안에서 잡았는가"를 알 수 있다.

- 전투가 아니면 **지연이 없다.** 필드·미니게임 포획은 큐가 비어 있어 그 자리에서 쏜다.
  무조건 `DeferTrigger`로 보내면 종료 통지가 안 와서 12초를 기다린다(그래서 기각했다).
- 포획이 전투 종료보다 먼저 일어났으므로 큐에서도 앞에 세운다(`DeferTriggerFirst`) —
  미루기를 붙이면서 발화 순서가 뒤집히지 않게.
- 가챠 다연차처럼 한 프레임에 여러 건이 올 수 있어 목록으로 받는다.

--- 덤으로 닫힌 조용한 누락 ---

`CompleteBeat`는 `GrantReward`(곤충 지급)를 `pendingBeatId`를 **비우기 전에** 부른다.
그래서 스토리 보상으로 받은 곤충의 `CaptureInsect`는 즉시 평가되던 시절
`FireBeat`의 `pendingBeatId` 가드에 걸려 **그냥 버려졌다** — 예외도 경고도 없다.
평가를 프레임 끝으로 옮기니 그때는 이미 비워져 있어 정상 발화한다.
배치 걸음에서 `ch12_sign`이 그 증거로 새로 떴다.

--- 검증 도구도 실제 경로로 ---

`StoryBeatWalkthrough.WinRaidAgainst`가 `RaidEnded`만 쏘고 포획은 안 줬다. 실제
`OnRaidVictory`는 `AddCapturedInsect`를 부른 **뒤** `RaidEnded`를 쏜다 — 그 순서를
재현하지 않으면 이번에 만든 미루기 분기가 한 번도 안 돌고, 그 상태로 통과하면
도구가 또 없는 경로를 검증하는 셈이다(이번 세션에서 이미 한 번 그랬다).

--- audit_candidates.py --write ---

`--emit-md`는 stdout에만 찍는다. 그걸 모르고 출력만 보고 넘어가면 큐가 빈 채로 남고,
`- [ ]`가 0이면 `audit_flow_inject`·`audit_reminder` 훅이 **함께 침묵해** 자동 플로우가
멈춘다(2026-05-27 ~ 07-17에 실제로 그렇게 멈춰 있었다). `--write`가 `## Uncovered` 절을
직접 갈아 끼운다.

정규식은 **줄 시작에 앵커한다** — 파일 상단 안내문이 `` `## Uncovered`의 `- [ ]` 개수가 ``
처럼 그 문자열을 산문 안에 들고 있어서, 앵커 없이 쓰면 거기 먼저 걸려 다음 `## `까지
먹고 **안내문 5줄을 통째로 날린다**(실측하고 고쳤다).

검증: PlayMode 734/734, 컴파일 error CS 0, ci_check 통과, story_lint 23 PASS.
걸음 campaign·blight 모두 전 걸음 통과 — 필드 포획은 즉시(0.0s), 레이드 포획은
결과 화면 뒤로.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PeZ8JgSDn46GK6Ei7QEfwA
"캐릭터 퀄리티가 떨어진다"의 가장 큰 원인은 모양이 아니라 **반영이 안 되는 것**이었다.
`EnsurePlayer()`(Bootstrap :94)가 `LoginUI`(:190)보다 **먼저** 기본값으로 몸을 짓고
`builtOnce` 가드가 재빌드를 막는데, 저장소 어디에도 리빌드 경로가 없다. 그래서 신규 유저는
첫 세션 내내 자기가 고른 캐릭터가 아닌 남자/짧은머리/검정/미소로 플레이한다(앱을 껐다 켜야
반영된다). 2D 초상화는 PlayerPrefs를 다시 읽으므로 **UI 속 얼굴과 필드 캐릭터가 서로 다른
사람**이 되기까지 했다.

## 반영되지 않던 것들

- **피부색**: `AppearanceSpec`에 필드가 아예 없었다. 키·세이브·클라우드 배선은 이미 다 있었고
  빌더만 안 읽었다(마이그레이션 0). 값이 4곳에 하드코딩돼 있었고 2D는 값 자체가 달랐다
  (0.92,0.78,0.62 vs 0.90,0.75,0.60) — `CharacterPalette`로 단일화.
- **의상 프리셋 3개 중 2개**: "연구원"이 4개, "자유"가 3개를 미보유 아이템으로 지정해
  `Equip`의 소유 가드에 막혀 `LogWarning`만 남기고 무시됐다. 고른 게 반영 안 되는 걸
  사용자가 알 방법이 없었다. 프리셋을 `CharacterPresetLibrary`로 빼고 기본 해금 안에서 재구성.
  EXP/캔디 배율이 붙은 outer_labcoat·bag_science는 일부러 제외했다(경제 곡선).

## 실루엣 — 정점은 오히려 1/5로

프리미티브 조합을 커스텀 메시로 바꿨다(`ProcMeshLibrary`: Disc/LowSphere/RoundedBox/
TaperedCapsule/Diamond + 프로세스 수명 정적 캐시). 한 캐릭터 **10,400 → 1,938정점**.

낭비의 대부분이 얼굴이었다 — 눈·동공·하이라이트·홍조 8개가 전부 `scale (0.15,0.17,0.06)`으로
눌린 **515정점 Sphere**라 원판 하나에 4,120정점을 쓰고 있었다. 몸통·부츠는 90° 모서리 Cube,
손은 구체 하나, 팔다리는 굵기가 일정한 캡슐이었다.

단위 크기 메시로 만들어 **기존 localPosition/localScale을 한 줄도 안 바꿨다** — 이 파일의
좌표는 여러 회귀를 거치며 맞춰진 값이라 건드릴수록 위험하다. 예외는 RoundedBox뿐이고
(둥근 모서리가 비균등 스케일에 왜곡된다) 그래서 bind 노드에는 쓰지 않는다.

NPC도 같은 전환을 했다. `NpcVisualBuilder`가 플레이어 지오메트리 복제라, 플레이어만
정교해지면 옆에 선 마을 NPC와 스토리 NPC 9종이 오히려 더 낡아 보인다.

## 부위별 재질

`MakeMaterial`이 `mat.color`만 세팅해 피부·천·가죽·금속이 전부 Standard 기본 광택(0.5)이었다.
`SurfaceKind` 6종으로 갈랐다 — `InsectEntity.ApplyColorRaw`가 곤충 34종에 이미 적용해
"무광 점토 문제의 해결"로 기록해 둔 그 처방이다.

## 생성 화면 3단계 + 3D 라이브 프리뷰

프리셋 5종 → 세부 조정 → 첫 파트너. **3D 마네킹 리그는 이미 완성돼 있었는데
생성 화면만 그걸 안 쓰고 2D 도트를 그리고 있었다** — `SetAppearanceOverride`로 저장 전
외형을 주입해 연결했다(다 쓰면 반드시 null로 되돌린다).

세부 조정은 좌우 2열이다. 세로로 쌓으면 5행(라벨 35 + 라디오 45~58)만 400~465px라
프리뷰를 최소로 줄여도 가로모드에서 하단 "모험 시작" 버튼이 패널 밖으로 밀렸다 —
그 상태로는 캐릭터 생성을 끝낼 수 없어 게임 진입 자체가 막힌다. 테스트가 이걸 잡았다.

## 첫 파트너 선택 (Rare 3종)

지급은 여전히 `ch1_intro` 비트가 하고, 그 고정 보상을 고른 종으로 바꿔치기한다.
Story.json 스키마를 안 늘린 이유: `story_lint`의 보상 검사가 단일 값만 봐서 후보 배열을
새로 만들면 아무도 검사하지 않는 구멍이 생긴다. 키가 없는 기존 세이브는 기본값으로 떨어져
**오늘과 완전히 같게** 동작한다.

- 오버라이드는 **beatId로 게이트**한다. 값 비교로 하면 나중에 장수풍뎅이를 주는 비트를
  하나 더 저작했을 때 조용히 하이재킹된다(rewardInsectId가 채워진 비트는 셋이다).
- `ResolveChoice`는 **화이트리스트**로 거른다. PlayerPrefs는 조작 가능해서, 없으면
  전설 곤충 id를 적어 넣어 1레벨에 받을 수 있다.
- 대사가 종을 명시하고 있어("…내가 함께 걷던 장수풍뎅이란다") 중립화했다.
  `story_lint`에 대사↔보상 대조 검사가 없어 안 고치면 조용히 어긋난다.
- **등급을 확인하고 골랐다**: 처음 넣은 `dragonfly_lake`가 활성 DB에서는 Uncommon이었다
  (옛 `CreateInsect` 목록에만 Rare로 적혀 같은 id의 등급이 두 곳에서 갈린다).
  `cicada_evening`(Rare, Pond→Water)으로 교체하고 `data_lint`에 등급 일치 검사를 신설했다.

## audit에서 나온 P0/P1

`ProcMeshLibrary.Disc`의 삼각형 순서가 자기 정점 노멀(+Z)과 반대여서 **눈·동공·하이라이트·
홍조가 정면에서 백페이스 컬링**되고 있었다. 와인딩 검사를 4개 생성기에 걸면서 평면인 Disc만
뺐기 때문이다(bounds.center가 면 위에 놓여 "바깥" 기준이 성립하지 않는다). 더 나쁜 건 그
증상을 이미 봤는데 "각도 탓"으로 결론냈다는 것이다 — 어느 각도에서도 안 보이는 상태였다.
평면에도 성립하는 기준(면 노멀 ↔ 정점 노멀 일치)으로 다시 짜 5개 생성기 전부에 걸었다.

그 외: TaperedCapsule 반구 노멀에 수평 성분(sin) 누락(극점이 45°로 누움), 같은 함수의 극 링
축퇴 삼각형 20개, `PlayerVisualBuilder.OnDestroy`의 `SafeDestroyMat`이 **사실상 항상 스킵**
(색을 안 칠하는 노드 10~12개가 끝까지 sharedMaterial이고 "플레이어는 영구 객체"라는 전제도
DontDestroyOnLoad가 없어 틀렸다 — b9a9771과 같은 유형), `LoginUI.OnAuthFailed`가 생성 화면을
떠나며 프리뷰 override를 안 풀어 이후 의상 화면 썸네일 24장까지 '버려진 얼굴'로 굽던 것,
드래그 상태가 창 밖 MouseUp에서 굳어 조작 불능이 되던 것.

## 에셋화

`CharacterAppearanceConfig`(색 팔레트·프리셋)를 만들었다. 저장소에서 두 번째로 실재하는
`.asset`이지만 **코드 폴백이 정답 경로**다 — 기존 `Resources.Load` SO 다섯 개가 전부 에셋 없이
돌고 있어, 에셋을 필수로 잡으면 "생성기를 안 돌렸다"가 조용한 값 회귀가 된다.
필드 초기값을 코드 기본값과 같게 두어 생성기를 돌려도 게임이 안 바뀌는 걸 테스트가 고정한다.

검증: PlayMode 808/808, ci_check 11검사 통과, story_lint 23 PASS, dex_grant_lint 누락 0,
LiveSceneCapture로 실루엣·눈 렌더 육안 확인.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TeDRGLPRX6GhbMy5f2vyTn
audit 큐는 소진 상태였지만 그 판정 기준(미검토 파일·변경 줄 수)으로는 안 잡히는 게 둘 남아
있었다. 둘 다 **실패가 조용한** 경로다.

## 1. 첫 파트너 지급 — 검증 수단이 하나도 없었다

- `StarterInsectTests`는 `ResolveChoice` **순수부**만 본다. 결과가 컬렉션·도감에 들어가는지는 모른다.
- `StoryBeatWalkthrough`는 `ch1_intro`를 **선행 비트로 미리 채운다**(검증 대상이 그 뒤라서).
  그래서 이 비트가 실제로 발화하는 걸 아무도 안 걸었다.
- `story_lint`는 Story.json을 정적으로만 읽는다.

선택이 통째로 무시돼도 장수풍뎅이가 조용히 들어올 뿐이라 화면으로는 "고른 대로 됐다"와
구분되지 않는다. `StarterInsectProbe`가 플레이어가 실제로 지나는 경로를 두드린다 —
`OnNpcTalked` → 대사창 대기 → `CloseModal`. **보상은 발화가 아니라 닫을 때 난다.**

4케이스(3종 + 선택 없음) 전부 정상 지급 확인.

## 2. 의상 spawn/bind 파츠 — 좌표는 고정돼 있는데 렌더는 아무도 안 봤다

`OutfitShapeLibraryTests`·`OutfitShapeParityTests`가 좌표와 스키마를 고정하지만 "그 좌표에
실제로 메시가 그려지는가"는 검사가 없다. 직전 커밋의 `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 수정이 마네킹에도 적용됨을 눈으로 확인했다.

## 프로브 자체가 두 번 틀렸고 그게 이번의 교훈이다

1. `EnterPlaymode()`의 **도메인 리로드**로 static과 `EditorApplication.update` 구독이 날아가
   영원히 대기만 했다. `LiveSceneCapture`가 `SessionState` + `InitializeOnLoadMethod`를
   쓰는 이유가 이것이다.
2. 고친 뒤엔 수동 `MoveNext()`가 **중첩 코루틴을 안 펼쳐** 검증을 한 건도 안 하고 PASS를 냈다.
   표가 비었는데 "PASS"였다 — `testing.md`의 "0건 보고는 통과가 아니라 실패다"가 그대로
   재현됐다. 코루틴 스택으로 펼치고 `probedCases == 0`이면 FAIL로 떨어뜨리는 가드를 넣었다.

연속 실행에서 2건이 "비트완료=False"로 실패했으나 같은 종을 `-probeOnly`로 단독 실행하니
PASS였다 — `ResetAll`이 `StoryDirector` 인메모리 상태까지 되돌리지 못한 프로브의 인공물이고,
실제 플레이어는 `ch1_intro`를 일생 한 번만 겪으므로 게임 결함이 아니다. **성급히 결함으로
보고하지 않고 단독 실행으로 갈랐고**, 그 한계를 클래스 문서와 보고서 양쪽에 못 박았다.

검증: error CS 0, ci_check 11검사 통과, verify_coverage 미할당 0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TeDRGLPRX6GhbMy5f2vyTn
세 결함이 한 줄로 이어져 있었다. 인벤토리를 열 수 없고, 유일하게 쓸 수 있던 채집망은
퀘스트에 세어지지 않고, 그렇게 깬 진행은 로그인할 때마다 되감겼다.

## 1. 인벤토리를 여는 코드가 저장소에 없었다

uGUI `PlayerItemInventoryGridUIController`를 Bootstrap이 만들기는 하는데 `Toggle`도
`SetActive`도 **어디에도 없고** 퀵액세스 바 10개 항목에도 없었다. 상점에서 산 부스터·
치료제는 살 수는 있어도 쓸 방법이 아예 없었고, "인벤토리에서 아이템을 선택해 사용하세요"라는
q_item 힌트는 가리킬 화면이 없었다.

IMGUI `InventoryUI`를 신설하고 퀵바 [I]에 붙였다(맨 뒤 index 10 — case 번호가 배열
인덱스라 중간 삽입은 그 아래를 전부 한 칸씩 밀어낸다). 같은 "사용" 버튼이 세 갈래다:
부스터는 즉시 발동, 치료제는 `HospitalUI.UseTreatmentItem`으로 곤충 선택기(**소비는
거기서** 일어나므로 여기서 차감하면 이중 소모), 포획 전용은 비활성.

`net_basic`은 `ItemDatabase`에 없어서(포획 전용 `CaptureItemData`로만 정의된다) DB 조회만으로
목록을 만들면 **보유 중인데 가방에서 사라진다** — 포획 아이템 표를 폴백으로 함께 읽는다.
반대로 `net_silver`/`net_gold`는 **양쪽에 다 있어** 가방에서 쓰면 10분 부스터로 타 없어지고
미니게임 재고가 준다. 함정이라 "부스터로 사용" 라벨과 설명으로 명시했다.

## 2. 채집망으로는 q_item이 오르지 않았다

`NotifyItemUsed`의 유일한 호출지가 `ItemEffectManager.ActivateItem`이라 **시간제 부스터만**
그곳을 지난다. 그런데 그 자리 주석은 이렇게 주장하고 있었다:

    // q_item 진행도 — 단일 진입점 (CaptureChoiceUI/PlayerItemInventoryGridUIController 모두 통과)

`CaptureChoiceUI`(채집망)도 `HospitalUI`(치료제)도 그곳을 지나지 않는다. 둘 다
`PlayerItemInventory.UseItem`만 직접 부른다. 퀘스트 설명이 "(채집망 등)"이라고 채집망을
콕 집어 지목하는데 정확히 그 경로만 빠져 있었다.

소비가 성립하는 `UseItem`으로 옮겼다 — 소비 4경로(가방·포획 버튼·포획 단축키·병원)가 전부
여기를 지나고, 판매 등 소비가 아닌 호출자는 없다(전수 확인). `ActivateItem`에서는 뺐다:
가방 경로는 `UseItem` → `ActivateItem` 순서라 양쪽에 두면 한 번 써도 2가 오른다.

## 3. 클라우드가 로그인마다 깬 퀘스트를 지우고 있었다

`CloudSaveManager`의 부트 로드는 "빈 클라우드 필드는 로컬을 보존한다"가 원칙이다 —
gems의 음수 sentinel, `charStarter`의 빈 문자열 체크(*"이 필드가 없던 시절의 문서를 복원해도
선택이 안 지워진다"*는 주석까지 있다), `ApplyCloudFile`의 `forceReplace`가 전부 그 형태다.

**퀘스트 5줄만 그 원칙 밖에 있었다.** `?? ""`로 null만 막고 빈 문자열은 그대로 덮어써서,
클라우드가 낡았거나(오프라인·PATCH 실패) 그 필드가 없던 시절 문서면 깬 퀘스트가 통째로
미완료로 돌아간다. 예외도 경고도 없다.

실측으로 확인했다 — 로컬 PlayerPrefs는 `q_move,q_talk_elder` 완료에 활성 `q_approach`인데,
세이브 파일에는 곤충 1마리 포획과 `ch1_first_capture` 스토리 비트가 남아 있었다.
`q_approach`는 실제로 달성했는데 기록만 없는 상태다.

퀘스트 완료는 **단조 증가**라(한 번 깬 것이 미완료로 돌아갈 이유가 없다) 합집합·키별
최댓값으로 병합한다. 순수 계산부를 `QuestSaveMerge`로 분리하고 18개 테스트로 고정했다.
명시적 "클라우드 사용"(`forceReplace`)만 통째로 치환한다 — 그건 되돌리겠다는 의사다.

## 4. 저장이 이벤트 예외에 삼켜지고 있었다

`CompleteQuest`의 `SaveProgress()`가 `GrantRewards`와 `QuestCompleted` 발화 **뒤에**
있었다. 보상 지급이나 구독자 하나가 예외를 던지면 그 줄에 영영 도달하지 못해
**인메모리는 완료인데 디스크는 미완료**로 갈린다 — 그 상태로 앱을 껐다 켜면 방금 깬
퀘스트가 그대로 되살아난다.

상태를 바꾼 직후로 올렸다(`IncrementProgress`·`CompleteQuest`·`IncrementSideProgress`·
`CompleteSideQuest` 4곳). 완료 시 진행 저장과 합쳐 2회가 되지만, 퀘스트 진행은 포획·전투처럼
드문 사건이라 그 값이 flush 비용보다 싸다. `ActivateNextQuest`는 클라우드 업로드보다 앞으로
옮겼다 — 순서가 반대면 클라우드의 activeQuest만 한 박자 낡은 값으로 남는다.

검증: PlayMode 826/826, 컴파일 error CS 0, ci_check 11검사 통과, verify_coverage 미할당 0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E58KiQKWoGWFiiobuem7mn
두 갈래다. 앞은 계획서(시나리오 부족·진행 안내 부족)대로 저작과 코드를 채운 것이고,
뒤는 그 검증이 끝난 뒤 audit 15라운드가 걷어낸 P0 3·P1 19다.

## 1. 이야기가 비어 있던 자리 27곳과 "선택"

- HUD가 **잠긴 리전으로 안내**했다. 2막 도착 비트의 prereq가 전부 앞 챕터 대치라 수문장을
  건너뛰었고, 스토리 코드는 해금 여부를 한 번도 보지 않았다. `StoryObjectiveTracker`가
  잠긴 리전이면 앞 리전 수문장으로 라벨·좌표를 바꿔 치고 권장 레벨을 병기한다.
- `StoryChoice` 모델은 있는데 읽는 코드가 0줄이었다. 최종장 "이름을 주지 않는다"가 대사로만
  처리됐다. 대사창이 마지막 줄에서 선택 버튼을 그리고, 결과 비트를 큐 맨 앞에 건다.
  선택은 표현이지 분기가 아니다 — 결과는 전부 Immediate leaf·보상 0(story_lint 검사 24).
- 간부 대결 승리 반응 0, 서브에리어 10곳 스토리 0, 1막 도감 마일스톤 0, 엔딩 뒤 콘텐츠 0,
  2막이 세라 독백(61/117). 27비트를 저작해 144비트가 됐고 앵커 3곳을 더했다.
- 걸음 도구에 `-walkChoice last`를 붙여 선택 양쪽이 실제로 뜨는지 본다(73·73·22 걸음 OK).

## 2. audit — 정체성 판정의 반대편 결함

수문장 격파를 "그 개체를 이겼나"로 바꾼 것은 옳았는데, 그 개체가 **살아남는 경로**를 아무도
안 봤다. 수문장은 풀 객체가 아니라 `Despawn`이 아무것도 반환·파괴하지 못한 채 래치만 걸고,
`StartBattle`의 `SetEngaged(true)`는 어느 종료 경로도 되돌리지 않았다. **한 번 지거나
도주하면 눈앞의 수문장에게 다시 말을 걸 수 없고 리전이 영영 잠겼다.** 두 Explore가
독립적으로 짚었다. 수문장의 Despawn은 교전 해제만 하고, 패배·도주는
`ReleaseEnemyAfterBattle`을 지난다. 포획 금지도 `CaptureChoiceUI` 버튼에만 있어 근접·
레이캐스트·입력 세 경로가 우회했다 — `StartMinigame` 진입부로 단일화.

같은 형태가 하나 더 있었다. 캐릭터 리워크는 **생성 화면 프리뷰**만 붙였고, 필드 캐릭터는
부트스트랩 초반에 기본값으로 지어져 `builtOnce`로 잠긴 채였다. 고른 성별·피부·머리가 첫
세션 내내 안 보였다. `RebuildFromPrefs`로 다시 짓고 `PlayerMovement`·`CharacterFaceAnimator`
캐시를 함께 비운다(안 비우면 눈을 안 깜빡인다).

그 밖에: 자동 장착 루프가 훈련 화면의 "해제"를 되살림 · 디스크 소모가 무표시 · 드레인이
1회성 트리거를 게이트 실패 시 버림 · 퀘스트 완료가 큐에 실린 채 종료되면 영구 유실 ·
선택 버튼이 button 파생 스타일이라 회색 상자 겹침 · 하위 디스크 3종의 입수 경로가 화면에
안 나오는 uGUI 상점뿐 · HospitalUI 배선이 마을 빌더 try 안 · 손상 세이브가 빈 목록으로 덮임.

## 검증

story_lint 24 · quest_lint 11(questId 구분자 검사 신설) · ci_check 통과 · error CS 0 ·
PlayMode 887/887(신규 32건) · verify_coverage 0 · audit 후보 0.
자체 발견 회귀 1건(ResolveSkill 두 번째 null 참조 — 첫 실행 1 실패 → 수정).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
cb48ece를 다시 읽어 내 손으로 넣은 결함 셋을 걷어냈다.

- **P0** `PlayerVisualBuilder.RebuildFromPrefs`가 플레이어의 **자식 전체**를 파괴했다. 그 밑에는
  빌더가 지은 몸 파츠만 있는 게 아니라 `CaptureProximityTrigger`가 붙어 있다 — 캐릭터를
  만들자마자 포획이 죽었다. 이제 BuildAll 전후의 직계 자식 차집합을 `builtRoots`로 적어 두고
  그것만 파괴한다. 같은 머티리얼을 두 번 지우던 것도 HashSet으로 막았다.
  `PlayerVisualRebuildTests`가 "남의 자식은 남고 Body는 새 인스턴스"를 고정한다.
- `KeyGuideHUD`에 [I] 행을 넣으면서 `rowCount = 7`을 그대로 둬 패널이 한 줄만큼 넘쳤다.
- `StoryDirector` 드레인의 1회성 되돌리기가 "기다리는 비트가 아예 없는" 퀘스트 완료까지
  3회 되돌리고 경고를 냈다(QuestComplete 비트는 둘뿐인데 퀘스트는 33개). 그 트리거를
  기다리는 미열람 비트가 있을 때만 되돌린다.

의심했지만 문제 없던 것: 수문장은 `forBattle`이라 교전 해제 뒤에도 도주 분기에 들어가지 않는다 ·
카메라는 플레이어 자식이 아니다 · `GetEquippedSkills`의 `InsectUpdated` 발화는 핸들러가 되부르지
않아 재귀하지 않는다 · 선택 버튼의 `Rounded`는 `UISurface.Button`과 같은 색 규약이다.

검증: error CS 0 · PlayMode 888/888 · ci_check 통과 · verify_coverage 0.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
cb48ece·31be2de를 새 눈(교차 리뷰 2건 + 걸음 도구 3종 재실행)으로 다시 봤다.

## 1. 대결 승리는 "누구에게 이겼나"지 "무슨 종을 이겼나"가 아니다

`duel_grip_win`·`duel_scale_win`·`duel_chief_win`을 `BattleWin <간부 종>`으로 걸어 두었는데,
간부 종 셋(`centipede_sand`·`mantis_icicle`·`moth_effaced`)이 전부 자기 리전 **야생 풀**에 있고
Epic이라 레이드로 흔히 붙는다. 무명 땅에서 야생 나방 레이드만 이겨도 관장 패배 대사와
Lv.62 보상이 결말 전에 나왔다 — 같은 커밋이 수문장 판정에서 "종·레벨로는 못 가른다"며 걷어낸
그 결함을 스토리 쪽에 그대로 심은 것이다. `NpcDuelController.BossDuelWon` 이벤트를 두고
`StoryDirector`가 `DuelWin <storyNpcId>`로 흘린다(switch case·구독·해지·HUD 안내·저널 힌트·
바이블 표 — story_lint 검사 6이 배선을 본다).

## 2. 나머지

- 선택지가 떠 있는 동안 ESC로 닫히면 결과를 볼 길이 0이었다(저널은 미열람 결과를 숨기고
  다시보기엔 버튼이 없다). 선택 중엔 닫기를 삼킨다.
- `ch9_confront_answer`의 세라 대사가 뒤이은 `ch9_clash`("대답을 못 했어요")와 모순됐다 —
  새 비트 쪽을 고쳤고(열람된 비트는 안 건드린다) `ch9_clash`는 `ch9_confront` 뒤로.
- 2막 여운 셋(`ch7_echo_rival`·`ch8_echo_scholar`·`ch9_echo_rival`)에 리전 게이트 — 그 자리 대사가
  마을에서 뜨지 않게.
- 이미 격파 등록된 수문장을 다시 이기면(클라우드 로드 전 선전투) 봉인이 안 걷혀 무한히 다시
  싸울 수 있었다 — `TryDefeatGuardian`이 그 경우에도 `GuardianDefeated`를 울려 봉인을 걷는다.
- 계정 삭제가 `.corrupt` 보존본을 남겼다.
- 하위 디스크 젬가를 상위 3종 간격에 맞추고, 어디에도 없어 **획득 불가**였던 Rare 디스크 2종을
  상점에 올렸다.
- 잠긴 리전 안내가 hops 소진 시 여전히 잠긴 곳을 가리켰다 · 재스윕은 기다리는 비트가 있을 때만,
  경고는 세션당 1회 · 진척 정리(Prune)를 전투 진입마다가 아니라 로드·레지스트리 주입 시점으로 ·
  디스크 라벨 폰트 · `FindDiscFor` 캐시 키에 DB 인스턴스 · 비용 0 방식은 캔디 배선 무관 ·
  죽은 `StopAllCoroutines` 제거.

검증: story_lint 24 PASS · ci_check 통과 · error CS 0 · PlayMode 889/889 · 걸음 도구 campaign(first/last)
73·73 OK, blight 22 OK, 예외 0 · verify_coverage 0.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@kimsungsugit
kimsungsugit merged commit b22448e into main Sep 8, 2026
1 check passed
@kimsungsugit
kimsungsugit deleted the feat/act2-story branch September 8, 2026 17:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant