[{"data":1,"prerenderedAt":-1},["ShallowReactive",2],{"skill:01M6CBK0KNCMPAX6QSHMMKCS02":3,"skill-same:01M6CBK0KNCMPAX6QSHMMKCS02":67},{"skillNo":4,"name":5,"nameZh":6,"description":7,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":15,"licenseFileUrl":6,"sourceHtmlUrl":16,"categories":17,"keywords":18,"publishedAt":23,"linkOnly":24,"markdownContent":25,"markdownBody":26,"frontMatter":27,"refName":28,"commitSha":29,"skillDirectory":30,"forksCount":14,"repositoryHtmlUrl":31,"repositoryHomepageUrl":6,"ownerHtmlUrl":32,"ownerBlogUrl":6,"ownerDescription":33,"updatedAt":34,"install":35,"resources":48},"01M6CBK0KNCMPAX6QSHMMKCS02","10-shop-manager",null,"MobilePackage ShopManager를 CompoSingleton으로 구현하고 `SHOP_CATALOG` 기반 카탈로그 생성\u002Frefresh\u002F잠금해금(`unlock_msg_id`) + 구매(`CanBuy`, `BuyAsync`) 플로우를 적용할 때 사용한다.","devwinsoft\u002Fdevian","devwinsoft","https:\u002F\u002Favatars.githubusercontent.com\u002Fu\u002F22570160?v=4","01M6C6RR8W05NH8NPKQNCE3Q72","maoshy","01M6C6RRDJ5TJJCZXRTY801YDH",0,"MIT","https:\u002F\u002Fgithub.com\u002Fdevwinsoft\u002Fdevian\u002Fblob\u002Fmain\u002Fskills\u002Fdevian-unity\u002F50-mobile-package\u002F28-shop-system\u002F10-shop-manager\u002FSKILL.md",[],[19,20,21,22],"shop","10 shop manager","refresh","catalog","2026-11-10 10:12:30",false,"---\nname: 10-shop-manager\ndescription: MobilePackage ShopManager를 CompoSingleton으로 구현하고 `SHOP_CATALOG` 기반 카탈로그 생성\u002Frefresh\u002F잠금해금(`unlock_msg_id`) + 구매(`CanBuy`, `BuyAsync`) 플로우를 적용할 때 사용한다.\n---\n\n# 10-shop-manager\n\nStatus: ACTIVE\nAppliesTo: v10\n\nShopManager는 MobilePackage 상점 구매 진입점이며, 카탈로그별 상품 목록을 관리한다.\n\n`catalog initialize\u002Frefresh operation`의 정본은 [03-ssot](..\u002F03-ssot\u002FSKILL.md)다.\n이 문서는 API\u002F구현 포인트를 다루며, 동작 순서 충돌 시 SSOT가 우선한다.\n\n---\n\n## 1. Class\n\n```csharp\npublic sealed class ShopManager : CompoSingleton\u003CShopManager>\n```\n\n- namespace: `Devian`\n- asmdef: `Devian.Samples.MobilePackage`\n- `MobileApplication`에서 `RequireComponent(typeof(ShopManager))`로 보장한다.\n\n---\n\n## 2. Public API\n\n```csharp\npublic GAME_ERROR_TYPE CanBuy(string shop_item_id)\npublic Task\u003CGameResult\u003CRewardData[]>> BuyAsync(string shop_item_id, CancellationToken ct = default)\npublic GameResult Initialize()\npublic GameResult RefreshProducts(bool requireServerTime = true)\npublic IReadOnlyList\u003CShopCatalogBase> GetCatalogs()\npublic ShopCatalogBase GetCatalog(SHOP_CATALOG_TYPE catalog_type)\npublic T GetCatalog\u003CT>() where T : ShopCatalogBase\npublic GameResult\u003CShopProductBase> GetProduct(string shop_item_id)\npublic GameResult\u003CShopRewardProductBase> GetRewardProduct(string shop_item_id)\npublic GameResult\u003CShopLimitedProductBase> GetLimitedProduct(string shop_item_id)\npublic GameResult\u003CShopProductDaily> GetDailyProduct(string shop_item_id)\npublic GameResult\u003CShopProductEvent> GetEventProduct(string shop_item_id)\npublic GameResult\u003CShopProductGold> GetGoldProduct(string shop_item_id)\npublic GameResult\u003CShopProductChest> GetChestProduct(string shop_item_id)\npublic GameResult\u003CShopProductPurchase> GetPurchaseProduct(string shop_item_id)\n```\n\n핵심 플로우:\n\n1. `shop_item_id`로 `ShopProductBase` 조회\n2. 카탈로그 목록은 하드코딩이 아니라 `TB_SHOP_CATALOG.GetAll()` row로 생성된다.\n3. `SHOP_ITEM_DAILY` 카탈로그는 초기화\u002Frefresh 시 `FREE\u002FADS 고정 + 비고정 5개 선택` snapshot으로 생성된다.\n4. `CHEST\u002FPURCHASE\u002FGOLD`는 각 `SHOP_*` 테이블의 모든 상품을 생성한다.\n5. `EVENT`는 `SHOP_ITEM_EVENT.start_time\u002Fend_time` 서버 UTC 구간 안에 있는 상품만 생성한다.\n6. `SHOP_ITEM_DAILY` 선택 생성에서 동일 `shop_item_id`(pk)는 중복 선택하지 않는다.\n7. 구매 제한 자동 refresh 체크(서버 시간 기준): 일반 카탈로그는 `autoRefreshUtcMs` + `auto_refresh_days` 규칙을 사용한다. `auto_refresh_days \u003C= 0`이면 자동 refresh를 사용하지 않는다.\n8. `EVENT`는 `auto_refresh_days`가 아니라 가장 가까운 미래 `start_time\u002Fend_time` 경계 시각을 `autoRefreshUtcMs`로 저장해 refresh를 예약한다.\n9. `unlock_msg_id`가 있는 카탈로그는 초기 `IsLocked=true`이며 `GameMessageManager` 구독으로 `unlock_op_type\u002Funlock_value` 조건 만족 시 해금된다.\n10. 잠긴 카탈로그의 상품은 `CanBuy\u002FBuyAsync`에서 차단된다.\n11. 구매 제한(`max_count\u002FremainCount`) 체크\n12. `ShopProductBase.Price`(할인 반영 최종가)로 구매 가능 여부\u002F통화 차감을 처리한다. 원가는 `ShopProductBase.PriceWithoutDiscount`를 사용한다.\n13. `SHOP_ITEM_DAILY.unit_price`는 단가다. DAILY 상품의 `PriceWithoutDiscount`는 `unit_price * snapshot.amount`이며 할인은 그 결과에 적용한다.\n14. 카탈로그 분기와 storage 기반 초기 product 구성은 `ShopCatalogFactory` + `ShopCatalogBase` 계층이 담당한다. ShopManager는 registry\u002Forchestration\u002F구매 검증\u002F결제를 담당한다.\n15. `ShopManager.onInitAwake()`는 catalog를 초기화하지 않으며, `Initialize()`가 유일한 manager 초기화 진입점이다.\n16. `ShopCatalogBase`는 `Initialize() -> onInitialize() -> RefreshProducts() -> onRefresh()` 라이프사이클을 따른다. product 생성 책임은 `onRefresh()`에 있다.\n17. 기본 `onRefresh()`는 `CHEST\u002FPURCHASE\u002FGOLD`의 테이블 전체 상품을 생성한다. `ShopCatalogDaily.onRefresh()`와 `ShopCatalogEvent.onRefresh()`가 고유 생성 로직을 override 한다.\n18. `PURCHASE`는 `PurchaseManager.PurchaseAsync(internal_product_id)` 위임, 그 외는 통화 차감\u002F광고 시청 후 `RewardManager` 지급\n19. 성공 시 `remainCount`와 DAILY snapshot 상태를 저장 + SaveData 저장\n20. ADS\u002FFREE 상품 리필은 카탈로그 저장 버킷의 `adsRefreshUtcMs`로 별도 관리하며, ADS\u002FFREE 구매 성공 시 `serverNow + 1day`로 기록한다.\n21. 카탈로그 초기화\u002Frefresh 시 `adsRefreshUtcMs`가 없거나 만료면 ADS\u002FFREE 제한 상품을 `remainCount=max_count`로 리필한다.\n22. refresh 조건 탐색은 `ShopManager.evaluateCatalogRefreshState(...)` 한 곳에서 처리한다.\n23. 카탈로그 refresh 실행은 `ShopManager.tryRefreshCatalog(...)` 한 경로에서 처리한다.\n24. SaveData 로드는 `ShopStorage`에만 반영되고, 런타임 카탈로그 반영은 이후 `LoginManager`가 호출하는 `ShopManager.Initialize()`에서 수행한다.\n25. `SHOP_ITEM_DAILY`의 모든 상태(`FREE\u002FADS` 포함, `amount` 포함)는 `dailyCatalogProducts`에 저장한다. DAILY 전용 `productRemainCounts` 중복 저장은 금지한다.\n26. 카탈로그별 남은 시간 조회는 `ShopCatalogBase.RemainAutoRefreshTimeMs`를 공통으로 사용하고, DAILY만 `ShopCatalogDaily.RemainAdsRefreshTimeMs`, `ManualRefreshRemainTimeMs`, `ManualRefreshRemainCount`를 사용한다.\n27. 로그인 초기화 마지막 단계에서 `LoginManager`가 `Initialize()`를 호출해 Shop 카탈로그 상태를 최종 정합화한다.\n28. `GetCatalog\u003CT>()`는 typed catalog 획득용이다. 사용 예: `ShopManager.Instance.GetCatalog\u003CShopCatalogDaily>()`.\n29. `ShopCatalogBase.ResetAds()`와 `ShopCatalogDaily.RefreshByAdsAsync()`는 catalog instance public API다.\n30. `ShopCatalogDaily.RefreshByAdsAsync()`는 daily catalog 내부에서 manual refresh 상태 판단, 광고 시청, 성공 기록을 직접 처리한다.\n31. `ShopCatalogDaily.RefreshByAdsAsync()`는 rolling 24시간 기준 최대 5회만 허용한다. 상태는 `manualRefreshUtcMs`, `manualRefreshRemainCount`로 관리하며, `manualRefreshRemainCount`는 남은 횟수다.\n32. `ShopCatalogDaily.RefreshByAdsAsync()` 제한 초과 시 `GAME_ERROR_TYPE.SHOP_ITEM_DAILY_MANUAL_REFRESH_COUNT_EXHAUSTED`를 반환한다.\n33. `ShopManager`는 `syncCatalogRuntimeStates()`로 catalog별 runtime state 동기화를 공통 처리한다.\n34. `ShopManager.ensureCatalogInitialized()`는 `CreateRuntimeCatalogs(storage)`로 catalog를 먼저 등록하고, 두 번째 pass에서 `Initialize()`를 호출한다.\n35. `CreateRuntimeCatalogs(storage)`는 catalog 생성 시 typed storage data를 같이 주입한다.\n36. `ShopManager.synchronizeProductIndexFromCatalogs()`는 product index와 limited shop id 인덱스만 재구성한다. remain\u002Fstorage 복원 책임은 가지지 않는다.\n\n---\n\n## 3. Currency Rules\n\n- `FREE`: 가격 0 구매로 사용(차감 없음)\n- `ADS`: 광고 시청 성공 시 구매 성공\n- `JEWEL`: `JEWEL_FREE` 우선 차감 후 부족분 `JEWEL_PAID` 차감\n- `NO_ADS` 대여(`InventoryStorage.GetRentalRemainingMs(\"NO_ADS\") > 0`) 중이면 `CanBuy`의 `AdsManager.CanShow` 체크와 `BuyAsync`의 광고 show를 skip한다.\n- `SHOP_DISCOUNT_TYPE`이 설정된 상품은 `ShopProductBase.Price`(할인 반영 값)로 지갑 잔액 검증\u002F차감을 수행한다.\n- `BuyAsync`의 통화 상품 구매는 `InventoryManager.Storage.Wallet`에서 `Currency_type` 기준으로 `Price`만큼 차감한다.\n- `BuyAsync` 차감 경로에서 `Price \u003C 0`은 즉시 차단하고 `SHOP_PRODUCT_PRICE_INVALID`를 반환한다. (음수 가격으로 재화 증감 금지)\n- 통화 부족 시 `GAME_ERROR_TYPE.SHOP_CURRENCY_INSUFFICIENT`를 반환한다.\n- `Initialize()` 이전의 `RefreshProducts\u002FCanBuy\u002FBuyAsync`와 catalog public API는 `GAME_ERROR_TYPE.SAVEDATA_SYNC_REQUIRED`로 실패한다.\n- `ShopCatalogBase.ResetAds()`: 지정 카탈로그를 강제 refresh한다. (`forceCatalogRefresh=true`)\n- `ShopCatalogDaily.RefreshByAdsAsync()`: DAILY 수동 refresh를 수행한다. 광고 실패는 `GAME_ERROR_TYPE.SHOP_ADS_SHOW_FAILED`, 횟수 소진은 `GAME_ERROR_TYPE.SHOP_ITEM_DAILY_MANUAL_REFRESH_COUNT_EXHAUSTED`를 반환한다.\n- `ShopCatalogDaily.RefreshByAdsAsync()`는 global refresh를 호출하지 않고 DAILY만 1회 refresh한다.\n- SaveData 저장은 ShopManager 초기화 루틴이 아니라 mutation 경로에서 수행한다. 일반 refresh\u002Fbuy는 ShopManager 저장 경로를 사용하고, DAILY manual refresh는 `ShopCatalogDaily.RefreshByAdsAsync()`가 로컬 저장을 직접 수행한다.\n\n---\n\n## 4. Catalog Rules\n\n- `DAILY`, `CHEST`, `GOLD`: `RewardManager` 지급\n- `PURCHASE`: `PurchaseManager` 구매 처리 사용 (`BuyAsync` 보상 지급 로직 직접 사용 금지)\n- `season_id` 구매 제한(시즌 종료 임박 차단)은 ShopManager에서 검사한다.\n\n---\n\n## 5. Error Rules\n\n- `CanBuy` 성공: `GAME_ERROR_TYPE.SUCCESS`\n- `CanBuy` 실패: `checkCanBuy()`에서 계산된 `GAME_ERROR_TYPE`을 그대로 반환한다. `SHOP_CAN_BUY_FAILED` wrapper나 `LastCanBuy*` 상태 필드는 사용하지 않는다.\n- `BuyAsync` 실패: 기본적으로 `GAME_ERROR_TYPE.SHOP_BUY_FAILED`\n- 단, 카탈로그 잠금 실패는 `GAME_ERROR_TYPE.GAME_INVALID_ARGUMENT`을 반환한다.\n- 단, 통화 부족 실패는 `GAME_ERROR_TYPE.SHOP_CURRENCY_INSUFFICIENT`를 그대로 반환한다.\n- inner 실패 코드는 메시지에 `inner=...` 형태로 포함한다.\n\n---\n\n## 6. Implementation Location (3-path mirror)\n\n- UPM (정본): `framework-cs\u002Fupm\u002Fcom.devian.foundation\u002FSamples~\u002FMobilePackage\u002FRuntime\u002FShop\u002FShopManager.cs`\n- Packages (sync): `framework-cs\u002Fapps\u002FUnityExample\u002FPackages\u002Fcom.devian.foundation\u002FSamples~\u002FMobilePackage\u002FRuntime\u002FShop\u002FShopManager.cs`\n- Assets\u002FSamples (import): `framework-cs\u002Fapps\u002FUnityExample\u002FAssets\u002FSamples\u002FDevian Samples\u002F{version}\u002FMobilePackage\u002FRuntime\u002FShop\u002FShopManager.cs`\n\n---\n\n## 7. Related\n\n- [11-shop-product](..\u002F11-shop-product\u002FSKILL.md)\n- [12-shop-storage](..\u002F12-shop-storage\u002FSKILL.md)\n- [13-shop-catalog](..\u002F13-shop-catalog\u002FSKILL.md)\n- [49-reward-system\u002F10-reward-manager](..\u002F..\u002F49-reward-system\u002F10-reward-manager\u002FSKILL.md)\n- [22-inventory-system\u002F12-inventory-wallet](..\u002F..\u002F22-inventory-system\u002F12-inventory-wallet\u002FSKILL.md)\n","# 10-shop-manager\n\nStatus: ACTIVE\nAppliesTo: v10\n\nShopManager는 MobilePackage 상점 구매 진입점이며, 카탈로그별 상품 목록을 관리한다.\n\n`catalog initialize\u002Frefresh operation`의 정본은 [03-ssot](..\u002F03-ssot\u002FSKILL.md)다.\n이 문서는 API\u002F구현 포인트를 다루며, 동작 순서 충돌 시 SSOT가 우선한다.\n\n---\n\n## 1. Class\n\n```csharp\npublic sealed class ShopManager : CompoSingleton\u003CShopManager>\n```\n\n- namespace: `Devian`\n- asmdef: `Devian.Samples.MobilePackage`\n- `MobileApplication`에서 `RequireComponent(typeof(ShopManager))`로 보장한다.\n\n---\n\n## 2. Public API\n\n```csharp\npublic GAME_ERROR_TYPE CanBuy(string shop_item_id)\npublic Task\u003CGameResult\u003CRewardData[]>> BuyAsync(string shop_item_id, CancellationToken ct = default)\npublic GameResult Initialize()\npublic GameResult RefreshProducts(bool requireServerTime = true)\npublic IReadOnlyList\u003CShopCatalogBase> GetCatalogs()\npublic ShopCatalogBase GetCatalog(SHOP_CATALOG_TYPE catalog_type)\npublic T GetCatalog\u003CT>() where T : ShopCatalogBase\npublic GameResult\u003CShopProductBase> GetProduct(string shop_item_id)\npublic GameResult\u003CShopRewardProductBase> GetRewardProduct(string shop_item_id)\npublic GameResult\u003CShopLimitedProductBase> GetLimitedProduct(string shop_item_id)\npublic GameResult\u003CShopProductDaily> GetDailyProduct(string shop_item_id)\npublic GameResult\u003CShopProductEvent> GetEventProduct(string shop_item_id)\npublic GameResult\u003CShopProductGold> GetGoldProduct(string shop_item_id)\npublic GameResult\u003CShopProductChest> GetChestProduct(string shop_item_id)\npublic GameResult\u003CShopProductPurchase> GetPurchaseProduct(string shop_item_id)\n```\n\n핵심 플로우:\n\n1. `shop_item_id`로 `ShopProductBase` 조회\n2. 카탈로그 목록은 하드코딩이 아니라 `TB_SHOP_CATALOG.GetAll()` row로 생성된다.\n3. `SHOP_ITEM_DAILY` 카탈로그는 초기화\u002Frefresh 시 `FREE\u002FADS 고정 + 비고정 5개 선택` snapshot으로 생성된다.\n4. `CHEST\u002FPURCHASE\u002FGOLD`는 각 `SHOP_*` 테이블의 모든 상품을 생성한다.\n5. `EVENT`는 `SHOP_ITEM_EVENT.start_time\u002Fend_time` 서버 UTC 구간 안에 있는 상품만 생성한다.\n6. `SHOP_ITEM_DAILY` 선택 생성에서 동일 `shop_item_id`(pk)는 중복 선택하지 않는다.\n7. 구매 제한 자동 refresh 체크(서버 시간 기준): 일반 카탈로그는 `autoRefreshUtcMs` + `auto_refresh_days` 규칙을 사용한다. `auto_refresh_days \u003C= 0`이면 자동 refresh를 사용하지 않는다.\n8. `EVENT`는 `auto_refresh_days`가 아니라 가장 가까운 미래 `start_time\u002Fend_time` 경계 시각을 `autoRefreshUtcMs`로 저장해 refresh를 예약한다.\n9. `unlock_msg_id`가 있는 카탈로그는 초기 `IsLocked=true`이며 `GameMessageManager` 구독으로 `unlock_op_type\u002Funlock_value` 조건 만족 시 해금된다.\n10. 잠긴 카탈로그의 상품은 `CanBuy\u002FBuyAsync`에서 차단된다.\n11. 구매 제한(`max_count\u002FremainCount`) 체크\n12. `ShopProductBase.Price`(할인 반영 최종가)로 구매 가능 여부\u002F통화 차감을 처리한다. 원가는 `ShopProductBase.PriceWithoutDiscount`를 사용한다.\n13. `SHOP_ITEM_DAILY.unit_price`는 단가다. DAILY 상품의 `PriceWithoutDiscount`는 `unit_price * snapshot.amount`이며 할인은 그 결과에 적용한다.\n14. 카탈로그 분기와 storage 기반 초기 product 구성은 `ShopCatalogFactory` + `ShopCatalogBase` 계층이 담당한다. ShopManager는 registry\u002Forchestration\u002F구매 검증\u002F결제를 담당한다.\n15. `ShopManager.onInitAwake()`는 catalog를 초기화하지 않으며, `Initialize()`가 유일한 manager 초기화 진입점이다.\n16. `ShopCatalogBase`는 `Initialize() -> onInitialize() -> RefreshProducts() -> onRefresh()` 라이프사이클을 따른다. product 생성 책임은 `onRefresh()`에 있다.\n17. 기본 `onRefresh()`는 `CHEST\u002FPURCHASE\u002FGOLD`의 테이블 전체 상품을 생성한다. `ShopCatalogDaily.onRefresh()`와 `ShopCatalogEvent.onRefresh()`가 고유 생성 로직을 override 한다.\n18. `PURCHASE`는 `PurchaseManager.PurchaseAsync(internal_product_id)` 위임, 그 외는 통화 차감\u002F광고 시청 후 `RewardManager` 지급\n19. 성공 시 `remainCount`와 DAILY snapshot 상태를 저장 + SaveData 저장\n20. ADS\u002FFREE 상품 리필은 카탈로그 저장 버킷의 `adsRefreshUtcMs`로 별도 관리하며, ADS\u002FFREE 구매 성공 시 `serverNow + 1day`로 기록한다.\n21. 카탈로그 초기화\u002Frefresh 시 `adsRefreshUtcMs`가 없거나 만료면 ADS\u002FFREE 제한 상품을 `remainCount=max_count`로 리필한다.\n22. refresh 조건 탐색은 `ShopManager.evaluateCatalogRefreshState(...)` 한 곳에서 처리한다.\n23. 카탈로그 refresh 실행은 `ShopManager.tryRefreshCatalog(...)` 한 경로에서 처리한다.\n24. SaveData 로드는 `ShopStorage`에만 반영되고, 런타임 카탈로그 반영은 이후 `LoginManager`가 호출하는 `ShopManager.Initialize()`에서 수행한다.\n25. `SHOP_ITEM_DAILY`의 모든 상태(`FREE\u002FADS` 포함, `amount` 포함)는 `dailyCatalogProducts`에 저장한다. DAILY 전용 `productRemainCounts` 중복 저장은 금지한다.\n26. 카탈로그별 남은 시간 조회는 `ShopCatalogBase.RemainAutoRefreshTimeMs`를 공통으로 사용하고, DAILY만 `ShopCatalogDaily.RemainAdsRefreshTimeMs`, `ManualRefreshRemainTimeMs`, `ManualRefreshRemainCount`를 사용한다.\n27. 로그인 초기화 마지막 단계에서 `LoginManager`가 `Initialize()`를 호출해 Shop 카탈로그 상태를 최종 정합화한다.\n28. `GetCatalog\u003CT>()`는 typed catalog 획득용이다. 사용 예: `ShopManager.Instance.GetCatalog\u003CShopCatalogDaily>()`.\n29. `ShopCatalogBase.ResetAds()`와 `ShopCatalogDaily.RefreshByAdsAsync()`는 catalog instance public API다.\n30. `ShopCatalogDaily.RefreshByAdsAsync()`는 daily catalog 내부에서 manual refresh 상태 판단, 광고 시청, 성공 기록을 직접 처리한다.\n31. `ShopCatalogDaily.RefreshByAdsAsync()`는 rolling 24시간 기준 최대 5회만 허용한다. 상태는 `manualRefreshUtcMs`, `manualRefreshRemainCount`로 관리하며, `manualRefreshRemainCount`는 남은 횟수다.\n32. `ShopCatalogDaily.RefreshByAdsAsync()` 제한 초과 시 `GAME_ERROR_TYPE.SHOP_ITEM_DAILY_MANUAL_REFRESH_COUNT_EXHAUSTED`를 반환한다.\n33. `ShopManager`는 `syncCatalogRuntimeStates()`로 catalog별 runtime state 동기화를 공통 처리한다.\n34. `ShopManager.ensureCatalogInitialized()`는 `CreateRuntimeCatalogs(storage)`로 catalog를 먼저 등록하고, 두 번째 pass에서 `Initialize()`를 호출한다.\n35. `CreateRuntimeCatalogs(storage)`는 catalog 생성 시 typed storage data를 같이 주입한다.\n36. `ShopManager.synchronizeProductIndexFromCatalogs()`는 product index와 limited shop id 인덱스만 재구성한다. remain\u002Fstorage 복원 책임은 가지지 않는다.\n\n---\n\n## 3. Currency Rules\n\n- `FREE`: 가격 0 구매로 사용(차감 없음)\n- `ADS`: 광고 시청 성공 시 구매 성공\n- `JEWEL`: `JEWEL_FREE` 우선 차감 후 부족분 `JEWEL_PAID` 차감\n- `NO_ADS` 대여(`InventoryStorage.GetRentalRemainingMs(\"NO_ADS\") > 0`) 중이면 `CanBuy`의 `AdsManager.CanShow` 체크와 `BuyAsync`의 광고 show를 skip한다.\n- `SHOP_DISCOUNT_TYPE`이 설정된 상품은 `ShopProductBase.Price`(할인 반영 값)로 지갑 잔액 검증\u002F차감을 수행한다.\n- `BuyAsync`의 통화 상품 구매는 `InventoryManager.Storage.Wallet`에서 `Currency_type` 기준으로 `Price`만큼 차감한다.\n- `BuyAsync` 차감 경로에서 `Price \u003C 0`은 즉시 차단하고 `SHOP_PRODUCT_PRICE_INVALID`를 반환한다. (음수 가격으로 재화 증감 금지)\n- 통화 부족 시 `GAME_ERROR_TYPE.SHOP_CURRENCY_INSUFFICIENT`를 반환한다.\n- `Initialize()` 이전의 `RefreshProducts\u002FCanBuy\u002FBuyAsync`와 catalog public API는 `GAME_ERROR_TYPE.SAVEDATA_SYNC_REQUIRED`로 실패한다.\n- `ShopCatalogBase.ResetAds()`: 지정 카탈로그를 강제 refresh한다. (`forceCatalogRefresh=true`)\n- `ShopCatalogDaily.RefreshByAdsAsync()`: DAILY 수동 refresh를 수행한다. 광고 실패는 `GAME_ERROR_TYPE.SHOP_ADS_SHOW_FAILED`, 횟수 소진은 `GAME_ERROR_TYPE.SHOP_ITEM_DAILY_MANUAL_REFRESH_COUNT_EXHAUSTED`를 반환한다.\n- `ShopCatalogDaily.RefreshByAdsAsync()`는 global refresh를 호출하지 않고 DAILY만 1회 refresh한다.\n- SaveData 저장은 ShopManager 초기화 루틴이 아니라 mutation 경로에서 수행한다. 일반 refresh\u002Fbuy는 ShopManager 저장 경로를 사용하고, DAILY manual refresh는 `ShopCatalogDaily.RefreshByAdsAsync()`가 로컬 저장을 직접 수행한다.\n\n---\n\n## 4. Catalog Rules\n\n- `DAILY`, `CHEST`, `GOLD`: `RewardManager` 지급\n- `PURCHASE`: `PurchaseManager` 구매 처리 사용 (`BuyAsync` 보상 지급 로직 직접 사용 금지)\n- `season_id` 구매 제한(시즌 종료 임박 차단)은 ShopManager에서 검사한다.\n\n---\n\n## 5. Error Rules\n\n- `CanBuy` 성공: `GAME_ERROR_TYPE.SUCCESS`\n- `CanBuy` 실패: `checkCanBuy()`에서 계산된 `GAME_ERROR_TYPE`을 그대로 반환한다. `SHOP_CAN_BUY_FAILED` wrapper나 `LastCanBuy*` 상태 필드는 사용하지 않는다.\n- `BuyAsync` 실패: 기본적으로 `GAME_ERROR_TYPE.SHOP_BUY_FAILED`\n- 단, 카탈로그 잠금 실패는 `GAME_ERROR_TYPE.GAME_INVALID_ARGUMENT`을 반환한다.\n- 단, 통화 부족 실패는 `GAME_ERROR_TYPE.SHOP_CURRENCY_INSUFFICIENT`를 그대로 반환한다.\n- inner 실패 코드는 메시지에 `inner=...` 형태로 포함한다.\n\n---\n\n## 6. Implementation Location (3-path mirror)\n\n- UPM (정본): `framework-cs\u002Fupm\u002Fcom.devian.foundation\u002FSamples~\u002FMobilePackage\u002FRuntime\u002FShop\u002FShopManager.cs`\n- Packages (sync): `framework-cs\u002Fapps\u002FUnityExample\u002FPackages\u002Fcom.devian.foundation\u002FSamples~\u002FMobilePackage\u002FRuntime\u002FShop\u002FShopManager.cs`\n- Assets\u002FSamples (import): `framework-cs\u002Fapps\u002FUnityExample\u002FAssets\u002FSamples\u002FDevian Samples\u002F{version}\u002FMobilePackage\u002FRuntime\u002FShop\u002FShopManager.cs`\n\n---\n\n## 7. Related\n\n- [11-shop-product](..\u002F11-shop-product\u002FSKILL.md)\n- [12-shop-storage](..\u002F12-shop-storage\u002FSKILL.md)\n- [13-shop-catalog](..\u002F13-shop-catalog\u002FSKILL.md)\n- [49-reward-system\u002F10-reward-manager](..\u002F..\u002F49-reward-system\u002F10-reward-manager\u002FSKILL.md)\n- [22-inventory-system\u002F12-inventory-wallet](..\u002F..\u002F22-inventory-system\u002F12-inventory-wallet\u002FSKILL.md)\n",{"name":5,"description":7},"main","8cb5bd877a3a7004867d2f55697cb9e4ff2f1734","skills\u002Fdevian-unity\u002F50-mobile-package\u002F28-shop-system\u002F10-shop-manager","https:\u002F\u002Fgithub.com\u002Fdevwinsoft\u002Fdevian","https:\u002F\u002Fgithub.com\u002Fdevwinsoft","Kim Hyong Joon","2026-04-12 18:44:10",{"source":8,"skillName":5,"commands":36,"prompt":46,"zipAvailable":47},[37,40,43],{"tool":38,"command":39},"npx","npx skills add devwinsoft\u002Fdevian --skill 10-shop-manager",{"tool":41,"command":42},"bunx","bunx skills add devwinsoft\u002Fdevian --skill 10-shop-manager",{"tool":44,"command":45},"pnpm","pnpm dlx skills add devwinsoft\u002Fdevian --skill 10-shop-manager","安装技能，技能地址：https:\u002F\u002Fgithub.com\u002Fdevwinsoft\u002Fdevian\u002Fblob\u002Fmain\u002Fskills\u002Fdevian-unity\u002F50-mobile-package\u002F28-shop-system\u002F10-shop-manager\u002FSKILL.md",true,[49,55,59,63],{"path":50,"resourceType":51,"resourceTypeDesc":52,"sizeBytes":53,"sourceHtmlUrl":54},"references\u002Fsubmission.md","REFERENCE","参考文档",2303,"https:\u002F\u002Fgithub.com\u002Fjiayaoqijia\u002Fcryptoskill\u002Fblob\u002Fmain\u002Fskills\u002Fchains\u002Friegan-ctf\u002Freferences\u002Fsubmission.md",{"path":56,"resourceType":51,"resourceTypeDesc":52,"sizeBytes":57,"sourceHtmlUrl":58},"references\u002Fwriteup.md",1170,"https:\u002F\u002Fgithub.com\u002Fjiayaoqijia\u002Fcryptoskill\u002Fblob\u002Fmain\u002Fskills\u002Fchains\u002Friegan-ctf\u002Freferences\u002Fwriteup.md",{"path":60,"resourceType":51,"resourceTypeDesc":52,"sizeBytes":61,"sourceHtmlUrl":62},"SOURCE.md",323,"https:\u002F\u002Fgithub.com\u002Fjiayaoqijia\u002Fcryptoskill\u002Fblob\u002Fmain\u002Fskills\u002Fchains\u002Friegan-ctf\u002FSOURCE.md",{"path":64,"resourceType":51,"resourceTypeDesc":52,"sizeBytes":65,"sourceHtmlUrl":66},"TRUST.auto.yaml",2100,"https:\u002F\u002Fgithub.com\u002Fjiayaoqijia\u002Fcryptoskill\u002Fblob\u002Fmain\u002Fskills\u002Fchains\u002Friegan-ctf\u002FTRUST.auto.yaml",[68,74,82,90,99,106],{"skillNo":69,"name":70,"nameZh":6,"description":6,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":15,"licenseFileUrl":6,"sourceHtmlUrl":71,"categories":72,"keywords":73,"publishedAt":23},"01M6CBX3K2JJV38ANHVGCAT8A0","devian","https:\u002F\u002Fgithub.com\u002Fdevwinsoft\u002Fdevian\u002Fblob\u002Fmain\u002Fskills\u002Fdevian\u002FSKILL.md",[],[70],{"skillNo":75,"name":76,"nameZh":6,"description":6,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":15,"licenseFileUrl":6,"sourceHtmlUrl":77,"categories":78,"keywords":79,"publishedAt":23},"01M6CBX210BFMM8AMN770J9WY8","90-project-archive","https:\u002F\u002Fgithub.com\u002Fdevwinsoft\u002Fdevian\u002Fblob\u002Fmain\u002Fskills\u002Fdevian\u002F80-tools\u002F90-project-archive\u002FSKILL.md",[],[80,81],"archive","90 project archive",{"skillNo":83,"name":84,"nameZh":6,"description":6,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":15,"licenseFileUrl":6,"sourceHtmlUrl":85,"categories":86,"keywords":87,"publishedAt":23},"01M6CBWZSGRR9R3YJZB3177667","30-env-management","https:\u002F\u002Fgithub.com\u002Fdevwinsoft\u002Fdevian\u002Fblob\u002Fmain\u002Fskills\u002Fdevian\u002F80-tools\u002F30-env-management\u002FSKILL.md",[],[88,89],"management","30 env management",{"skillNo":91,"name":92,"nameZh":6,"description":6,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":15,"licenseFileUrl":6,"sourceHtmlUrl":93,"categories":94,"keywords":95,"publishedAt":23},"01M6CBWYD3TGWBQV7V35551THF","19-page-push-send","https:\u002F\u002Fgithub.com\u002Fdevwinsoft\u002Fdevian\u002Fblob\u002Fmain\u002Fskills\u002Fdevian\u002F80-tools\u002F20-operation\u002F19-page-push-send\u002FSKILL.md",[],[96,97,98],"push","page","19 page push send",{"skillNo":100,"name":101,"nameZh":6,"description":6,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":15,"licenseFileUrl":6,"sourceHtmlUrl":102,"categories":103,"keywords":104,"publishedAt":23},"01M6CBWWRQ05A7604GHJKTPJTF","18-page-savedata","https:\u002F\u002Fgithub.com\u002Fdevwinsoft\u002Fdevian\u002Fblob\u002Fmain\u002Fskills\u002Fdevian\u002F80-tools\u002F20-operation\u002F18-page-savedata\u002FSKILL.md",[],[97,105],"18 page savedata",{"skillNo":107,"name":108,"nameZh":6,"description":6,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":15,"licenseFileUrl":6,"sourceHtmlUrl":109,"categories":110,"keywords":111,"publishedAt":23},"01M6CBWVH4S2VF1V7B2GBTESCT","17-page-deobfuscate","https:\u002F\u002Fgithub.com\u002Fdevwinsoft\u002Fdevian\u002Fblob\u002Fmain\u002Fskills\u002Fdevian\u002F80-tools\u002F20-operation\u002F17-page-deobfuscate\u002FSKILL.md",[],[97,112],"17 page deobfuscate"]