[{"data":1,"prerenderedAt":-1},["ShallowReactive",2],{"skill:01M6CBH5X0B4A7HTN9DBVN5FC0":3,"skill-same:01M6CBH5X0B4A7HTN9DBVN5FC0":44},{"skillNo":4,"name":5,"nameZh":6,"description":6,"descriptionZh":6,"repositoryFullName":7,"ownerLogin":8,"ownerAvatarUrl":9,"ownerAccountNo":10,"ownerDisplayName":11,"repositoryNo":12,"starsCount":13,"licenseSpdxId":14,"licenseFileUrl":6,"sourceHtmlUrl":15,"categories":16,"keywords":17,"publishedAt":19,"linkOnly":20,"markdownContent":21,"markdownBody":21,"frontMatter":22,"refName":23,"commitSha":24,"skillDirectory":25,"forksCount":13,"repositoryHtmlUrl":26,"repositoryHomepageUrl":6,"ownerHtmlUrl":27,"ownerBlogUrl":6,"ownerDescription":28,"updatedAt":29,"install":30,"resources":43},"01M6CBH5X0B4A7HTN9DBVN5FC0","10-savedata-manager",null,"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\u002F21-savedata-system\u002F10-savedata-manager\u002FSKILL.md",[],[18],"10 savedata manager","2026-11-10 10:12:30",false,"# devian-unity\u002F50-mobile-package — SaveDataManager\n\n\n## Scope\n- SaveLocal\u002FSaveCloud 로직을 단일 매니저로 통합하여 시나리오 기반 동기화(Conflict + 선택)를 수행한다.\n- SaveLocalManager\u002FSaveCloudManager는 삭제되었다. **SaveDataManager가 유일한 진입점**이다.\n- 현재 구조는 멀티 슬롯이 아니라 **단일 primary save** 기준이다.\n\n\n## Preconditions\n- DeviceId는 string GUID (앱 최초 실행 시 발급, `PlayerPrefs` 저장)\n- SaveSeq는 per-device 단조 증가 카운터 (`PlayerPrefs` 저장, same-device 최신성 판정용)\n- UtcTime, Checksum은 최신성 판정에 사용하지 않는다\n\n\n## Singleton\n- `SaveDataManager`는 `CompoSingleton\u003CSaveDataManager>` 기반이다.\n- 샘플 씬(또는 초기화 루틴)에서 컴포넌트로 배치되어 lifecycle을 가진다.\n\n\n## Primary Save Binding\n- public API는 단일 게임 상태 save만 다룬다.\n- 설정은 `SaveDataSettings` ScriptableObject에서 로드한다 → [11-savedata-settings](..\u002F11-savedata-settings\u002FSKILL.md)\n  - `PrimaryLocalFilename` (기본: `save\u002Fmain.json`)\n  - `PrimaryCloudSlot` (기본: `main`)\n  - `LocalRoot` (기본: `PersistentData`)\n\n중요:\n- 이 binding은 파일\u002F클라우드 키 매핑일 뿐이다.\n- 런타임 게임 상태는 `AccountManager.Storage`, `InventoryManager.Storage`, `PurchaseManager.Storage`의 singleton 집합 하나다.\n\n\n## SyncState\n- `Success` — 동기화 정상 완료, 데이터 존재\n- `Conflict` — 자동 승자 결정 불가\n- `Initial` — 데이터 없음, 또는 데이터는 있지만 sign-in 성공한 적 없음 (`Account.loginType == NONE`)\n\n\n## SyncResult\n- `SyncState State`\n- `SaveLocalPayload LocalPayload`\n- `SaveCloudPayload CloudPayload`\n- `LocalPayload.account` \u002F `CloudPayload.Account` — 계정 메타 미러\n- `string LocalDeviceId`\n- `string CloudDeviceId`\n- `SaveRecordSummary LocalSummary`\n- `SaveRecordSummary CloudSummary`\n\n`SyncGameStorageAsync(ct)` 반환 시 payload가 `SyncResult`에 포함되므로 추가 파일 I\u002FO는 불필요하다.\n인메모리 게임 상태 복원은 `SaveDataManager` 내부 책임이다. `SyncGameStorageAsync(ct)` 성공 시 `LoadFromPayload()`가 직접 manager storage를 복원한다.\n\n## Save Summary (payload 해석)\n- `SaveRecordSummary`\n  - `Exists`, `SchemaVersion`, `UpdateTime`, `SaveSeq`, `DeviceId`, `LoginType`, `SocialUserId`\n  - `PayloadSummary`\n- `SavePayloadSummary`\n  - `HasPayload`, `ParseSuccess`, `ParseError`, `JsonVersion`\n  - inventory count: `WalletCurrencyCount`, `HeroCount`, `CardCount`, `EquipCount`, `RentalCount`, `PassCount`\n  - runtime count: `MissionRuntimeCount`, `MissionCompletedCount`, `AchieveRuntimeCount`, `AchieveWaitingCount`, `AchieveCompletedCount`\n\n규칙:\n- 요약 생성(복호화 + JSON 파싱)은 `SaveDataManager` 내부에서 수행한다.\n- payload 파싱 실패여도 Sync 자체는 실패로 승격하지 않고 `ParseSuccess=false`로 요약에 반영한다.\n\n\n## Scenario\n\n### NONE (미로그인)\n- `LoginType.NONE`은 sign-in 성공 전 초기 상태다.\n- primary local 데이터를 로드한 뒤, `Account.loginType == NONE`이면 `Initial`을 반환한다.\n- local 데이터가 없어도 `Initial`을 반환한다.\n\n### Guest 로그인\n- CloudSave 경로 비활성: Cloud 호출 금지\n- `SyncGameStorageAsync(ct)`: primary local save만 로드 — 데이터 있으면 `Success`, 없으면 `Initial`\n\n### Cloud Init 실패 (Non-Guest)\n- Cloud 초기화 실패 + Local 없음 → `SyncGameStorageAsync` 실패 (`_initializeCloudAsync` 에러 반환)\n- Cloud 초기화 실패 + Local 있음 → local-only 모드로 진행 (`SyncGameStorageAsync`: local payload로 `Success` 반환)\n\n### Cloud Load\u002FSave 실패 (Non-Guest)\n- Cloud `LoadAsync`\u002F`SaveAsync` 실패는 `SyncGameStorageAsync`의 실패로 반환한다.\n- Cloud 연결 계층 실패는 `COMMON_ERROR_TYPE.CLOUDSAVE_CONNECTION_FAILED`를 사용한다.\n\n### Local 없음 + Cloud 있음\n- Cloud 데이터를 primary local에 저장\n- Cloud에는 재저장하지 않는다\n\n### Local 있음 + Cloud 없음\n- Cloud 쓰기 가능 시 Local을 Cloud에 저장한다\n\n### Local 있음 + Cloud 있음\n- `local.payload == cloud.Payload` → `Success`\n- `local.payload != cloud.Payload` AND `local.deviceId == cloud.DeviceId`\n  - `saveSeq` 비교로 자동 승자 결정\n  - `local.saveSeq > cloud.SaveSeq` → Local → Cloud 저장\n  - `cloud.SaveSeq > local.saveSeq` → Cloud → Local 저장\n  - `saveSeq` 누락\u002F동률 → `Conflict`\n- `local.payload != cloud.Payload` AND `local.deviceId != cloud.DeviceId` → `Conflict`\n  - `UseLocal` → Local → Cloud 저장\n  - `UseCloud` → Cloud → Local 저장\n\n\n## Public API\n\n### Sync\n- `Task\u003CCommonResult\u003CSyncResult>> SyncGameStorageAsync(CancellationToken ct)`\n- `Task\u003CCommonResult\u003Cbool>> ResolveConflictAsync(SyncResolution resolution, CancellationToken ct)`\n  - local-only(`GUEST\u002FEDITOR`) 모드에서는 cloud conflict 해소를 수행하지 않고 **no-op success(true)**를 반환한다.\n\n### Save\n- `Task\u003CCommonResult\u003Cbool>> SaveGameStorageAsync(bool saveCloud, CancellationToken ct)`\n\n규칙:\n- `SyncGameStorageAsync(ct)` 성공 후에만 저장 가능하다.\n- `Initial`도 primary save context를 활성화하므로 첫 저장이 가능하다.\n- `saveCloud=false`이면 local save만 수행하고 cloud save는 시도하지 않는다.\n- `saveCloud=true`이면 local save 후 cloud save를 best effort로 시도한다.\n- cloud 저장 실패는 non-fatal이며 `NeedsCloudSave`만 올린다.\n\n### Load\n- `CommonResult\u003Cbool> LoadLocalGameState()`\n\n### Clear\n- `Task\u003CCommonResult\u003Cbool>> ClearSaveAsync(CancellationToken ct)`\n\n### NeedsCloudSave\n- `bool NeedsCloudSave`\n- `void MarkNeedsCloudSave()`\n- `void ClearNeedsCloudSave()`\n\n### Payload Parsing\n- `static CommonResult\u003CT> ParsePayloadResult\u003CT>(SaveLocalPayload payload)`\n- `static CommonResult\u003CT> ParsePayloadResult\u003CT>(SaveCloudPayload payload)`\n\n### Serialization\n- `string ToJson()`\n\n현재 런타임 게임 상태(Inventory, Purchase, Account, Mission)를 평문 JSON으로 직렬화한다.\n내부적으로 `SaveDataJsonCodec.Serialize()`를 호출한다.\n\n### Recovery\n- `Task\u003CCommonResult\u003Cbool>> RestoreFromPlainJsonAsync(string json, bool saveCloud, CancellationToken ct)`\n\n평문 JSON을 런타임에 적용하고 local(+cloud)에 영속화한다.\n`_hasPrimarySaveContext`를 활성화하므로 Sync 없이 직접 복원이 가능하다.\nRecoveryManager(25-recovery-system)에서 Import 시 사용한다.\n\n내부 동작:\n1. `LoadFromJson(json)` — 런타임 스토리지에 적용\n2. `_hasPrimarySaveContext = true` — primary save context 활성화\n3. `SaveGameStorageAsync(saveCloud, ct)` — local(+cloud) 영속화\n\n\n## Internal API\n- `internal Task\u003CCommonResult\u003CSaveCloudResult>> _initializeCloudAsync(CancellationToken ct)` — `SaveDataManager` 내부 sync\u002Fresolve\u002Fsave 경로에서 호출\n- `internal bool _isCloudAvailable`\n\n\n## Json Ownership\n- `SaveDataManager`가 게임 상태 JSON 직렬화\u002F역직렬화의 유일한 진입점이다.\n- JSON 구현은 [43-savedata-json-codec](..\u002F43-savedata-json-codec\u002FSKILL.md)를 따른다.\n- 각 시스템은 자신의 storage를 직접 소유한다.\n  - `AccountManager.Instance.Storage`\n  - `InventoryManager.Instance.Storage`\n  - `PurchaseManager.Instance.Storage`\n- `SaveLocalPayload.account` \u002F `SaveCloudPayload.Account` 메타는 account mirror 용도로 유지한다.\n\n\n## Location\n- UPM: `framework-cs\u002Fupm\u002Fcom.devian.foundation\u002FSamples~\u002FMobilePackage\u002FRuntime\u002FSaveData\u002FSaveDataManager.cs`\n- UnityExample mirror: `framework-cs\u002Fapps\u002FUnityExample\u002FPackages\u002Fcom.devian.foundation\u002FSamples~\u002FMobilePackage\u002FRuntime\u002FSaveData\u002FSaveDataManager.cs`\n- Assets\u002FSamples import: `framework-cs\u002Fapps\u002FUnityExample\u002FAssets\u002FSamples\u002FDevian Samples\u002F{version}\u002FMobilePackage\u002FRuntime\u002FSaveData\u002FSaveDataManager.cs`\n\n\n## Out of Scope\n- 멀티 슬롯 UX\n- payload 부분 병합\n- utcTime 기반 최신성 판정\n- cross-device 자동 승자 결정\n",{},"main","8cb5bd877a3a7004867d2f55697cb9e4ff2f1734","skills\u002Fdevian-unity\u002F50-mobile-package\u002F21-savedata-system\u002F10-savedata-manager","https:\u002F\u002Fgithub.com\u002Fdevwinsoft\u002Fdevian","https:\u002F\u002Fgithub.com\u002Fdevwinsoft","Kim Hyong Joon","2026-04-12 18:44:10",{"source":7,"skillName":5,"commands":31,"prompt":41,"zipAvailable":42},[32,35,38],{"tool":33,"command":34},"npx","npx skills add devwinsoft\u002Fdevian --skill 10-savedata-manager",{"tool":36,"command":37},"bunx","bunx skills add devwinsoft\u002Fdevian --skill 10-savedata-manager",{"tool":39,"command":40},"pnpm","pnpm dlx skills add devwinsoft\u002Fdevian --skill 10-savedata-manager","安装技能，技能地址：https:\u002F\u002Fgithub.com\u002Fdevwinsoft\u002Fdevian\u002Fblob\u002Fmain\u002Fskills\u002Fdevian-unity\u002F50-mobile-package\u002F21-savedata-system\u002F10-savedata-manager\u002FSKILL.md",true,[],[45,51,59,67,76,83],{"skillNo":46,"name":47,"nameZh":6,"description":6,"descriptionZh":6,"repositoryFullName":7,"ownerLogin":8,"ownerAvatarUrl":9,"ownerAccountNo":10,"ownerDisplayName":11,"repositoryNo":12,"starsCount":13,"licenseSpdxId":14,"licenseFileUrl":6,"sourceHtmlUrl":48,"categories":49,"keywords":50,"publishedAt":19},"01M6CBX3K2JJV38ANHVGCAT8A0","devian","https:\u002F\u002Fgithub.com\u002Fdevwinsoft\u002Fdevian\u002Fblob\u002Fmain\u002Fskills\u002Fdevian\u002FSKILL.md",[],[47],{"skillNo":52,"name":53,"nameZh":6,"description":6,"descriptionZh":6,"repositoryFullName":7,"ownerLogin":8,"ownerAvatarUrl":9,"ownerAccountNo":10,"ownerDisplayName":11,"repositoryNo":12,"starsCount":13,"licenseSpdxId":14,"licenseFileUrl":6,"sourceHtmlUrl":54,"categories":55,"keywords":56,"publishedAt":19},"01M6CBX210BFMM8AMN770J9WY8","90-project-archive","https:\u002F\u002Fgithub.com\u002Fdevwinsoft\u002Fdevian\u002Fblob\u002Fmain\u002Fskills\u002Fdevian\u002F80-tools\u002F90-project-archive\u002FSKILL.md",[],[57,58],"archive","90 project archive",{"skillNo":60,"name":61,"nameZh":6,"description":6,"descriptionZh":6,"repositoryFullName":7,"ownerLogin":8,"ownerAvatarUrl":9,"ownerAccountNo":10,"ownerDisplayName":11,"repositoryNo":12,"starsCount":13,"licenseSpdxId":14,"licenseFileUrl":6,"sourceHtmlUrl":62,"categories":63,"keywords":64,"publishedAt":19},"01M6CBWZSGRR9R3YJZB3177667","30-env-management","https:\u002F\u002Fgithub.com\u002Fdevwinsoft\u002Fdevian\u002Fblob\u002Fmain\u002Fskills\u002Fdevian\u002F80-tools\u002F30-env-management\u002FSKILL.md",[],[65,66],"management","30 env management",{"skillNo":68,"name":69,"nameZh":6,"description":6,"descriptionZh":6,"repositoryFullName":7,"ownerLogin":8,"ownerAvatarUrl":9,"ownerAccountNo":10,"ownerDisplayName":11,"repositoryNo":12,"starsCount":13,"licenseSpdxId":14,"licenseFileUrl":6,"sourceHtmlUrl":70,"categories":71,"keywords":72,"publishedAt":19},"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",[],[73,74,75],"push","page","19 page push send",{"skillNo":77,"name":78,"nameZh":6,"description":6,"descriptionZh":6,"repositoryFullName":7,"ownerLogin":8,"ownerAvatarUrl":9,"ownerAccountNo":10,"ownerDisplayName":11,"repositoryNo":12,"starsCount":13,"licenseSpdxId":14,"licenseFileUrl":6,"sourceHtmlUrl":79,"categories":80,"keywords":81,"publishedAt":19},"01M6CBWWRQ05A7604GHJKTPJTF","18-page-savedata","https:\u002F\u002Fgithub.com\u002Fdevwinsoft\u002Fdevian\u002Fblob\u002Fmain\u002Fskills\u002Fdevian\u002F80-tools\u002F20-operation\u002F18-page-savedata\u002FSKILL.md",[],[74,82],"18 page savedata",{"skillNo":84,"name":85,"nameZh":6,"description":6,"descriptionZh":6,"repositoryFullName":7,"ownerLogin":8,"ownerAvatarUrl":9,"ownerAccountNo":10,"ownerDisplayName":11,"repositoryNo":12,"starsCount":13,"licenseSpdxId":14,"licenseFileUrl":6,"sourceHtmlUrl":86,"categories":87,"keywords":88,"publishedAt":19},"01M6CBWVH4S2VF1V7B2GBTESCT","17-page-deobfuscate","https:\u002F\u002Fgithub.com\u002Fdevwinsoft\u002Fdevian\u002Fblob\u002Fmain\u002Fskills\u002Fdevian\u002F80-tools\u002F20-operation\u002F17-page-deobfuscate\u002FSKILL.md",[],[74,89],"17 page deobfuscate"]