[{"data":1,"prerenderedAt":-1},["ShallowReactive",2],{"skill:01M6CBJP4VNEZ4B83MCSRCG330":3,"skill-same:01M6CBJP4VNEZ4B83MCSRCG330":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},"01M6CBJP4VNEZ4B83MCSRCG330","03-ssot",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\u002F27-attend-system\u002F03-ssot\u002FSKILL.md",[],[18],"03 ssot","2026-11-10 10:12:30",false,"# 03-ssot — 27-attend-system (SSOT)\n\nStatus: ACTIVE\nAppliesTo: v10\n\n## 이 문서가 정본이다 (SSOT)\n\n출석 시스템의 정본은 이 문서다.\n\n- `ATTEND` 테이블 스키마\n- row 필터\u002F정렬 규칙\n- cycle\u002Fday 계산 규칙\n- claim 가능\u002F처리 규칙\n- 저장 스키마(AttendStorage)\n\n---\n\n## A) ATTEND Table Schema (정본)\n\n정본 입력:\n- `input\u002FDomains\u002FGame\u002FMetaTable.xlsx`\n- sheet: `ATTEND`\n\n| 필드 | 타입 | 옵션 | 설명 |\n|---|---|---|---|\n| `attend_id` | string | pk | 출석 항목 ID |\n| `is_active` | bool | | 운영 활성 토글 |\n| `day` | int | | UI\u002F운영 day (1부터 시작) |\n| `reward_group_id` | string | | 지급할 보상 그룹 ID (`TB_REWARD` group key) |\n\n런타임에서는 `Devian.Domain.Game.ATTEND` row와 `TB_ATTEND` container를 사용한다.\n\n---\n\n## B) Runtime Row Selection Rules (정본)\n\n`activeRows` 계산:\n1. `TB_ATTEND.GetAll()` 조회\n2. `Is_active == true`만 포함\n3. `day >= 1 && day \u003C= 7`만 포함\n4. `reward_group_id`가 null\u002Fempty\u002Fwhitespace가 아닌 row만 포함\n5. `day ASC`, tie-break `attend_id ASC`로 정렬\n\n`day -> row` 매핑:\n- 같은 day가 여러 개인 경우 정렬 후 첫 row를 운영 row로 사용한다.\n- 출석 보상 day 범위는 `1..7` 고정이다.\n\n`attend runtime` 생성:\n- 런타임은 day `1..7` 고정 슬롯으로 항상 7개를 생성한다.\n- 각 슬롯은 `day -> row` 매핑 결과를 참조한다.\n- 해당 day row가 없으면 `isConfigured=false` 슬롯으로 유지한다(보정 없음).\n\n---\n\n## C) Cycle \u002F Day Rules (정본)\n\n기본 상수:\n- `DayMs = 86_400_000` (UTC 기준 24h)\n- `MaxAttendDay = 7`\n- `ResetAfterClaimMs = 259_200_000` (72h)\n\n핵심 상태:\n- `AttendStorage.nextAttendDay` (다음 claim day, 범위: `1..8`)\n  - `1..7`: 해당 day 보상 claim 대기\n  - `8`: 7일차까지 모두 claim 완료(다음 day reset 대기)\n\nreset 조건(정본):\n1. 출석 정보가 없을 때\n2. `lastClaimUtcMs > 0`이고 `(serverNowUtcMs - lastClaimUtcMs) >= 72h`일 때\n3. `nextAttendDay==8`이고, `lastClaimUtcMs`의 다음 UTC day로 넘어갔을 때\n\nreset 동작:\n- `nextAttendDay = 1`\n- `claimedAttendUtcMs.Clear()`\n- `lastClaimUtcMs = 0`\n- `cycleStartUtcMs = toUtcDayStart(serverNowUtcMs)`\n- reset 직후 runtime 상태는 `day1=CLAIMABLE`, `day2..7=WAIT`다.\n\n접속 시점 상태 갱신:\n- reset 판정 후 `lastLoginUtcMs = serverNowUtcMs`로 갱신한다.\n\n데이터 누락 정책:\n- `nextAttendDay`에 대응하는 `ATTEND.day` row가 없으면 보정을 수행하지 않는다.\n- 해당 상태에서는 claim이 단순 실패\u002Fskip된다.\n\n---\n\n## D) Claim Rules (정본)\n\n입력:\n- `attend_id` (string)\n\n출력:\n- `GameResult\u003CRewardData[]>`\n- 성공 시 이번 claim으로 적용된 보상 목록을 반환한다.\n\nclaim 가능 조건:\n1. row 존재\n2. row가 운영 대상(activeRows + `day 1..7`)에 포함\n3. `row.day == nextAttendDay`\n4. 같은 UTC day에 이미 claim하지 않았음 (`toUtcDayStart(lastClaimUtcMs) != toUtcDayStart(serverNowUtcMs)`)\n5. `claimedAttendUtcMs`에 해당 `attend_id`가 없음\n\nclaim 처리:\n1. `RewardManager.ApplyRewardGroup(row.reward_group_id)` 호출\n2. 실패면 storage mutation 없음\n3. 성공이면 `AttendStorage.SetClaimed(row.attend_id, serverNowUtcMs)` 반영\n4. day 진행:\n   - `row.day \u003C 7`이면 `nextAttendDay = row.day + 1`\n   - `row.day == 7`이면 `nextAttendDay = 8` (완료 상태)\n5. `SaveDataManager.SaveGameStorageAsync(true, ct)` 호출\n6. 위 저장은 SaveData cloud payload 저장이며, Firestore 저장이 아니다.\n\nclaim 후 runtime 상태 전이:\n- claim 당일에는 다음 day 슬롯이 즉시 열리지 않는다.\n- 같은 UTC day 중복 claim 금지 규칙에 따라 다음 day는 `WAIT` 상태다.\n- 다음 UTC day에 `RefreshCycle\u002FInitialize`가 수행되면 현재 `nextAttendDay` 슬롯이 `CLAIMABLE`이 된다.\n\n---\n\n## E) Storage Schema (정본)\n\n`AttendStorage` 기본 필드:\n\n```csharp\npublic sealed class AttendStorage\n{\n    public int schemaVersion = 2;\n    public long cycleStartUtcMs;\n    public long lastClaimUtcMs;\n    public long lastLoginUtcMs;\n    public int nextAttendDay;\n    public Dictionary\u003Cstring, long> claimedAttendUtcMs = new();\n}\n```\n\n규칙:\n- key: `attend_id`\n- value: 해당 attendId를 claim한 서버 UTC ms\n- `nextAttendDay` 범위는 `1..8`\n- `8`은 \"7일차까지 완료, reset 대기\" 상태다.\n\n---\n\n## F) SaveData JSON Section (정본)\n\n루트 payload의 `attend` 섹션:\n\n```json\n{\n  \"version\": 17,\n  \"attend\": {\n    \"schemaVersion\": 2,\n    \"cycleStartUtcMs\": 0,\n    \"lastClaimUtcMs\": 0,\n    \"lastLoginUtcMs\": 0,\n    \"nextAttendDay\": 1,\n    \"claimedAttendUtcMs\": {\n      \"attend_day_001\": 1735689600000\n    }\n  }\n}\n```\n\n직렬화\u002F역직렬화는 `SaveDataJsonCodecAttend`가 담당한다.\n\n---\n\n## G) Implementation Location (3-path mirror)\n\n- UPM (정본):\n  - `framework-cs\u002Fupm\u002Fcom.devian.foundation\u002FSamples~\u002FMobilePackage\u002FRuntime\u002FAttend\u002FAttendManager.cs`\n  - `framework-cs\u002Fupm\u002Fcom.devian.foundation\u002FSamples~\u002FMobilePackage\u002FRuntime\u002FAttend\u002FAttendStorage.cs`\n  - `framework-cs\u002Fupm\u002Fcom.devian.foundation\u002FSamples~\u002FMobilePackage\u002FRuntime\u002FSaveData\u002FJsonCodec\u002FSaveDataJsonCodecAttend.cs`\n- Packages (sync):\n  - `framework-cs\u002Fapps\u002FUnityExample\u002FPackages\u002Fcom.devian.foundation\u002FSamples~\u002FMobilePackage\u002FRuntime\u002F...`\n- Assets\u002FSamples (import):\n  - `framework-cs\u002Fapps\u002FUnityExample\u002FAssets\u002FSamples\u002FDevian Samples\u002F{version}\u002FMobilePackage\u002FRuntime\u002F...`\n\n---\n\n## H) Attend Runtime Model (정본)\n\n런타임 모델:\n\n```csharp\npublic enum AttendRuntimeState\n{\n    NONE = 0,\n    WAIT = 1,\n    CLAIMABLE = 2,\n    CLAIMED = 3,\n}\n\npublic sealed class AttendRuntime\n{\n    public int Day { get; }\n    public string Attend_id { get; }\n    public string Reward_group_id { get; }\n    public bool IsConfigured { get; }\n    public AttendRuntimeState State { get; }\n    public long ClaimedAtUtcMs { get; }\n}\n```\n\n상태 규칙:\n- `CLAIMED`: 해당 `attend_id`가 `claimedAttendUtcMs`에 존재\n- `CLAIMABLE`: `day == nextAttendDay`이고, today 중복 claim이 아니며, row가 존재\n- `WAIT`: 그 외 대기 상태(미래 day, 오늘 이미 claim, row 누락 포함)\n- `NONE`: 런타임 미초기화\u002F무효 입력 조회 등 예외적 조회 결과\n\n---\n\n## Related\n\n- [10-attend-manager](..\u002F10-attend-manager\u002FSKILL.md)\n- [11-attend-storage](..\u002F11-attend-storage\u002FSKILL.md)\n- [49-reward-system\u002F10-reward-manager](..\u002F..\u002F49-reward-system\u002F10-reward-manager\u002FSKILL.md)\n- [21-savedata-system\u002F43-savedata-json-codec](..\u002F..\u002F21-savedata-system\u002F43-savedata-json-codec\u002FSKILL.md)\n",{},"main","8cb5bd877a3a7004867d2f55697cb9e4ff2f1734","skills\u002Fdevian-unity\u002F50-mobile-package\u002F27-attend-system\u002F03-ssot","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 03-ssot",{"tool":36,"command":37},"bunx","bunx skills add devwinsoft\u002Fdevian --skill 03-ssot",{"tool":39,"command":40},"pnpm","pnpm dlx skills add devwinsoft\u002Fdevian --skill 03-ssot","安装技能，技能地址：https:\u002F\u002Fgithub.com\u002Fdevwinsoft\u002Fdevian\u002Fblob\u002Fmain\u002Fskills\u002Fdevian-unity\u002F50-mobile-package\u002F27-attend-system\u002F03-ssot\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"]