← 자료실/designsLOWAPPROVED

Sprint 01 W2 — Transcendent Drop / Milestone / Affix Importer + Runtime Lookup 기술 설계

designs/sprint-01-w2-importer-design.md

Sprint 01 W2 — Transcendent Drop / Milestone / Affix Importer + Runtime Lookup 기술 설계

작성: 2026-04-21 (code-lead) · 근거: transcendent_drop_table_README.md v1.0 APPROVED, transcendent_reward_draft.md v1.0 APPROVED 문서 상태: APPROVED v1.0 (PD 승인 2026-04-21) 범위: Sprint 01 W2 기술 설계 전용 — 실구현 코드 제외 CP 추적: CP1(§3) · CP2(§4) · CP3(§4·§8) · CP4(§5) · CP5(§6) · CP6 = CP5 이월 원칙 내장 · CP7(§7) --- ## PD 승인 결정 (2026-04-21) | # | 질문 | 확정 | |---|------|------| | DQ1 | SO 구조 | 옵션 A — 개별 SO 3개 (DropRateTableSO / MilestoneRewardCatalogSO / AffixPoolSO) | | DQ2 | 메뉴 진입점 | 개별 4개 + ALL 메뉴 1개 | | DQ3 | TsvReader 공통화 | Sprint 02 이월 — W2에선 Stage Importer 재건드림 금지 (Sprint 00 회귀 방지) | | DQ4 | 테스트 asmdef | 신설 GodBlade.Tests.Editor | 블로커 해소: com.unity.test-framework: 1.4.5 는 Unity6/Packages/manifest.json:10 에 이미 존재 (packages-lock 자동 1.4.6 해석). §9 컴파일러 시뮬레이션 체크리스트 11개(CP9-1~CP9-11) 필수 — PR 머지 전 Console 새 에러 0 확인 원칙.

0. 한 줄 요약

TSV 3종을 Sprint 00 메뉴 방식 Importer 패턴 그대로 복제·확장하여 ScriptableObject 3개(옵션 A) 로 변환, 런타임은 Dictionary<(floor_bucket_id, mobKind, grade), float> 단일 해시 O(1) 조회. Grade 리매핑은 Data 어셈블리의 GradeRemap 정적 클래스가 단일 진실. DOTS BlobAssetReference는 Sprint 02 이후 판단(지금 도입하면 마이그레이션 2회).


1. 데이터 모델 결정

1.1 3 옵션 비교

옵션구조장점단점W2 적합성
A. 개별 SO 3개DropRateTableSO + MilestoneRewardCatalogSO + AffixPoolSOSprint 00 TranscendentStageCatalogSO 패턴 1:1 재활용 / 각 TSV 변경 시 영향 분리 / Addressables 주소 단순SO 3개 로딩 필요 (방치형 부팅 1회)권장
B. 통합 SO 1개RewardDatabaseSO 단일체한 번 로드 / FK 조합 쉬움60+22+6 = 88행 단일 덩어리 → diff noise / TSV별 빌드 타임 격리 불가과결합
C. DOTS BlobAssetReferenceBlobArray<DropRow> 3종수천 아이템 드롭 롤 시 캐시 친화 / GC 0W2 범위 초과 / 에디터 도구·마이그레이션 비용 큼 / 측정 없이 도입 원칙 위반기각 (증거 없음)

1.2 권장안 = 옵션 A + DOTS 스키마 사전 호환

1.3 SO 세 개 필드 초안 (요약)

DropRateTableSO
  List<DropRateRow> rows                           // 60행
  struct DropRateRow { int floorMin; int floorMax; MobKind mob; Grade grade; float rate; }
  [NonSerialized] Dictionary<(int,MobKind,Grade),float> _lookup  // OnEnable lazy build
  [NonSerialized] List<(int min, int max)> _bucketIndex          // 경계 검색용

MilestoneRewardCatalogSO
  List<MilestoneRewardRow> rows                    // 22행
  struct MilestoneRewardRow {
    int floor; string milestoneName;
    RewardType type; string rewardCode;  // "TBD_" 허용
    int count; Grade? gradeFallback; string notes;
  }
  IReadOnlyList<MilestoneRewardRow> GetRowsAtFloor(int floor)

AffixPoolSO
  List<AffixSkeletonRow> rows                      // 6행
  struct AffixSkeletonRow {
    Grade grade; int affixSlotCount;
    int prefixSlotCount; int suffixSlotCount;
    float valueRollMinPct; float valueRollMaxPct;
    string legacyGradeMap;  // read-only 메모 (Sprint 02 주입 힌트)
    int rerollCostGold;
  }

모두 GodBlade.Data 어셈블리. Sprint 00 SO와 같은 [CreateAssetMenu] 부착, Addressables 주소는 §2.4.


2. Importer 클래스 설계

2.1 파일 레이아웃 (전부 Editor 어셈블리)

Assets/Editor/
  GodBlade.Editor.asmdef                                (기존)
  Importers/
    TranscendentStageImporter.cs                        (Sprint 00 — 손대지 않음)
    DropRateTableImporter.cs                            (신규, 60행)
    MilestoneRewardImporter.cs                          (신규, 22행)
    AffixPoolImporter.cs                                (신규, 6행)
    RewardImportAll.cs                                  (신규, 4개 메뉴 순차 실행)
    Shared/
      TsvReader.cs                                      (신규, 3개 Importer 공통)
      GradeParser.cs                                    (신규, "Rare"→Grade.Rare(신규 Epic 의미 NOT 원본!))

2.2 ScriptedImporter vs MenuItem — MenuItem 채택

2.3 의존 순서 = Import ALL 한 진입점이 보장

[1] TranscendentStageImporter.Import()   (Sprint 00)     — Stage SO 200개 + Catalog
[2] DropRateTableImporter.Import()                       — Stage에 의존하지 않음(독립)
[3] MilestoneRewardImporter.Import()                     — floor→Stage FK 없음(Grant 시 lookup)
[4] AffixPoolImporter.Import()                           — 독립

2.4 공통 Const 규약 (Sprint 00과 동일 룩&필)

OUT_DIR       = "Assets/_Project/Data/Rewards"
ADDR_GROUP    = "Rewards"
ADDR_LABEL    = "reward-table"
Addresses:
  "Rewards/DropRateTable"
  "Rewards/MilestoneRewardCatalog"
  "Rewards/AffixPool"

2.5 TsvReader 공통화 (Sprint 00 private Row 승격)

  public static class TsvReader {
    public struct Row { string Get(string k); int I(string k); float F(string k); double D(string k); }
    public static List<Row> Read(string path, string[] expectedHeaders);   // BOM trim + ValidateHeader 포함
  }

3. Grade 리매핑 (CP1 대응)

3.1 위치 = GodBlade.Data 어셈블리 (Assets/_Project/Scripts/Data/GradeRemap.cs)

3.2 Enum 구조 초안

namespace GodBlade.Data {
  public enum Grade {
    Common     = 0,   // 원본 Normal
    Magic      = 1,   // 신규 (원본 없음, Sprint 02 Normal 복제)
    Rare       = 2,   // 원본 Superior  ← ★ 의미 변경 주의
    Epic       = 3,   // 원본 Rare      ← ★ 절대 혼동 금지
    Legendary  = 4,   // 원본 Unique
    Mythic     = 5,   // 신규 (원본 없음)
  }

  public enum MobKind { Normal = 0, Boss = 1 }

  public enum RewardType {
    CurrencyGold,
    EquipmentRandom,
    AffixRerollToken,
    GuardianExp,
    GuardianStone,
    CosmeticTitle,
    CosmeticAura,
    Unknown,
  }

  public static class GradeRemap {
    // 원본 XML Grade 리터럴 4종 → 신규 enum
    // Sprint 01 W2: TSV 내부 리터럴(신규 6종) 직접 파싱 + 이 맵은 Sprint 02 XML 주입 때 쓰임
    public static bool TryFromLegacy(string legacyLiteral, out Grade grade) { ... }

    // TSV 내부 신규 리터럴 파싱
    public static bool TryParseNew(string literal, out Grade grade) { ... }
  }

  public sealed class UnknownGradeException : System.Exception {
    public string Literal { get; }
    public string SourceTsv { get; }
    public int RowNumber { get; }
  }
}

3.3 리매핑 테이블 (하드코딩, Dictionary)

원본 리터럴(XML, Sprint 02 주입용)신규 Grade주의
"Normal"Grade.Common1:1
"Superior"Grade.Rare상향 1단계
"Rare"Grade.Epic★ 원본 Rare ≠ 신규 Rare. 코멘트 3줄 필수
"Unique"Grade.Legendary1:1
그 외("Magic"/"Mythic"/대소문자 변형/오타)UnknownGradeException 발생

3.4 Unknown 정책


4. 드롭 테이블 Lookup (CP2·CP3 대응)

4.1 자료구조 = Dictionary<Key, float>

readonly struct DropRateKey : System.IEquatable<DropRateKey> {
  public readonly byte BucketId;   // 0~4, 5개 버킷 (1-30, 31-80, 81-130, 131-180, 181-200)
  public readonly byte MobKind;    // Normal=0 / Boss=1
  public readonly byte Grade;      // 0~5
  // GetHashCode = BucketId | (MobKind<<4) | (Grade<<5)  → 컴팩트
  public bool Equals(DropRateKey o) => ...
}

Dictionary<DropRateKey, float> _lookup   // 60 엔트리 (5×2×6)

4.2 왜 Dictionary인가 (vs 다차원 배열)

접근Dictionaryfloat[5,2,6]
조회 O(1)해시 + EqualityComparer직접 인덱싱 (더 빠름)
누락 검증TryGetValue false → 0 감지 쉬움0.0 가 "미정"인지 "진짜 0"인지 모호
직렬화SO에는 List<DropRateRow> 저장, OnEnable에서 Dictionary 빌드다차원 배열은 Unity 직렬화 불가 → 래핑 필요
확장mobKind 추가 쉬움상수 5/2/6 고정 — 추가 시 리팩터

선택: Dictionary + OnEnable lazy build (Sprint 00 TranscendentStageCatalogSO._byCode 패턴 그대로).

4.3 Floor → BucketId 변환 (CP3 경계값)

static byte FloorToBucket(int floor) {
  if (floor <=  30) return 0;
  if (floor <=  80) return 1;
  if (floor <= 130) return 2;
  if (floor <= 180) return 3;
  return 4;  // 181~200
}

4.4 런타임 API 초안 (DropRateTableSO 메서드)

public float GetDropRate(int floor, MobKind mob, Grade grade);
// floor 1~200 → BucketId → key → dict. miss면 0.0 + Debug.LogWarning(한 번만).

public Grade RollGrade(int floor, MobKind mob, System.Random rng);
// 6등급 weighted pick. 내부에서 GetDropRate 6회 + 누적분포 생성 (캐시 고려는 Sprint 02 측정 후).

4.5 드롭률 합 자기검증 (Importer)


5. TBD_ 플레이스홀더 처리 (CP4)

5.1 감지

  bool isPlaceholder = rewardCode.StartsWith("TBD_", StringComparison.Ordinal);

5.2 정책 = 로드는 하되 런타임 Grant는 Skip + Warn

  Debug.LogWarning($"[MilestoneGranter] TBD reward skipped: floor={floor} code={rewardCode}");
  continue;  // 다음 row 진행

5.3 TODO / Issue 스텁

  // TODO(Sprint 02): TBD_EQUIP_POOL → RewardItemInfo.xml StageGeneralCode=5001 계열 매핑
  //                  TBD_REROLL     → 신규 아이템 등록 (game-designer 협업)
  //                  TBD_TITLE_*    → 칭호 시스템 설계 후 (Sprint 04+)

6. 어픽스 뼈대만 W2 처리 (CP5)

6.1 W2 범위 = AffixPoolSO 6행만

6.2 Sprint 02 확장 시 마이그레이션 0 설계

public sealed class AffixPoolSO : ScriptableObject {
  public List<AffixSkeletonRow> rows;          // W2: 6행 (grade → slot 뼈대)
  public List<AffixValueEntry> valueEntries;   // W2: 빈 리스트. Sprint 02가 211행 채움.
}

[System.Serializable]
public struct AffixValueEntry {
  public Grade grade;
  public int optionRandomGroupId;   // 원본 OptionItemRandom.OptionRandomGroupID
  public int optionDivision;
  public int optionRank;
  public float valueMin;
  public float valueMax;
  public int rate;                  // 10000 분율
  // ... 6컬럼 나머지
}

6.3 Sprint 02에서 필요한 것 (인지만, W2는 구현 안 함)


7. 마일스톤 독립 경로 (CP7)

7.1 MilestoneGranter 위치·API

7.2 API 초안

namespace GodBlade.Runtime.Rewards {
  public sealed class MilestoneGranter {
    private readonly MilestoneRewardCatalogSO _catalog;
    public MilestoneGranter(MilestoneRewardCatalogSO catalog) { ... }

    // 드롭 Roll과 독립. FloorReached 이벤트 채널 한 번만 호출.
    public IReadOnlyList<GrantResult> Award(int floor);

    public readonly struct GrantResult {
      public readonly MilestoneRewardRow Row;
      public readonly bool Skipped;             // TBD placeholder면 true
      public readonly string SkipReason;
    }
  }
}

7.3 로드 시점

7.4 드롭 Roll과 중복 지급 방지 (이벤트 채널 분리)

이벤트 채널 A: OnMonsterKilled(floor, mobKind, monsterCode)
  → LootRoller (드롭 Roll: DropRateTableSO + AffixPoolSO)

이벤트 채널 B: OnFloorReached(floor) ← 층 클리어 1회만 발화
  → MilestoneGranter.Award(floor) ← 10/50/100/150/200에만 실제 지급

8. 테스트 전략 (CP3 보강)

8.1 Edit 모드 = 본 스프린트 타겟

8.2 파일 배치

Assets/_Project/Tests/Editor/
  GodBlade.Tests.Editor.asmdef           (신규, testFramework 참조)
  DropRateTableLookupTests.cs
  GradeRemapTests.cs
  MilestoneCatalogTests.cs

§9 재발 경고: asmdef.references에 UnityEngine.TestRunner, UnityEditor.TestRunner, GodBlade.Data, GodBlade.Runtime, GodBlade.Editor 명시. defineConstraints: ["UNITY_INCLUDE_TESTS"].

8.3 Floor 경계값 테스트 8개 (CP3)

[TestCase(30, 0)] [TestCase(31, 1)]
[TestCase(80, 1)] [TestCase(81, 2)]
[TestCase(130, 2)] [TestCase(131, 3)]
[TestCase(180, 3)] [TestCase(181, 4)]
public void FloorToBucket_BoundariesAreInclusiveOnLower(int floor, int expectedBucket);

추가 테스트:


9. 정적 분석 한계 + 컴파일러 시뮬레이션 체크리스트

PD 명시 요청 섹션. 2026-04-21 Sprint 00 회귀 2건(CS0246 ResourceManager 네임스페이스 누락 / CS1061 멤버 접근 실패)이 정적 리뷰를 통과했지만 실컴파일에서 드러났다. W2 PR 제출 전 다음 11개 체크 포인트 를 코드 작성자가 직접 확인한다.

9.1 체크 포인트 (11개)

  1. [CP9-1] asmdef references 배열 완결성 — 신규 테스트 asmdef GodBlade.Tests.Editor.asmdef 에 다음 누락 재발 금지:
  1. [CP9-2] Editor asmdef의 Unity.Addressables.Editor 유지 — 기존 GodBlade.Editor.asmdef 은 이미 포함. 신규 Importer들이 AddressableAssetSettingsDefaultObject 를 건드리면 이 참조 없으면 CS0246.
  2. [CP9-3] internal 멤버 + [InternalsVisibleTo] — 새 SO 중 Editor만 쓸 세터가 있으면 TranscendentStageSO.cs 상단의 [assembly: InternalsVisibleTo("GodBlade.Editor")] 패턴 복제. 새 SO 파일마다 중복 선언은 금지 (중복 정의 CS0579). 한 파일에만.
  3. [CP9-4] #if UNITY_EDITOR 가드 경계 — Data asmdef는 Editor 전용 심볼 참조 금지. UnityEditor.* 사용 블록은 반드시 #if UNITY_EDITOR 로 감싸거나 Editor 어셈블리로 이동.
  4. [CP9-5] 신규 네임스페이스 충돌 — GodBlade.Runtime.Rewards / GodBlade.Data.Rewards(X, 하위 안 만듦) / GodBlade.EditorTools.Rewards(X, 평탄 유지) 중 Runtime만 하위 네임스페이스 도입. rootNamespace가 GodBlade.Runtime 이므로 파일 최상단 namespace GodBlade.Runtime.Rewards로 명시. 자동 삽입 방지 위해 asmdef rootNamespace 수정 금지.
NOTE (2026-04-21 scaffold sync audit 반영): Editor asmdef의 파일명은 GodBlade.Editor 이나 rootNamespace / 파일 내 네임스페이스는 GodBlade.EditorTools (Sprint 00 scaffold 컨벤션 유지). 본 문서에서 GodBlade.Editor.* 표기는 asmdef 이름 기준이며, 실제 코드 작성 시 namespace GodBlade.EditorTools / GodBlade.EditorTools.Rewards 사용. asmdef rootNamespace 수정 금지.
  1. [CP9-6] TSV Split('\t') CRLF/LF 처리 — File.ReadAllLines 는 플랫폼 개행을 자동 분리하나, Windows TSV를 LF만 남기는 편집기에서 복사된 경우 마지막 줄에 \r 잔여. Row 파싱 후 Trim() 1회:
   for (int c = 0; c < max; c++) row.Fields[header[c]] = cells[c].Trim();

Sprint 00 TranscendentStageImporter.ReadTsv 에 없음 — 공통 TsvReader 추출 시 함께 보강.

  1. [CP9-7] BOM 트림 중복 적용 — 공통 TsvReader 한 곳만 BOM trim. Sprint 00 기존 함수에서 제거(중복 작업 방지 — 단 테스트 돌려 회귀 없음 확인 후 제거).
  2. [CP9-8] Dictionary 직렬화 금지 — Unity는 Dictionary<K,V> 직렬화 안 함. SO는 반드시 List<Row> 로 저장, [NonSerialized] Dictionary는 OnEnable에서 빌드. Sprint 00 Catalog 패턴 유지.
  3. [CP9-9] Enum underlying type 고정 — Grade : int 기본이지만, DOTS 전환 고려하면 byte 로 선언 가능. W2에서는 int 유지 (Unity Inspector 표시, Serialize compatibility 안정). Sprint 02+ DOTS 전환 시 한 번에 byte로.
  4. [CP9-10] System.Random 는 런타임 전용 / Editor Import 단계는 rng 금지 — Importer는 결정론적(input TSV만). rng 사용 zero. LootRoller(Sprint 02)에서 도입.
  5. [CP9-11] Addressables 그룹 중복 생성 방지 — 신규 Rewards 그룹을 Stage 그룹과 별도로 만들 것. settings.FindGroup("Rewards") 로 기존 확인 후 없으면 생성. Sprint 00 TranscendentStageImporter.RegisterAddressables 패턴 복제.

9.2 Sprint 00 회귀 2건 재발 경고 (반드시 읽기)


10. 리스크 · 미해결

10.1 W2 내 발견 가능 추가 회귀 후보 3건

10.2 Sprint 02 이월 경계선 (W2 하지 않는 것)

항목W2 범위Sprint 02
TranscendentStageReward 3459행 주입❌✅
OptionItemRandom 211행 주입❌✅
XML 파서 구현❌✅
Grade 리매핑 코드(GradeRemap.TryFromLegacy)✅ 작성만Sprint 02에서 호출 시작
TBD_ 치환❌ (Skip+Warn만)✅
LootRoller (드롭 Roll 실행)❌✅
AffixRoller (어픽스 실수치 추첨)❌✅
Addressables 플레이모드 로드 테스트❌✅
프레스티지 PP 적립 훅❌prestige_system_spec.md v1.0 Sprint 02

10.3 Unity.ResourceManager asmdef 참조 재확인

10.4 시즌 리셋 / 프레스티지 호환성 (스키마 수준만 인지)


11. PD 결정 대기 열린 질문 (4건)

#질문옵션code-lead 추천
DQ1SO 구조는 옵션 A(개별 3개) / B(통합) / C(DOTS Blob) 중?A / B / CA. DOTS는 Sprint 02 이후 측정 기반 재검토.
DQ2Importer 진입점은 개별 메뉴 4개 + ALL 메뉴 1개 / 개별만 / ALL만?개별+ALL / 개별만 / ALL만개별+ALL. 엔지니어 선택권 + 원자 실행 모두 제공.
DQ3TsvReader 공통화를 W2에 포함 / Sprint 02로 이월?포함 / 이월이월. R-W2-2 회귀 위험 — W2 1차 PR은 Sprint 00 건드리지 않음. W2 말 별도 리팩터 PR.
DQ4테스트 asmdef 신설 / 기존 Editor asmdef 재활용?신설 / 재활용신설 (GodBlade.Tests.Editor). testFramework / defineConstraints 격리.

12. 분량 · CP 추적