2 Commits
master ... tap2

Author SHA1 Message Date
Developer
04334691d0 一克泥在线服务 2026-06-18 18:03:47 +08:00
Developer
ebd5dafa2d update 2026-06-18 10:06:49 +08:00
1039 changed files with 146877 additions and 2685 deletions

63
.omo/boulder.json Normal file
View File

@@ -0,0 +1,63 @@
{
"schema_version": 2,
"active_work_id": "api-auth-fix-fb81d2ac",
"works": {
"api-auth-fix-fb81d2ac": {
"work_id": "api-auth-fix-fb81d2ac",
"active_plan": "/mnt/d/Projects/ichni_Official/.omo/plans/api-auth-fix.md",
"plan_name": "api-auth-fix",
"status": "active",
"started_at": "2026-06-17T09:10:43.670Z",
"updated_at": "2026-06-17T09:22:23.705Z",
"session_ids": [
"opencode:ses_12b516164ffekTIOv7bJsDWp4s"
],
"session_origins": {
"opencode:ses_12b516164ffekTIOv7bJsDWp4s": "direct"
},
"agent": "atlas",
"task_sessions": {
"todo:1": {
"task_key": "todo:1",
"task_label": "1",
"task_title": "Fix ApiResponse.cs + ApiClient.cs — GlobalResponse<T> camelCase + BaseUrl port",
"session_id": "opencode:ses_12b25b8bcffeXO77tLYyh5N2BA",
"agent": "Sisyphus-Junior",
"category": "quick",
"updated_at": "2026-06-17T09:22:23.705Z",
"started_at": "2026-06-17T09:20:19.050Z",
"status": "completed",
"ended_at": "2026-06-17T09:22:23.705Z",
"elapsed_ms": 124655
}
}
}
},
"active_plan": "/mnt/d/Projects/ichni_Official/.omo/plans/api-auth-fix.md",
"started_at": "2026-06-17T09:10:43.670Z",
"status": "active",
"updated_at": "2026-06-17T09:22:23.705Z",
"session_ids": [
"opencode:ses_12b516164ffekTIOv7bJsDWp4s"
],
"session_origins": {
"opencode:ses_12b516164ffekTIOv7bJsDWp4s": "direct"
},
"plan_name": "api-auth-fix",
"task_sessions": {
"todo:1": {
"task_key": "todo:1",
"task_label": "1",
"task_title": "Fix ApiResponse.cs + ApiClient.cs — GlobalResponse<T> camelCase + BaseUrl port",
"session_id": "opencode:ses_12b25b8bcffeXO77tLYyh5N2BA",
"agent": "Sisyphus-Junior",
"category": "quick",
"updated_at": "2026-06-17T09:22:23.705Z",
"started_at": "2026-06-17T09:20:19.050Z",
"status": "completed",
"ended_at": "2026-06-17T09:22:23.705Z",
"elapsed_ms": 124655
}
},
"agent": "atlas"
}

316
.omo/plans/api-auth-fix.md Normal file
View File

@@ -0,0 +1,316 @@
# API Auth Access Fix — Unity Client
## TL;DR
> **Quick Summary**: Fix 3 critical bugs preventing the Unity client from successfully authenticating with the IchniOnline backend: wrong server port, GlobalResponse<T> field name mismatch (PascalCase vs camelCase) causing silent deserialization failure, and null pointer in third-party login flow.
>
> **Deliverables**:
> - `ApiResponse.cs` + `ApiClient.cs` — Atomic fix: fields renamed to camelCase, BaseUrl port corrected
> - `AuthService.cs` — null check added for third-party login response
>
> **Estimated Effort**: Quick (3 files, targeted changes)
> **Parallel Execution**: YES — all 3 tasks can run in parallel (independent files)
> **Critical Path**: None (no dependencies between tasks)
---
## Context
### Original Request
用户要求结合后端工程和 Apifox API 文档来对 `Assets/Scripts/Online` 目录里的 API 访问进行检查和修复,仅处理认证相关接口。
### Interview Summary
**Key Discussions**:
- Only auth-related endpoints in scope: login, register, session-key, third-party login
- Ignore beatmap and other APIs for now
- Only modify files within `Assets/Scripts/Online/`
**Research Findings**:
- Server (ASP.NET Core) uses `System.Text.Json` with camelCase policy → JSON keys: `code`, `message`, `data`
- Unity client uses `JsonUtility.FromJson` which is **case-sensitive** → C# field names must exactly match JSON keys
- Current `GlobalResponse<T>` has PascalCase `Code`/`Message`/`Data`**silent deserialization failure**
- Server runs on `http://localhost:5308` (launchSettings.json) but Unity client has `http://localhost:60887`**all requests fail**
- Third-party login can return `data: null` (unbound account) → **NullReferenceException** when accessing `result.Data.token`
### Metis Review
*Skipped — clear scope, direct bugs, user confirmed approach.*
---
## Work Objectives
### Core Objective
Fix the Unity client's auth API access so that all 4 auth endpoints (session-key, login, register, third-party login) successfully communicate with the IchniOnline backend.
### Concrete Deliverables
- `Assets/Scripts/Online/Network/Models/ApiResponse.cs` — GlobalResponse field names corrected
- `Assets/Scripts/Online/Network/ApiClient.cs` — BaseUrl port fixed, field access updated
- `Assets/Scripts/Online/Logic/AuthService.cs` — null-safe third-party login handling
### Definition of Done
- `IchniOnlineApiClient.Instance.BaseUrl` returns `http://localhost:5308`
- `JsonUtility.FromJson` correctly populates `GlobalResponse<T>.code`/`message`/`data`
- Third-party login with unbound account does not throw NullReferenceException
- All 3 auth flows (password login, TapTap login, register) return correct `ApiResult`
### Must Have
- Fix BaseUrl port from 60887 to 5308
- Fix `GlobalResponse<T>` field names to match JSON camelCase
- Add null check for `result.Data` in third-party login flow
- All changes within `Assets/Scripts/Online/` only
### Must NOT Have (Guardrails)
- Do NOT touch beatmap or non-auth API code
- Do NOT modify files outside `Assets/Scripts/Online/`
- Do NOT add Newtonsoft.Json or other new dependencies
- Do NOT refactor architecture (stay minimal, targeted fixes)
---
## Verification Strategy (MANDATORY)
> **ZERO HUMAN INTERVENTION** — ALL verification is agent-executed.
### Test Decision
- **Infrastructure exists**: NO
- **Automated tests**: None
- **Framework**: N/A
### QA Policy
Every task MUST include agent-executed QA scenarios. Evidence saved to `.omo/evidence/task-{N}-{scenario-slug}.{ext}`.
- **Library/Module verification**: Use Bash (bun/node REPL style) with `script-execute` to run C# test snippets that directly verify `JsonUtility.FromJson` deserialization behavior
- **Code review verification**: Read modified files and verify correctness by cross-referencing with server API contracts
---
## Execution Strategy
```
Wave 1 (Start Immediately):
├── Task 1: Fix ApiResponse.cs + ApiClient.cs — GlobalResponse camelCase + BaseUrl port [quick]
└── Task 2: Fix AuthService.cs — third-party login null check [quick]
Wave FINAL (After ALL tasks):
├── Task F1: QA verification (oracle)
└── Task F2: Scope fidelity check (deep)
Critical Path: Task 1 → F1, F2 (independent from Task 2)
Parallel Speedup: ~50% faster than sequential (Task 1 and Task 2 run in parallel)
Max Concurrent: 2 (both in Wave 1)
```
---
## TODOs
- [x] 1. Fix ApiResponse.cs + ApiClient.cs — GlobalResponse<T> camelCase + BaseUrl port
**What to do**:
- In `ApiResponse.cs`: Rename field `Code``code`, `Message``message`, `Data``data`
- In `ApiClient.cs`:
- Change `BaseUrl` default from `"http://localhost:60887"` to `"http://localhost:5308"`
- Update all references to `GlobalResponse<T>` fields from PascalCase to camelCase:
- `response.Code``response.code`
- `response.Message``response.message`
- `response.Data``response.data`
- `errorResponse.Code``errorResponse.code`
- `errorResponse.Message``errorResponse.message`
- Verify: `ResponseCode` enum values remain unchanged (Ok=10000, etc.)
- Verify no other PascalCase field access patterns exist in ApiClient.cs
> **Rationale for merging**: ApiResponse.cs declares the fields, ApiClient.cs consumes them. Both files must be changed atomically — if committed separately, the codebase is broken regardless of order. A single agent handles both files in one task to guarantee correctness.
**Must NOT do**:
- Do not change `ResponseCode` enum values or names
- Do not change `ApiResult<T>` class structure
- Do not add `[JsonProperty]` or other attributes
- Do not change the `BuildUrl`, `AddAuthHeader`, or `SendAsync` methods
- Do not change the method signatures of `GetAsync<T>` or `PostAsync<T>`
**Recommended Agent Profile**:
> - **Category**: `quick`
> - Reason: Two files, simple field rename + port change, no logic changes
> - **Skills**: none required
> - **Skills Evaluated but Omitted**: N/A
**Parallelization**:
> - **Can Run In Parallel**: NO (merged from previous Tasks 1+2 due to compilation dependency)
> - **Parallel Group**: Wave 1 (with Task 2)
> - **Blocks**: None
> - **Blocked By**: None (can start immediately)
**References**:
- `Assets/Scripts/Online/Network/Models/ApiResponse.cs` — Field declaration site
- `Assets/Scripts/Online/Network/ApiClient.cs` — Field usage site + BaseUrl
- Server `GlobalResponse.cs` at `/mnt/d/Projects/IchniOnline/IchniOnline.Server/Models/Responses/GlobalResponse.cs` — Reference (PascalCase with System.Text.Json camelCase policy)
- Backend `launchSettings.json` at `/mnt/d/Projects/IchniOnline/IchniOnline.Server/Properties/launchSettings.json` — Confirms port 5308
- Current JSON wire format: `{"code":10000,"message":"Success","data":{...}}` — JSON keys are lowercase
**Acceptance Criteria**:
- `ApiResponse.cs` field declarations use camelCase: `code`, `message`, `data`
- `ApiClient.cs` field references use camelCase: `response.code`, `response.message`, `response.data`
- `ApiClient.cs` BaseUrl line 20: `"http://localhost:5308"`
- `script-execute` confirms `JsonUtility.FromJson<GlobalResponse<string>>` correctly deserializes `{"code":10000,"message":"OK","data":"test"}`
**QA Scenarios (MANDATORY)**:
```
Scenario: Verify GlobalResponse deserialization works with camelCase JSON
Tool: script-execute (C# Roslyn)
Preconditions: Both files have been modified
Steps:
1. Use script-execute to run: string json = "{\\\"code\\\":10000,\\\"message\\\":\\\"OK\\\",\\\"data\\\":\\\"test\\\"}";
2. Deserialize: var obj = UnityEngine.JsonUtility.FromJson<GlobalResponse<string>>(json);
3. Assert: obj.code == (ResponseCode)10000
4. Assert: obj.message == "OK"
5. Assert: obj.data == "test"
Expected Result: All 3 fields are correctly populated from camelCase JSON keys
Failure Indicators: Any field remains default (0/null)
Evidence: .omo/evidence/task-1-globalresponse-deserialize.txt
Scenario: Verify GlobalResponseBase error deserialization
Tool: script-execute
Preconditions: Both files have been modified
Steps:
1. Use script-execute to run: string json = "{\\\"code\\\":10400,\\\"message\\\":\\\"Bad request\\\"}";
2. Deserialize as base: var obj = UnityEngine.JsonUtility.FromJson<GlobalResponseBase>(json);
3. Assert: obj.code == (ResponseCode)10400
4. Assert: obj.message == "Bad request"
Expected Result: Error response fields are correctly populated
Evidence: .omo/evidence/task-1-globalresponsebase-deserialize.txt
Scenario: Verify BaseUrl port is corrected
Tool: script-read
Preconditions: ApiClient.cs has been updated
Steps:
1. Read line 20 of ApiClient.cs
2. Assert: BaseUrl default value is "http://localhost:5308"
Expected Result: Port is corrected to 5308
Evidence: .omo/evidence/task-1-baseurl.txt
Scenario: Verify all GlobalResponse field references use camelCase in ApiClient.cs
Tool: grep
Preconditions: ApiClient.cs has been updated
Steps:
1. Search for pattern: "response\.Code" in ApiClient.cs — should return 0 matches
2. Search for pattern: "response\.code" — should return 2+ matches
3. Search for pattern: "errorResponse\.Code" — should return 0 matches
4. Search for pattern: "errorResponse\.code" — should return 2+ matches
Expected Result: All PascalCase field references replaced with camelCase
Evidence: .omo/evidence/task-1-field-access.txt
```
**Evidence to Capture**:
- [ ] script-execute output for deserialization test
- [ ] Line 20 read output
- [ ] grep results for field access patterns
**Commit**: YES
- Message: `fix(api): rename GlobalResponse<T> fields to camelCase, correct BaseUrl port to 5308`
- Files: `Assets/Scripts/Online/Network/Models/ApiResponse.cs`, `Assets/Scripts/Online/Network/ApiClient.cs`
- Pre-commit: Verify build compiles
---
- [x] 2. Fix AuthService.cs — third-party login null check
**What to do**:
- In `CompleteTapTapLoginAsync` method (~line 109): Add null check for `result.Data` before accessing `.token`
- Handle the case where third-party login succeeds (code=10000) but data is null (unbound account):
- If `result.IsSuccess && result.Data != null`: proceed normally (save session, set JWT, fire success)
- If `result.IsSuccess && result.Data == null`: fire `OnLoginFailed` with a clear message about account not being bound/linked
**Must NOT do**:
- Do not change the password login flow (LoginWithPasswordAsync)
- Do not change register flow
- Do not change the encryption logic
- Do not modify ThirdPartyServiceManager
**Recommended Agent Profile**:
> - **Category**: `quick`
> - Reason: Single null check addition in one method
> - **Skills**: none required
> - **Skills Evaluated but Omitted**: N/A
**Parallelization**:
> - **Can Run In Parallel**: YES
> - **Parallel Group**: Wave 1 (with Tasks 1, 2)
> - **Blocks**: None
> - **Blocked By**: None
**References**:
- `Assets/Scripts/Online/Logic/AuthService.cs` — File to edit
- Server `AuthController.cs` at `/mnt/d/Projects/IchniOnline/IchniOnline.Server/Controller/AuthController.cs` lines 70-77 — Shows the `ThirdPartyLogin` can return `GlobalResponse<LoginResponse>.Ok(null, "Account not bound")`
- `ApiResponse.cs` `ApiResult<T>` class — For understanding `IsSuccess`, `Data` properties
**Acceptance Criteria**:
- `CompleteTapTapLoginAsync` method has null guard before `result.Data.token` access
- When `result.IsSuccess && result.Data == null`: calls `OnLoginFailed` with message containing "not bound" or "unbound"
- When `result.IsSuccess && result.Data != null`: saves auth session, sets JWT, fires `OnLoginSuccess`
- Password login flow (`LoginWithPasswordAsync`) is unchanged
**QA Scenarios (MANDATORY)**:
```
Scenario: Verify null data handling for third-party login
Tool: script-read
Preconditions: AuthService.cs has been updated
Steps:
1. Read the CompleteTapTapLoginAsync method
2. Verify: there is a null check for result.Data before result.Data.token access
3. Verify: null case calls OnLoginFailed with appropriate message
Expected Result: NullReferenceException is prevented
Evidence: .omo/evidence/task-2-null-check.txt
Scenario: Verify password login flow is unchanged
Tool: grep
Preconditions: AuthService.cs has been updated
Steps:
1. Search for "result\.Data\.token" in AuthService.cs — should only appear in the TapTap completion method
2. Verify all occurrences have null guards
Expected Result: No unprotected access to result.Data.token
Evidence: .omo/evidence/task-2-password-flow.txt
```
**Evidence to Capture**:
- [ ] Read output showing the null check code
- [ ] grep results
**Commit**: YES
- Message: `fix(auth): add null check for third-party login response data`
- Files: `Assets/Scripts/Online/Logic/AuthService.cs`
- Pre-commit: Verify build compiles
---
## Final Verification Wave
- [ ] F1. **Plan Compliance Audit** — `oracle`
Read the plan end-to-end. For each "Must Have": verify implementation exists (read file, check values). For each "Must NOT Have": search codebase for forbidden patterns — reject with file:line if found. Check evidence files exist in .omo/evidence/. Compare deliverables against plan.
Output: `Must Have [N/N] | Must NOT Have [N/N] | Tasks [N/N] | VERDICT: APPROVE/REJECT`
- [ ] F2. **Scope Fidelity Check** — `deep`
For each task: read "What to do", read actual diff (git log/diff). Verify 1:1 — everything in spec was built (no missing), nothing beyond spec was built (no creep). Check "Must NOT do" compliance. Detect cross-task contamination.
Output: `Tasks [N/N compliant] | Contamination [CLEAN/N issues] | VERDICT`
---
## Commit Strategy
- **1**: `fix(api): rename GlobalResponse<T> fields to camelCase, correct BaseUrl port to 5308`
- Files: `ApiResponse.cs`, `ApiClient.cs`
- **2**: `fix(auth): add null check for third-party login response data`
- Files: `AuthService.cs`
---
## Success Criteria
### Verification Scenarios
1. `script-execute` confirms `JsonUtility.FromJson<GlobalResponse<string>>` correctly parses `{"code":10000,"message":"OK","data":"test"}`
2. `ApiClient.cs` line 20 shows `BaseUrl = "http://localhost:5308"`
3. `AuthService.cs` has null guard before `result.Data.token` access
### Final Checklist
- [ ] All "Must Have" present
- [ ] All "Must NOT Have" absent
- [ ] All 3 files modified correctly

View File

@@ -0,0 +1,918 @@
# Plan: IchniOnline API Integration
## TL;DR
> **Quick Summary**: Create an HTTP API client layer using BestHTTP and implement full auth flows (TapTap third-party login, password login, registration) to connect the Unity game client to the IchniOnline backend server.
>
> **Deliverables**:
> - BestHTTP-based API client with JSON serialization, JWT injection, error handling
> - Request/Response DTOs matching server contracts
> - Auth orchestration service (TapTap login, password login, register, logout)
> - Extended LoginCacheData with JWT + server user data
> - Updated LoginPage/StartUIPage to use new auth service
>
> **Estimated Effort**: Medium
> **Parallel Execution**: YES — 4 waves
> **Critical Path**: asmdef → ApiClient → AuthService → UI Integration
---
## Context
### Original Request
对接 IchniOnline 后端的 API 接口,使用 BestHTTP 进行 HTTP 通信,编写详细计划。所有代码写在 `Scripts/Online` 目录内IchniOnline.asmdef
### Interview Summary
**Key Discussions**:
- 服务器开发地址: `localhost:5433`,可配置 Base URL
- ThirdParty.Unbound: 暂时跳过,当做登录失败处理
- JWT 存储: 扩展 LoginCacheData加入 JWT + 用户数据,取代纯 TapTap 缓存
- 计划范围: TapTap 登录 + 密码登录/注册 + 基础 API 框架
**Research Findings**:
- **BestHTTP** 已安装为 embedded package (`com.tivadar.best.http`)GUID: `9069ac25d95ca17448a247f3bb1c769f`,支持 async/await via `Task<T>`
- **TapTap SDK** 登录返回 `TapTapAccount.accessToken`kid/tokenType/macKey/macAlgorithm可直接映射到 `ThirdPartyLoginRequest`
- **服务端密码加密**: XOR with session key`UserService.DecryptPassword`
- **服务端响应格式**: `GlobalResponse<T> { Code, Message, Data }`Code 10000=Ok
- **JWT 验证**: 服务端使用 JWT Bearer后续 API 请求需在 Header 注入 `Authorization: Bearer {token}`
### Metis Review
*(Skipped per user request — direct exploration used instead)*
---
## Work Objectives
### Core Objective
为 Unity 客户端创建 IchniOnline 后端 API 对接层实现完整的认证流程TapTap 第三方登录、密码登录、注册),让客户端和服务端互通。
### Concrete Deliverables
- `Scripts/Online/IchniOnline.asmdef` — 添加 BestHTTP 引用
- `Scripts/Online/Network/ApiClient.cs` — BestHTTP 封装单例
- `Scripts/Online/Network/Models/ApiResponse.cs` — 响应模型
- `Scripts/Online/Network/Models/AuthDtos.cs` — 请求/响应 DTO
- `Scripts/Online/Logic/AuthService.cs` — 认证编排服务
- `Scripts/Online/Logic/ThirdPartyServiceManager.cs` — 修改:集成 AuthService
- `Scripts/Online/Logic/LoginCacheManager.cs` — 修改:扩展 JWT 支持
- `Scripts/Online/Models/LoginCacheData.cs` — 修改:添加 JWT 字段
### Definition of Done
- [ ] 编译零错误,无警告
- [ ] TapTap 登录后 JWT Token 成功写入本地缓存
- [ ] 密码登录流程完整session key → XOR 加密 → login → JWT
- [ ] 注册流程完整
- [ ] LoginPage 使用新 AuthService 驱动流程
- [ ] StartUIPage 校验会话有效性
### Must Have
- ApiClient 支持 Base URL 配置(运行时可修改)
- ApiClient 自动注入 JWT Bearer 到 Authorization Header
- 所有 DTO 映射与服务器 `GlobalResponse<T>` 完全匹配
- 密码 XOR 加密算法与服务器端一致
- AuthService 对外暴露事件OnLoginSuccess/OnLoginFailed/etc.
- LoginCacheData 向后兼容(旧缓存不报错)
### Must NOT Have (Guardrails)
- 不要引入新的第三方 HTTP 库(只用 BestHTTP
- 不要修改 `LoginCacheEditor.cs`(但可扩展)
- 不要修改 `UI/Base/UIPageBase.cs`
- 不要引入 Newtonsoft.Json用 BestHTTP 内置的 LitJson 或 UnityEngine.JsonUtility
- 不要阻塞主线程(所有 HTTP 请求必须异步)
---
## Verification Strategy
### Test Decision
- **Infrastructure exists**: YES (Unity Test Framework)
- **Automated tests**: None for network layer (needs running server)
- **Agent-Executed QA**: ALWAYS — each task verified via Playwright/browser or manual Unity Editor play mode evidence
### QA Policy
Every task MUST include agent-executed QA scenarios.
- C# compilation through Unity Editor domain reload (verify zero compilation errors)
- Evidence: Unity Editor console logs, screenshots of play mode, runtime log output
---
## Execution Strategy
### Parallel Execution Waves
```
Wave 1 (Foundation — all parallel):
├── Task 1: IchniOnline.asmdef — 添加 BestHTTP 引用 [quick]
├── Task 2: ApiResponse.cs — GlobalResponse<T> 响应模型 [quick]
├── Task 3: AuthDtos.cs — 请求/响应 DTO [quick]
└── Task 4: LoginCacheData.cs — 扩展 JWT 字段 [quick]
Wave 2 (Core Client — depends on Wave 1):
├── Task 5: ApiClient.cs — BestHTTP 封装单例 [unspecified-high]
└── Task 6: LoginCacheManager.cs — 扩展 JWT 存取 [quick]
Note: Task 5+6 can run in parallel
Wave 3 (Auth Service — depends on Task 5+6):
├── Task 7: AuthService.cs — 认证编排服务 [unspecified-high]
Note: Depends on ApiClient + LoginCacheManager
Wave 4 (UI Integration — depends on Task 7):
├── Task 8: ThirdPartyServiceManager.cs — 集成 AuthService [quick]
├── Task 9: LoginPage.cs — 使用新 AuthService [visual-engineering]
└── Task 10: StartUIPage.cs — 校验会话有效性 [visual-engineering]
Wave FINAL:
├── F1: Plan Compliance Audit (oracle)
├── F2: Code Quality Review (unspecified-high)
├── F3: Real Manual QA (unspecified-high)
└── F4: Scope Fidelity Check (deep)
```
### Dependency Matrix
- **Task 1-4**: - → 5, 6 → Wave 2
- **Task 5, 6**: 1-4 → 7 → Wave 3
- **Task 7**: 5, 6 → 8-10 → Wave 4
- **Task 8-10**: 7 → F1-F4 → Final
---
## TODOs
- [x] 1. IchniOnline.asmdef — 添加 BestHTTP 引用
**What to do**:
-`IchniOnline.asmdef``references` 数组中追加 BestHTTP 的 GUID
- BestHTTP Runtime asmdef GUID: `9069ac25d95ca17448a247f3bb1c769f`
- 验证 Unity 编译无误IchniOnline 程序集可以 `using Best.HTTP;`
**Must NOT do**:
- 不要修改其他 asmdef
- 不要修改 IchniOnline.Editor.asmdef
**Recommended Agent Profile**:
- **Category**: `quick`
- Reason: 单行 asmdef 修改,极简单的任务
- **Skills**: None needed
**Parallelization**:
- **Can Run In Parallel**: YES
- **Parallel Group**: Wave 1 (with Tasks 2, 3, 4)
- **Blocks**: Tasks 5, 6
- **Blocked By**: None
**References**:
- `Assets/Scripts/Online/IchniOnline.asmdef` — 目标文件
- `Packages/com.tivadar.best.http/Runtime/com.Tivadar.Best.HTTP.asmdef.meta:L1-7` — GUID: `9069ac25d95ca17448a247f3bb1c769f`
**Acceptance Criteria**:
- [ ] `IchniOnline.asmdef``references` 数组包含 `"GUID:9069ac25d95ca17448a247f3bb1c769f"`
- [ ] Unity 编译完成后IchniOnline 程序集内可以 `using Best.HTTP;` 无报错
**QA Scenarios**:
```
Scenario: asmdef 引用验证
Tool: Bash (配合 Unity Editor)
Preconditions: Unity Editor 已打开IchniOnline.asmdef 已修改
Steps:
1. 在 Unity 中等待编译完成AssetDatabase.Refresh 或观察 Console 无红错)
2. 打开 `Assets/Scripts/Online/IchniOnline.asmdef` 确认 references 包含 BestHTTP GUID
Expected Result: 编译零错误,无 warning
Evidence: .omo/evidence/task-1-asmdef-refs.png (Editor 无错误截图)
```
**Commit**: YES (group with Tasks 2-4)
- Message: `feat(online): add BestHTTP reference to IchniOnline.asmdef`
- Files: `Assets/Scripts/Online/IchniOnline.asmdef`
- Pre-commit: 验证 Unity 编译通过
---
- [x] 2. ApiResponse.cs — 创建服务端响应模型
**What to do**:
- 新建文件: `Assets/Scripts/Online/Network/Models/ApiResponse.cs`
- 定义 `ResponseCode` enum匹配服务端 `Models/Responses/ResponseCode.cs`
- 定义 `GlobalResponse<T>` class匹配服务端 `GlobalResponse<T>`
- 定义 `ApiResult<T>` 封装类,包含成功/失败状态 + 错误信息
- 使用 `System.Text.Json` 或 `UnityEngine.JsonUtility` 做序列化(优先 JsonUtility 避免额外依赖)
- 命名空间: `IchniOnline.Online.Network.Models`
**关键映射**:
```csharp
// 服务端 ResponseCode
public enum ResponseCode {
Ok = 10000,
BadRequest = 10400,
Unauthorized = 10401,
Forbidden = 10403,
NotFound = 10404,
InternalServerError = 10500
}
// 服务端 GlobalResponse<T>
// 注意: Unity JsonUtility 不支持泛型直接反序列化,需要包装或使用封装层
// 方案: 实现一个非泛型的 ApiResponse 做中间反序列化,再通过 ApiResult<T> 取 Data
```
**Must NOT do**:
- 不要引入 Newtonsoft.Json项目中已无引用
- 不要将序列化逻辑写死到 ApiClient 之外
**Recommended Agent Profile**:
- **Category**: `quick`
- Reason: 纯数据模型定义,无复杂逻辑
- **Skills**: None
**Parallelization**:
- **Can Run In Parallel**: YES
- **Parallel Group**: Wave 1 (with Tasks 1, 3, 4)
- **Blocks**: Tasks 5, 6
- **Blocked By**: None
**References**:
- `D:\Projects\IchniOnline\IchniOnline.Server\Models\Responses\GlobalResponse.cs` — 服务端 GlobalResponse 实现
- `D:\Projects\IchniOnline\IchniOnline.Server\Models\Responses\ResponseCode.cs` — 服务端 ResponseCode enum
**Acceptance Criteria**:
- [ ] `ApiResponse.cs` 中定义 `ResponseCode` enumOk/BadRequest/Unauthorized/Forbidden/NotFound/InternalServerError
- [ ] `GlobalResponse<T>` class 包含 Code/Message/Data 三个字段
- [ ] Unity 编译通过,无错误
**QA Scenarios**:
```
Scenario: 编译验证
Tool: Unity Editor
Preconditions: 文件创建完成Unity 编译通过
Steps:
1. 在任意脚本中添加 `using IchniOnline.Online.Network.Models;`
2. 使用 `ResponseCode.Ok` 确认 enum 可用
Expected Result: 编译零错误
Evidence: .omo/evidence/task-2-compile.png
```
**Commit**: YES (group with Tasks 1, 3, 4)
---
- [x] 3. AuthDtos.cs — 创建认证请求/响应 DTO
**What to do**:
- 新建文件: `Assets/Scripts/Online/Network/Models/AuthDtos.cs`
- 定义以下 DTO匹配服务端 Models
```csharp
// 请求 DTO
[System.Serializable]
public class ThirdPartyLoginRequestDto {
public string Token; // accessToken.kid
public string TokenType; // accessToken.tokenType ("mac")
public string MacKey; // accessToken.macKey
public string MacAlgorithm; // accessToken.macAlgorithm ("hmac-sha-1")
}
[System.Serializable]
public class LoginRequestDto {
public string Username;
public string EncryptedPassword; // Base64 of XOR'd bytes
public string SessionKey;
}
[System.Serializable]
public class RegisterRequestDto {
public string Username;
public string Password;
public string DisplayName;
}
// 响应 DTO与服务端 AuthResponse.cs 对应)
[System.Serializable]
public class SessionKeyResponseDto {
public string sessionKey;
public string expiresAt;
}
[System.Serializable]
public class LoginResponseDto {
public string Token; // JWT
public UserResponseDto User;
}
[System.Serializable]
public class UserResponseDto {
public string UserId;
public string Username;
public string DisplayName;
public string AvatarUrl;
public int Permission; // 0=Guest, 1=Player, 2=Admin
}
```
**Must NOT do**:
- 不要包含非认证相关的 DTO如 Beatmap 相关)
**Recommended Agent Profile**:
- **Category**: `quick`
- Reason: 纯数据结构定义
- **Skills**: None
**Parallelization**:
- **Can Run In Parallel**: YES
- **Parallel Group**: Wave 1 (with Tasks 1, 2, 4)
- **Blocks**: Tasks 5, 7
- **Blocked By**: None
**References**:
- `D:\Projects\IchniOnline\IchniOnline.Server\Models\Requests\ThirdPartyLoginRequest.cs`
- `D:\Projects\IchniOnline\IchniOnline.Server\Models\Requests\LoginRequest.cs`
- `D:\Projects\IchniOnline\IchniOnline.Server\Models\Requests\RegisterRequest.cs`
- `D:\Projects\IchniOnline\IchniOnline.Server\Models\Responses\AuthResponse.cs`
**Acceptance Criteria**:
- [ ] 所有 DTO 定义为 `[System.Serializable]`
- [ ] Unity 编译通过
- [ ] 字段名小写JSON 反序列化兼容服务端 PascalCase → JsonUtility 需字段名匹配)
**QA Scenarios**:
```
Scenario: 编译验证
Tool: Unity Editor
Preconditions: 文件创建完成
Steps: Unity 编译自动触发
Expected Result: 无编译错误
Evidence: .omo/evidence/task-3-compile.png
```
**Commit**: YES (group with Tasks 1, 2, 4)
---
- [x] 4. LoginCacheData.cs — 扩展 JWT 和服务器用户数据字段
**What to do**:
- 编辑 `Assets/Scripts/Online/Models/LoginCacheData.cs`
- 添加以下字段:
```csharp
public string jwtToken; // JWT Bearer token
public string userId; // 服务端返回的 UserId (Guid 字符串)
public string displayName; // 服务端返回的 DisplayName
public string avatarUrl; // 服务端返回的 AvatarUrl
public int permission; // 服务端返回的 Permission
public bool hasServerSession; // 标记是否已完成服务端认证
```
- 更新 `IsValid` 逻辑:`hasServerSession && !string.IsNullOrEmpty(jwtToken)`
- 保持向后兼容:构造旧字段保留,无 session 时 `hasServerSession=false`
- 添加 `UpdateFromServerResponse(LoginResponseDto response)` 方法
- 添加 `ClearServerSession()` 方法(仅清除 JWT 系列字段,保留 TapTap 原始信息)
**Must NOT do**:
- 不要删除已有字段 (openId/unionId/name/avatar/email/cacheTimestamp)
- 不要破坏 `LoginCacheEditor.cs` 中使用的公开接口
**Recommended Agent Profile**:
- **Category**: `quick`
- Reason: 小幅度字段扩展,结构简单
- **Skills**: None
**Parallelization**:
- **Can Run In Parallel**: YES
- **Parallel Group**: Wave 1 (with Tasks 1, 2, 3)
- **Blocks**: Task 6
- **Blocked By**: None
**References**:
- `Assets/Scripts/Online/Models/LoginCacheData.cs` — 当前文件
- `D:\Projects\IchniOnline\IchniOnline.Server\Models\Responses\AuthResponse.cs` — 服务端 LoginResponse/UserResponse
**Acceptance Criteria**:
- [ ] `LoginCacheData` 包含新字段 `jwtToken`, `userId`, `displayName`, `avatarUrl`, `permission`, `hasServerSession`
- [ ] 旧 ES3 缓存数据加载后不会报错(缺失字段为默认值,`hasServerSession=false`
- [ ] `IsValid` 在 `hasServerSession` 为 true 时检查 `jwtToken` 非空
- [ ] Unity 编译通过
**QA Scenarios**:
```
Scenario: 向后兼容测试
Tool: Unity Editor + Bash (ES3 操作)
Preconditions: LoginCacheData 已修改Unity 已编译
Steps:
1. 用 LoginCacheEditor.GenerateMockData 写入旧格式数据
2. 读取 LoginCacheManager.CachedData
3. 确认 hasServerSession=false旧字段值正确
Expected Result: 旧缓存被正确加载,不丢失数据,不报错
Evidence: .omo/evidence/task-4-backward-compat.png
```
**Commit**: YES (group with Tasks 1, 2, 3)
---
- [x] 5. ApiClient.cs — BestHTTP 封装单例
**What to do**:
- 新建文件: `Assets/Scripts/Online/Network/ApiClient.cs`
- 创建 `IchniOnlineApiClient` 类(非 MonoBehaviour 单例,或挂载到 DontDestroyOnLoad 对象)
- 核心功能:
1. **Base URL 配置**: `public string BaseUrl { get; set; }`,初始值 `http://localhost:5433`
2. **JWT 管理**: `public string JwtToken { get; set; }`,设置后自动在请求头注入
3. **GET 请求**: `Task<ApiResult<T>> GetAsync<T>(string endpoint)`
4. **POST 请求**: `Task<ApiResult<T>> PostAsync<T>(string endpoint, object body)`
5. **内部实现**: 使用 BestHTTP 的 `HTTPRequest` + `GetHTTPResponseAsync()`
- POST body 序列化: `request.RawData = Encoding.UTF8.GetBytes(JsonUtility.ToJson(body))`
- Header 设置: `request.SetHeader("Content-Type", "application/json")`
- JWT 注入: `request.SetHeader("Authorization", $"Bearer {JwtToken}")`
6. **响应解析**: 从 `resp.DataAsText` 反序列化为 `GlobalResponse<T>`,提取 Data
7. **错误处理**:
- HTTP 状态码错误 → ApiResult 含错误信息
- 解析失败 → ApiResult 含异常信息
- 网络超时 → ApiResult.Timeout
- 服务器返回失败码 → 按 Code 分类处理
**Error Handling Design**:
```csharp
public class ApiResult<T> {
public bool IsSuccess;
public T Data;
public int Code; // ResponseCode
public string Message; // 服务端返回的 Message
public string ErrorDetail; // 客户端错误详情
public static ApiResult<T> Ok(T data) => ...;
public static ApiResult<T> Fail(int code, string msg, string detail = null) => ...;
}
```
**Must NOT do**:
- 不要阻塞 Unity 主线程(所有请求使用 async/await
- 不要硬编码 URLBaseUrl 必须可配置)
- 不要在 ApiClient 内处理业务逻辑(仅做 HTTP 通信)
**Recommended Agent Profile**:
- **Category**: `unspecified-high`
- Reason: 需要理解 BestHTTP API、异步模式、JSON 序列化,有较多细节
- **Skills**: None (BestHTTP API 文档已通过前期研究覆盖)
**Parallelization**:
- **Can Run In Parallel**: YES (with Task 6)
- **Parallel Group**: Wave 2 (with Task 6)
- **Blocks**: Task 7
- **Blocked By**: Tasks 1, 2, 3
**References**:
- `Packages/com.tivadar.best.http/Runtime/HTTP/HTTPRequestAsyncExtensions.cs` — BestHTTP async extension methods
- `Packages/com.tivadar.best.http/Runtime/HTTP/HTTPRequest.cs` — HTTPRequest class API
- `D:\Projects\IchniOnline\IchniOnline.Server\Models\Responses\GlobalResponse.cs` — 服务端响应格式
- `Assets/Scripts/Online/Network/Models/ApiResponse.cs` — 客户端响应模型(同 Wave 1 Task 2
**Acceptance Criteria**:
- [ ] `IchniOnlineApiClient` 可配置 `BaseUrl`
- [ ] `GetAsync<T>` 发送 GET 请求并正确解析 `GlobalResponse<T>`
- [ ] `PostAsync<T>` 发送 POST 请求body 序列化为 JSON
- [ ] JWT Token 自动注入到 Authorization Header
- [ ] 网络错误/服务端错误被正确封装为 `ApiResult<T>`
- [ ] Unity 编译通过
**QA Scenarios**:
```
Scenario: GET 请求 + 响应解析
Tool: Unity Editor + Bash (可启动测试后端或 mock)
Preconditions: IchniOnline 后端在 localhost:5433 运行(或 mock 端点)
Steps:
1. 在 Unity Start() 中调用 ApiClient.GetAsync<object>("/api/test/health")
2. 检查 Console 输出请求/响应日志
Expected Result: 请求发送成功,响应被正确解析
Evidence: .omo/evidence/task-5-get-request.png
Scenario: JWT 注入验证
Tool: Unity Editor
Preconditions: ApiClient.JwtToken 设置为 "test-token"
Steps:
1. 发起 PostAsync 请求
2. 检查请求 Header 包含 "Authorization: Bearer test-token"
Expected Result: Authorization Header 正确注入
Evidence: .omo/evidence/task-5-jwt-header.png
```
**Commit**: YES (group with Task 6)
- Message: `feat(online): implement API client and extend login cache`
---
- [x] 6. LoginCacheManager.cs — 扩展 JWT 存取方法
**What to do**:
- 编辑 `Assets/Scripts/Online/Logic/LoginCacheManager.cs`
- 添加新方法:
```csharp
// 保存完整认证会话JWT + 用户数据)
public static void SaveAuthSession(string jwtToken, LoginResponseDto response)
// 清除会话(保留 TapTap 原始数据,清除 JWT/服务端数据)
public static void ClearSession()
// 获取缓存的 JWT Token
public static string CachedJwtToken { get; }
// 检查是否有有效的服务端会话
public static bool HasValidSession { get; }
```
- ES3 key 保持不变: `Ichni_LoginCache`
- `HasCachedLogin` → 改为检查 `HasValidSession`
- `CachedData` → 保持返回完整数据
- `SaveFromTapTapAccount` → 保持原有行为(只存 TapTap 数据,不清除已有 JWT
**Must NOT do**:
- 不要修改 ES3 key 名称
- 不要改变已有方法的签名
**Recommended Agent Profile**:
- **Category**: `quick`
- Reason: 简单的方法扩展
- **Skills**: None
**Parallelization**:
- **Can Run In Parallel**: YES (with Task 5)
- **Parallel Group**: Wave 2 (with Task 5)
- **Blocks**: Task 7
- **Blocked By**: Tasks 1, 4
**References**:
- `Assets/Scripts/Online/Logic/LoginCacheManager.cs` — 当前文件
- `Assets/Scripts/Online/Models/LoginCacheData.cs` — 扩展后的数据模型
**Acceptance Criteria**:
- [ ] `SaveAuthSession(jwt, response)` 正确写入 ES3
- [ ] `CachedJwtToken` 从 ES3 读取正确
- [ ] `HasValidSession` 在 jwt 为空或无 session 时返回 false
- [ ] 编辑器中 `Ichni/Login Cache` 菜单仍正常工作
- [ ] Unity 编译通过
**QA Scenarios**:
```
Scenario: 保存/读取 JWT 会话
Tool: Unity Editor
Preconditions: 已有 LoginCacheData 模型扩展
Steps:
1. LoginCacheManager.SaveAuthSession("test-jwt", mockLoginResponse)
2. 读取 LoginCacheManager.CachedJwtToken
3. 读取 LoginCacheManager.HasValidSession
Expected Result: jwt="test-jwt", HasValidSession=true
Evidence: .omo/evidence/task-6-save-jwt.png
Scenario: 清除会话
Steps:
1. LoginCacheManager.ClearSession()
2. 检查 HasValidSession
Expected Result: HasValidSession=false, TapTap 原始数据保留
Evidence: .omo/evidence/task-6-clear.png
```
**Commit**: YES (group with Task 5)
---
- [x] 7. AuthService.cs — 认证编排服务
**What to do**:
- 新建文件: `Assets/Scripts/Online/Logic/AuthService.cs`
- 创建 `IchniOnlineAuthService` 类(非 MonoBehaviour 静态类 或 通过 ThirdPartyServiceManager 实例管理)
- 核心接口:
```csharp
public static class IchniOnlineAuthService {
// 事件
public static event Action<LoginResponseDto> OnLoginSuccess;
public static event Action<string> OnLoginFailed; // error message
public static event Action OnLoginCanceled;
// 属性
public static bool IsLoggingIn { get; }
public static bool IsLoggedIn { get; } // HasValidSession
// TapTap 第三方登录流程
// 1. 调用 ThirdPartyServiceManager.StartTapTapLogin()
// 2. 在 OnLoginSuccess 回调中:
// a. 从 TapTapAccount 取出 accessToken
// b. 构造 ThirdPartyLoginRequestDto
// c. 调用 ApiClient.PostAsync<LoginResponseDto>("/api/auth/third-party/login", body)
// d. 成功 → LoginCacheManager.SaveAuthSession(jwt, response)
// e. 失败 → 抛 OnLoginFailed
// f. ThirdParty.Unbound → 抛 OnLoginFailed("TapTap account not bound")
public static void LoginWithTapTap();
// 密码登录流程
// 1. GET /api/auth/session-key → 得到 sessionKey
// 2. 密码 XOR 加密:
// byte[] passwordBytes = Encoding.UTF8.GetBytes(password);
// byte[] sessionBytes = Encoding.UTF8.GetBytes(sessionKey);
// byte[] encrypted = new byte[passwordBytes.Length];
// for (int i = 0; i < passwordBytes.Length; i++)
// encrypted[i] = (byte)(passwordBytes[i] ^ sessionBytes[i % sessionBytes.Length]);
// string encryptedPassword = Convert.ToBase64String(encrypted);
// 3. POST /api/auth/login with { Username, EncryptedPassword, SessionKey }
// 4. 成功 → LoginCacheManager.SaveAuthSession(jwt, response)
public static async void LoginWithPassword(string username, string password);
// 注册流程
// POST /api/auth/register with { Username, Password, DisplayName }
// 注意: 注册后不自动返回 JWT需要用户手动登录
public static async void Register(string username, string password, string displayName);
// 登出
public static void Logout();
// 密码 XOR 加密工具方法(可复用)
public static string EncryptPassword(string password, string sessionKey);
}
```
**Must NOT do**:
- 不要直接调用 TapTap SDK 的 LoginWithScopes由 ThirdPartyServiceManager 封装)
- 不要在 AuthService 中管理 UI 状态(只触发事件,由 LoginPage 处理 UI
- 不要硬编码 API 路径(用字符串常量集中管理)
**Recommended Agent Profile**:
- **Category**: `unspecified-high`
- Reason: 涉及多步异步流程编排、事件管理、加密算法实现
- **Skills**: None
**Parallelization**:
- **Can Run In Parallel**: NO
- **Parallel Group**: Wave 3 (sequential)
- **Blocks**: Tasks 8, 9, 10
- **Blocked By**: Tasks 5, 6
**References**:
- `Assets/Scripts/Online/Logic/ThirdPartyServiceManager.cs` — TapTap SDK 调用封装
- `Assets/Scripts/Online/Network/ApiClient.cs` — HTTP 通信Task 5
- `Assets/Scripts/Online/Logic/LoginCacheManager.cs` — 缓存管理Task 6
- `D:\Projects\IchniOnline\IchniOnline.Server\Service\UserService.cs:213-225` — 服务端 XOR 解密算法(客户端需反转实现加密)
- `D:\Projects\IchniOnline\IchniOnline.Server\Controller\AuthController.cs` — 服务端 API 端点定义
**Acceptance Criteria**:
- [ ] `LoginWithTapTap()` 完整流程: TapTap SDK → API call → JWT → 事件
- [ ] `LoginWithPassword()` 完整流程: session key → XOR 加密 → API call → JWT → 事件
- [ ] `Register()` 调用 POST /api/auth/register
- [ ] `Logout()` 清除 JWT + TapTap 登出
- [ ] `EncryptPassword()` 与服务端 `DecryptPassword` 互为逆运算
- [ ] 所有事件正确触发OnLoginSuccess/OnLoginFailed/OnLoginCanceled
- [ ] Unity 编译通过
**QA Scenarios**:
```
Scenario: 密码 XOR 加密验证
Tool: C# 脚本验证
Preconditions: AuthService.EncryptPassword 已实现
Steps:
1. 设 password="test123", sessionKey="base64encodedkey=="
2. 调用 EncryptPassword 得到 encrypted
3. 用服务端 DecryptPassword 逻辑解密: 取 encrypted Base64 → XOR with sessionKey bytes → 得到明文
Expected Result: 解密后明文等于原始 password
Evidence: .omo/evidence/task-7-xor-verify.png
Scenario: TapTap 登录流程(模拟)
Tool: Unity Editor Play Mode
Preconditions: ThirdPartyServiceManager 已初始化
Steps:
1. AuthService.LoginWithTapTap()
2. Console 观察: TapTap SDK 登录 → API POST 请求 → JWT 缓存
Expected Result: 流程完整无异常
Evidence: .omo/evidence/task-7-taaptap-flow.png
```
**Commit**: YES
- Message: `feat(online): implement auth service with TapTap/password/register flows`
- Files: `Assets/Scripts/Online/Logic/AuthService.cs`
- Pre-commit: Unity 编译检查
---
- [x] 8. ThirdPartyServiceManager.cs — 集成 AuthService
**What to do**:
- 编辑 `Assets/Scripts/Online/Logic/ThirdPartyServiceManager.cs`
- 在 `StartTapTapLogin()` 的 `LoginWithScopes` 成功后,**不再直接调用** `LoginCacheManager.SaveFromTapTapAccount(account)`(该操作移至 AuthService 流程中)
- 添加事件 `OnTapTapAccessTokenReceived(AccessToken token)` 或修改流程,使 AuthService 能拿到 accessToken
- 可选方案 A: ThirdPartyServiceManager 保留纯 SDK 封装AuthService 通过 `TapTapLogin.Instance.GetCurrentTapAccount()` 获取 account
- 可选方案 B: ThirdPartyServiceManager 的 `OnLoginSuccess` 事件中附带 accessToken
- **推荐方案 B**: 扩展 `OnLoginSuccess` 事件参数或添加新事件
```csharp
// 新增事件: 携带 accessToken供 AuthService 使用)
public event Action<TapTapAccount, AccessToken> OnLoginWithToken;
```
- `StartTapTapLogin()` 保持不变(依然触发 OnLoginSuccess/OnLoginCanceled/OnLoginFailed
**Must NOT do**:
- 不要移除已有的事件OnLoginSuccess/OnLoginCanceled/OnLoginFailed
- 不要改变 TapTap SDK 初始化逻辑
**Recommended Agent Profile**:
- **Category**: `quick`
- Reason: 小幅接口扩展
- **Skills**: None
**Parallelization**:
- **Can Run In Parallel**: YES (with Tasks 9, 10)
- **Parallel Group**: Wave 4 (with Tasks 9, 10)
- **Blocks**: None
- **Blocked By**: Task 7
**References**:
- `Assets/Scripts/Online/Logic/ThirdPartyServiceManager.cs` — 当前文件
- `D:\Projects\Open\TapSDKLogin-Unity\Runtime\Public\TapTapAccount.cs` — TapTapAccount 结构(含 accessToken 属性)
**Acceptance Criteria**:
- [ ] `OnLoginWithToken` 事件在 TapTap 登录成功后触发
- [ ] 事件参数包含完整的 `TapTapAccount` 和 `AccessToken`
- [ ] Unity 编译通过
**QA Scenarios**:
```
Scenario: 事件触发测试
Tool: Unity Editor Play Mode
Preconditions: ThirdPartyServiceManager 已初始化
Steps:
1. 订阅 ThirdPartyServiceManager.Instance.OnLoginWithToken
2. 调用 StartTapTapLogin()
3. TapTap 登录成功后检查事件是否触发
Expected Result: 事件触发accessToken.kid/macKey/macAlgorithm 非空
Evidence: .omo/evidence/task-8-event.png
```
**Commit**: YES (group with Tasks 9, 10)
- Message: `feat(ui): integrate auth service into login UI`
---
- [x] 9. LoginPage.cs — 使用新 AuthService 驱动流程
**What to do**:
- 编辑 `Assets/Scripts/UI/LoginPage/LoginPage.cs`
- 修改 `OnTapTapClicked()`:
- 不再直接调用 `ThirdPartyServiceManager.Instance.StartTapTapLogin()`
- 改为调用 `IchniOnlineAuthService.LoginWithTapTap()`
- 修改 `OnTapTapLoginSuccess(TapTapAccount account)`:
- 不再立即 FadeOut改为等待 AuthService 的服务端验证完成)
- 改为 `IchniOnlineAuthService.OnLoginSuccess += OnApiLoginSuccess`
- 添加新回调 `OnApiLoginSuccess(LoginResponseDto response)`:
- 服务端验证成功后 FadeOut + 恢复 StartPage
- 添加 UI 加载状态:
- 显示加载指示器(或按钮禁用+文字变化)在等待服务端响应期间
- 事件订阅:
- 订阅 `AuthService.OnLoginSuccess` / `OnLoginFailed` / `OnLoginCanceled`
- 替代原有的 ThirdPartyServiceManager 事件(或共存)
- 账户密码登录 UI:
- 如果 LoginPage 上已有用户名/密码输入框 → 绑定到 `IchniOnlineAuthService.LoginWithPassword()`
- 如果没有 → 增加简单的用户名/密码输入框 + 登录按钮
**Must NOT do**:
- 不要删除 closeButton 和原有的 UI 结构
- 不要修改 UIPageBase 基类
**Recommended Agent Profile**:
- **Category**: `visual-engineering`
- Reason: 涉及 UI 逻辑更新和用户交互流程
- **Skills**: None
**Parallelization**:
- **Can Run In Parallel**: YES (with Tasks 8, 10)
- **Parallel Group**: Wave 4 (with Tasks 8, 10)
- **Blocks**: None
- **Blocked By**: Task 7
**References**:
- `Assets/Scripts/UI/LoginPage/LoginPage.cs` — 当前文件
- `Assets/Scripts/Online/Logic/AuthService.cs` — 认证服务Task 7
- `Assets/Scripts/UI/StartPage/StartUIPage.cs` — StartPage 交互恢复
- `Assets/Scripts/UI/StartPage/StartUIPage.cs:TouchToStart()` — 缓存检测入口
**Acceptance Criteria**:
- [ ] TapTap 按钮触发 `AuthService.LoginWithTapTap()` 而非直接调用 ThirdPartyServiceManager
- [ ] 登录请求加载状态有 UI 反馈
- [ ] 服务端返回成功后 FadeOut + 恢复 StartPage
- [ ] 失败时恢复按钮交互
- [ ] Unity 编译通过
**QA Scenarios**:
```
Scenario: TapTap 登录 UI 流程
Tool: Unity Editor Play Mode
Preconditions: LoginPage 在 MenuScene 中打开
Steps:
1. 点击 TapTap 按钮
2. 观察按钮变为非交互状态
3. TapTap SDK 登录窗口出现
4. 完成 TapTap 登录 → 等待服务端验证
5. 成功后 LoginPage 淡出 → 恢复 StartPage
Expected Result: UI 流程完整,无中断
Evidence: .omo/evidence/task-9-ui-flow.png
```
**Commit**: YES (group with Tasks 8, 10)
---
- [x] 10. StartUIPage.cs — 校验会话有效性
**What to do**:
- 编辑 `Assets/Scripts/UI/StartPage/StartUIPage.cs`
- `TouchToStart()` 中改用 `LoginCacheManager.HasValidSession` 替代 `LoginCacheManager.HasCachedLogin`
- 添加可选的 Token 过期检测:
- JWT 本身包含过期时间(可解析 payload 中的 exp 字段)
- 如果 JWT 已过期 → 清除 session → 显示 LoginPage
- 简单方案: 暂不做 JWT 解码解析,只检查 `HasValidSession`
- 如有 `RestoreInteraction()` 方法保持现有逻辑
**Must NOT do**:
- 不要修改不相关的 StartUIPage 逻辑
- 不要引入 JWT 解码库(暂不解码 JWT payload
**Recommended Agent Profile**:
- **Category**: `visual-engineering`
- **Skills**: None
**Parallelization**:
- **Can Run In Parallel**: YES (with Tasks 8, 9)
- **Parallel Group**: Wave 4 (with Tasks 8, 9)
- **Blocks**: None
- **Blocked By**: Task 7
**References**:
- `Assets/Scripts/UI/StartPage/StartUIPage.cs` — 当前文件
- `Assets/Scripts/Online/Logic/LoginCacheManager.cs` — 扩展后的缓存管理器Task 6
**Acceptance Criteria**:
- [ ] `TouchToStart()` 使用 `LoginCacheManager.HasValidSession` 判断
- [ ] 有有效 session → 直接进 ChapterSelection
- [ ] 无有效 session → 显示 LoginPage
- [ ] Unity 编译通过
**QA Scenarios**:
```
Scenario: 有 JWT session 时跳过登录
Tool: Unity Editor Play Mode
Preconditions: ES3 中存在有效 JWT 缓存
Steps:
1. 进入 MenuScene
2. 点击 TouchToStart
Expected Result: 直接进入 ChapterSelection不显示 LoginPage
Evidence: .omo/evidence/task-10-skip-login.png
Scenario: 无 JWT session 时显示登录
Preconditions: 清除 ES3 缓存
Steps:
1. 进入 MenuScene
2. 点击 TouchToStart
Expected Result: LoginPage 显示
Evidence: .omo/evidence/task-10-show-login.png
```
**Commit**: YES (group with Tasks 8, 9)
---
## Final Verification Wave
- [x] F1. **Plan Compliance Audit** — `oracle`
Read the plan end-to-end. For each "Must Have": verify implementation exists (read file, check compilation). For each "Must NOT Have": search codebase for forbidden patterns — reject with file:line if found. Check evidence files exist in .omo/evidence/. Compare deliverables against plan.
Output: `Must Have [N/N] | Must NOT Have [N/N] | Tasks [N/N] | VERDICT: APPROVE/REJECT`
- [x] F2. **Code Quality Review** — `unspecified-high`
Check compilation status. Review changed files: proper async patterns (no sync-over-async), no `try/catch` swallowing, proper error handling, no magic strings (use constants), correct `[Serializable]` attributes on DTOs.
Output: `Build [PASS/FAIL] | Code Quality [N clean/N issues] | VERDICT`
- [x] F3. **Real Manual QA** — `unspecified-high`
Start from clean scene state. Execute QA scenarios from EVERY task — follow exact steps, capture evidence. Test integration flow: TapTap login → API call → JWT cache → restart → skip login. Test edge cases: network error, invalid credentials, ThirdParty.Unbound.
Output: `Scenarios [N/N pass] | Integration [N/N] | Edge Cases [N tested] | VERDICT`
- [x] F4. **Scope Fidelity Check** — `deep`
For each task: read "What to do", read actual diff (git log/diff). Verify 1:1 — everything in spec was built (no missing), nothing beyond spec was built (no creep). Check "Must NOT do" compliance. Detect cross-task contamination.
Output: `Tasks [N/N compliant] | Contamination [CLEAN/N issues] | Unaccounted [CLEAN/N files] | VERDICT`
---
## Commit Strategy
- **C1** (Task 1-4): `feat(online): add network models and asmdef references`
- **C2** (Task 5-6): `feat(online): implement API client and extend login cache`
- **C3** (Task 7): `feat(online): implement auth service with TapTap/password/register flows`
- **C4** (Task 8-10): `feat(ui): integrate auth service into login UI`
---
## Success Criteria
### Verification Commands
```bash
# In Unity Editor:
# 1. Open MenuScene
# 2. Enter Play Mode
# 3. Click TapTap Login button → should call POST /api/auth/third-party/login
# 4. Check Console for network request/response logs
# 5. Exit Play Mode → check ES3 cache contains JWT data
```
### Final Checklist
- [ ] No compilation errors in IchniOnline.asmdef or dependent assemblies
- [ ] TapTap login → server API call → JWT → ES3 cache
- [ ] Password login (if server running) → JWT → ES3 cache
- [ ] Register (if server running) → user created
- [ ] LoginPage responds to all auth states (loading/success/failure)

View File

@@ -0,0 +1,10 @@
{
"sessionID": "ses_12b41534affeQDNAisTmCG0XrZ",
"updatedAt": "2026-06-17T08:45:47.266Z",
"sources": {
"background-task": {
"state": "idle",
"updatedAt": "2026-06-17T08:45:47.266Z"
}
}
}

View File

@@ -0,0 +1,10 @@
{
"sessionID": "ses_12b516164ffekTIOv7bJsDWp4s",
"updatedAt": "2026-06-17T08:25:45.254Z",
"sources": {
"background-task": {
"state": "idle",
"updatedAt": "2026-06-17T08:25:45.254Z"
}
}
}

View File

@@ -0,0 +1,10 @@
{
"sessionID": "ses_135a9996effen0Yyr1bZVA5svu",
"updatedAt": "2026-06-15T08:17:13.685Z",
"sources": {
"background-task": {
"state": "idle",
"updatedAt": "2026-06-15T08:17:13.685Z"
}
}
}

View File

@@ -213,7 +213,7 @@ Material:
- _Dst: 10
- _DstBlend: 0
- _DstBlendAlpha: 0
- _EdgeValue: 0.34460104
- _EdgeValue: 0.9979626
- _EnvironmentReflections: 1
- _FNLfanxiangkaiguan: 0
- _Face: 1
@@ -258,7 +258,7 @@ Material:
- _Mask_scale: 1
- _Metallic: 0
- _OcclusionStrength: 1
- _Opacity: 0.65539896
- _Opacity: 0.002037406
- _Parallax: 0.005
- _Pass: 0
- _QueueOffset: 0

View File

@@ -1,7 +0,0 @@
fileFormatVersion: 2
guid: 641c955d37d2fac4f87e00ac5c9d9bd8
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -1,7 +0,0 @@
fileFormatVersion: 2
guid: 2690f45490c175045bbdc63395bf6278
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -1,7 +0,0 @@
fileFormatVersion: 2
guid: fbd1fd9b3a70fad429d1eaaa5799c2a5
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -1,7 +0,0 @@
fileFormatVersion: 2
guid: 3579d9cf4b75c564faa8fffc58a9f3f6
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -1,7 +0,0 @@
fileFormatVersion: 2
guid: 0023a0858ba124646a55dfcb7231ed46
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -1,7 +0,0 @@
fileFormatVersion: 2
guid: d1c0b77896049554fa4b635531caf741
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -1,7 +0,0 @@
fileFormatVersion: 2
guid: c0a0a980c9ba86345bc15411db88d34f
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -1,7 +0,0 @@
fileFormatVersion: 2
guid: 2edbf4a9b9544774bbef617e92429664
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -1,7 +0,0 @@
fileFormatVersion: 2
guid: 13ab599a7bda4e54fba3e92a13c9580a
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -1,7 +0,0 @@
fileFormatVersion: 2
guid: f6f268949ccf3f34fa4d18e92501ed82
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -1,7 +0,0 @@
fileFormatVersion: 2
guid: 69bc3229216b1504ea3e28b5820bbb0d
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -1,7 +0,0 @@
fileFormatVersion: 2
guid: 4f665a06c5a2aa5499fa1c79ac058999
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -1,7 +0,0 @@
fileFormatVersion: 2
guid: 8a87ed432fe2d97498c0de5fae312e35
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -1,7 +0,0 @@
fileFormatVersion: 2
guid: 7c3bfbbeb9427b94099254e2e2768ad4
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -1,7 +0,0 @@
fileFormatVersion: 2
guid: c5303861611f41c438a30be552da5de4
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -1,7 +0,0 @@
fileFormatVersion: 2
guid: a9d68dd8913f05d4d9ce75e7b40c6044
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -1,7 +0,0 @@
fileFormatVersion: 2
guid: 2243c8b4e1ab6914995699133f67ab5a
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -1,7 +0,0 @@
fileFormatVersion: 2
guid: 9a5e61a8b3421b944863d0946e32da0a
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -1,7 +0,0 @@
fileFormatVersion: 2
guid: 89f0b84148d149d4d96b838d7ef60e92
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -1,7 +0,0 @@
fileFormatVersion: 2
guid: 19939ee2cdb76e0489b1b8cd4bed7f3d
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -1,7 +0,0 @@
fileFormatVersion: 2
guid: 70777e8ce9f3c8d4a8182ca2f965cdb2
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -1,7 +0,0 @@
fileFormatVersion: 2
guid: ba281a1a00c8ac54c914e0763299f637
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -1,7 +0,0 @@
fileFormatVersion: 2
guid: e6fc8948257acee42b666d0bfe1d782c
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -1,7 +0,0 @@
fileFormatVersion: 2
guid: 4b5cb8698f2d9c14fadf8e2383441d37
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -1,7 +0,0 @@
fileFormatVersion: 2
guid: b460b52e6c1feae45b70b7ddc2c45bd6
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -1,7 +0,0 @@
fileFormatVersion: 2
guid: 57fcea0ed8b5eb347923c4c21fa31b57
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -1,7 +0,0 @@
fileFormatVersion: 2
guid: 2e9da72e7e3196146bf7d27450013734
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -1,7 +0,0 @@
fileFormatVersion: 2
guid: 0904cdf24ddcd5042b024326476220d5
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -1,7 +0,0 @@
fileFormatVersion: 2
guid: 929783250050f8a448821b6ca1f2c578
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -1,7 +0,0 @@
fileFormatVersion: 2
guid: fcc4d2eb0af82e546ae75506872cf092
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -1,7 +0,0 @@
fileFormatVersion: 2
guid: cd0a0171c5157b748afe763b89f71211
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -1,7 +0,0 @@
fileFormatVersion: 2
guid: f4990f6ace6142c4bbbf41cdd80b0bd3
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

Binary file not shown.

View File

@@ -1,7 +0,0 @@
fileFormatVersion: 2
guid: 1782b72cd0e99a54fac09382c482e3db
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -1,7 +0,0 @@
fileFormatVersion: 2
guid: 49d5bcbbd4cbd754b98cf3200197b0f1
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -1,7 +0,0 @@
fileFormatVersion: 2
guid: eefe45a405f061045be947217e30ed10
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -1,7 +0,0 @@
fileFormatVersion: 2
guid: 2e995dfe11e22d34d92432383d15c067
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -1,7 +0,0 @@
fileFormatVersion: 2
guid: ec984c51d4ae2504184eeb292734c672
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -1,7 +0,0 @@
fileFormatVersion: 2
guid: e447b3d7d913d694ca35f74e30581840
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -1,7 +0,0 @@
fileFormatVersion: 2
guid: 6584a66582083a1459dcf5e4e87f6d62
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -1,7 +0,0 @@
fileFormatVersion: 2
guid: 0190b8bde50f12943926613d9a63c89a
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -1,7 +0,0 @@
fileFormatVersion: 2
guid: ae2ce8ad295486349839288636aed1ed
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -1,7 +0,0 @@
fileFormatVersion: 2
guid: d69745226619e3241a8e04ce86aee6a6
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -1,7 +0,0 @@
fileFormatVersion: 2
guid: 37e6a9374416bc946a55779c58d0d984
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

Binary file not shown.

View File

@@ -1,7 +0,0 @@
fileFormatVersion: 2
guid: e45363610a59a4543a9793b3bf2be4aa
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,27 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<application>
<!--Used when Application Entry is set to Activity, otherwise remove this activity block-->
<activity android:name="com.unity3d.player.UnityPlayerActivity"
android:theme="@style/UnityThemeSelector">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<meta-data android:name="unityplayer.UnityActivity" android:value="true" />
</activity>
<!--Used when Application Entry is set to GameActivity, otherwise remove this activity block-->
<activity android:name="com.unity3d.player.UnityPlayerGameActivity"
android:theme="@style/BaseUnityGameActivityTheme">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
<data android:scheme="ichni" android:host="mylink" />
</intent-filter>
<meta-data android:name="unityplayer.UnityActivity" android:value="true" />
<meta-data android:name="android.app.lib_name" android:value="game" />
</activity>
</application>
</manifest>

View File

@@ -1,5 +1,5 @@
fileFormatVersion: 2
guid: 776287b984cb9f94c88478f1af0bcbad
guid: 0409f4966cdb7064eb86f782bd83211b
TextScriptImporter:
externalObjects: {}
userData:

View File

@@ -13,7 +13,7 @@ dependencies {
// Android Resolver Exclusions Start
android {
packaging {
packagingOptions {
exclude ('/lib/armeabi/*' + '*')
exclude ('/lib/mips/*' + '*')
exclude ('/lib/mips64/*' + '*')

View File

@@ -1,62 +0,0 @@
apply plugin: 'com.android.library'
apply from: '../shared/keepUnitySymbols.gradle'
apply from: '../shared/common.gradle'
**APPLY_PLUGINS**
dependencies {
implementation fileTree(dir: 'libs', include: ['*.jar'])
// Android Resolver Dependencies Start
implementation 'com.taptap.sdk:tap-core-unity:4.5.4' // Packages/com.taptap.sdk.core/Mobile/Editor/NativeDependencies.xml:7
implementation 'com.taptap.sdk:tap-login-unity:4.5.4' // Packages/com.taptap.sdk.login/Mobile/Editor/NativeDependencies.xml:7
// Android Resolver Dependencies End
**DEPS**}
// Android Resolver Exclusions Start
android {
packagingOptions {
exclude ('/lib/armeabi/*' + '*')
exclude ('/lib/mips/*' + '*')
exclude ('/lib/mips64/*' + '*')
exclude ('/lib/x86/*' + '*')
exclude ('/lib/x86_64/*' + '*')
}
}
// Android Resolver Exclusions End
android {
namespace "com.unity3d.player"
ndkPath "**NDKPATH**"
ndkVersion "**NDKVERSION**"
compileSdk **APIVERSION**
buildToolsVersion = "**BUILDTOOLS**"
compileOptions {
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}
defaultConfig {
minSdk **MINSDK**
targetSdk **TARGETSDK**
ndk {
abiFilters **ABIFILTERS**
debugSymbolLevel **DEBUGSYMBOLLEVEL**
}
versionCode **VERSIONCODE**
versionName '**VERSIONNAME**'
consumerProguardFiles 'proguard-unity.txt'**USER_PROGUARD**
**DEFAULT_CONFIG_SETUP**
}
lint {
abortOnError false
}
androidResources {
noCompress = **BUILTIN_NOCOMPRESS** + unityStreamingAssets.tokenize(', ')
ignoreAssetsPattern = "!.svn:!.git:!.ds_store:!*.scc:!CVS:!thumbs.db:!picasa.ini:!*~"
}**PACKAGING**
}
**IL_CPP_BUILD_SETUP**
**SOURCE_BUILD_SETUP**
**EXTERNAL_SOURCES**

View File

@@ -1,7 +0,0 @@
fileFormatVersion: 2
guid: 70baebc98017f2e4cb5897fa82962e94
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -1,7 +0,0 @@
fileFormatVersion: 2
guid: de41a70ead12af544a4e99a925b269d2
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -1,13 +1,13 @@
{
"packages": {
"com.IvanMurzak.McpPlugin": {
"version": "6.10.0",
"version": "6.7.1",
"dlls": [
"McpPlugin.dll"
]
},
"com.IvanMurzak.McpPlugin.Common": {
"version": "6.10.0",
"version": "6.7.1",
"dlls": [
"McpPlugin.Common.dll"
]

Binary file not shown.

View File

@@ -1,7 +0,0 @@
fileFormatVersion: 2
guid: f597f19f656ba56eae4f6a3a7cc528f4
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -1,7 +0,0 @@
fileFormatVersion: 2
guid: 48e08dc33330d11e9d4a1b246c52e4f6
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -1,7 +0,0 @@
fileFormatVersion: 2
guid: ed09910c0094cb27be8f3ca264680da3
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -1,7 +0,0 @@
fileFormatVersion: 2
guid: cc355dd4cf1e6173beaeb22c2858cbe1
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -1,5 +1,5 @@
fileFormatVersion: 2
guid: 2687865bbf8495e42baa80cbd81b5e1c
guid: 3439c936224b75c4fb5b2fe75cb78ef9
folderAsset: yes
DefaultImporter:
externalObjects: {}

View File

@@ -1,5 +1,5 @@
fileFormatVersion: 2
guid: a45b5316a1162ac469d7c48144dd4bea
guid: 98885f0f2a3659142b5d1fedd2fd87c7
TextScriptImporter:
externalObjects: {}
userData:

View File

@@ -1,5 +1,5 @@
fileFormatVersion: 2
guid: 6ff91e16b8764f544a0b74e155bc2bab
guid: fad8c06945f340a4c856e1cb27cac91c
folderAsset: yes
DefaultImporter:
externalObjects: {}

View File

@@ -1,5 +1,5 @@
fileFormatVersion: 2
guid: 1501ffe885c2449438946c44a354b79d
guid: c6f3c07df5efce048874f6abb93a3a96
folderAsset: yes
DefaultImporter:
externalObjects: {}

View File

@@ -0,0 +1,62 @@
#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member
using System;
using System.Linq;
using System.Reflection;
using UnityEditor;
using UnityEngine;
namespace Cysharp.Threading.Tasks.Editor
{
// reflection call of UnityEditor.SplitterGUILayout
internal static class SplitterGUILayout
{
static BindingFlags flags = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Static;
static Lazy<Type> splitterStateType = new Lazy<Type>(() =>
{
var type = typeof(EditorWindow).Assembly.GetTypes().First(x => x.FullName == "UnityEditor.SplitterState");
return type;
});
static Lazy<ConstructorInfo> splitterStateCtor = new Lazy<ConstructorInfo>(() =>
{
var type = splitterStateType.Value;
return type.GetConstructor(flags, null, new Type[] { typeof(float[]), typeof(int[]), typeof(int[]) }, null);
});
static Lazy<Type> splitterGUILayoutType = new Lazy<Type>(() =>
{
var type = typeof(EditorWindow).Assembly.GetTypes().First(x => x.FullName == "UnityEditor.SplitterGUILayout");
return type;
});
static Lazy<MethodInfo> beginVerticalSplit = new Lazy<MethodInfo>(() =>
{
var type = splitterGUILayoutType.Value;
return type.GetMethod("BeginVerticalSplit", flags, null, new Type[] { splitterStateType.Value, typeof(GUILayoutOption[]) }, null);
});
static Lazy<MethodInfo> endVerticalSplit = new Lazy<MethodInfo>(() =>
{
var type = splitterGUILayoutType.Value;
return type.GetMethod("EndVerticalSplit", flags, null, Type.EmptyTypes, null);
});
public static object CreateSplitterState(float[] relativeSizes, int[] minSizes, int[] maxSizes)
{
return splitterStateCtor.Value.Invoke(new object[] { relativeSizes, minSizes, maxSizes });
}
public static void BeginVerticalSplit(object splitterState, params GUILayoutOption[] options)
{
beginVerticalSplit.Value.Invoke(null, new object[] { splitterState, options });
}
public static void EndVerticalSplit()
{
endVerticalSplit.Value.Invoke(null, Type.EmptyTypes);
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 40ef2e46f900131419e869398a8d3c9d
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,17 @@
{
"name": "UniTask.Editor",
"references": [
"UniTask"
],
"includePlatforms": [
"Editor"
],
"excludePlatforms": [],
"allowUnsafeCode": false,
"overrideReferences": false,
"precompiledReferences": [],
"autoReferenced": false,
"defineConstraints": [],
"versionDefines": [],
"noEngineReferences": false
}

View File

@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 4129704b5a1a13841ba16f230bf24a57
AssemblyDefinitionImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,186 @@
#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member
using UnityEngine;
using UnityEditor;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using System;
using UnityEditor.IMGUI.Controls;
using Cysharp.Threading.Tasks.Internal;
using System.Text;
using System.Text.RegularExpressions;
#if UNITY_6000_2_OR_NEWER
using TreeView = UnityEditor.IMGUI.Controls.TreeView<int>;
using TreeViewItem = UnityEditor.IMGUI.Controls.TreeViewItem<int>;
using TreeViewState = UnityEditor.IMGUI.Controls.TreeViewState<int>;
#endif
namespace Cysharp.Threading.Tasks.Editor
{
public class UniTaskTrackerViewItem : TreeViewItem
{
static Regex removeHref = new Regex("<a href.+>(.+)</a>", RegexOptions.Compiled);
public string TaskType { get; set; }
public string Elapsed { get; set; }
public string Status { get; set; }
string position;
public string Position
{
get { return position; }
set
{
position = value;
PositionFirstLine = GetFirstLine(position);
}
}
public string PositionFirstLine { get; private set; }
static string GetFirstLine(string str)
{
var sb = new StringBuilder();
for (int i = 0; i < str.Length; i++)
{
if (str[i] == '\r' || str[i] == '\n')
{
break;
}
sb.Append(str[i]);
}
return removeHref.Replace(sb.ToString(), "$1");
}
public UniTaskTrackerViewItem(int id) : base(id)
{
}
}
public class UniTaskTrackerTreeView : TreeView
{
const string sortedColumnIndexStateKey = "UniTaskTrackerTreeView_sortedColumnIndex";
public IReadOnlyList<TreeViewItem> CurrentBindingItems;
public UniTaskTrackerTreeView()
: this(new TreeViewState(), new MultiColumnHeader(new MultiColumnHeaderState(new[]
{
new MultiColumnHeaderState.Column() { headerContent = new GUIContent("TaskType"), width = 20},
new MultiColumnHeaderState.Column() { headerContent = new GUIContent("Elapsed"), width = 10},
new MultiColumnHeaderState.Column() { headerContent = new GUIContent("Status"), width = 10},
new MultiColumnHeaderState.Column() { headerContent = new GUIContent("Position")},
})))
{
}
UniTaskTrackerTreeView(TreeViewState state, MultiColumnHeader header)
: base(state, header)
{
rowHeight = 20;
showAlternatingRowBackgrounds = true;
showBorder = true;
header.sortingChanged += Header_sortingChanged;
header.ResizeToFit();
Reload();
header.sortedColumnIndex = SessionState.GetInt(sortedColumnIndexStateKey, 1);
}
public void ReloadAndSort()
{
var currentSelected = this.state.selectedIDs;
Reload();
Header_sortingChanged(this.multiColumnHeader);
this.state.selectedIDs = currentSelected;
}
private void Header_sortingChanged(MultiColumnHeader multiColumnHeader)
{
SessionState.SetInt(sortedColumnIndexStateKey, multiColumnHeader.sortedColumnIndex);
var index = multiColumnHeader.sortedColumnIndex;
var ascending = multiColumnHeader.IsSortedAscending(multiColumnHeader.sortedColumnIndex);
var items = rootItem.children.Cast<UniTaskTrackerViewItem>();
IOrderedEnumerable<UniTaskTrackerViewItem> orderedEnumerable;
switch (index)
{
case 0:
orderedEnumerable = ascending ? items.OrderBy(item => item.TaskType) : items.OrderByDescending(item => item.TaskType);
break;
case 1:
orderedEnumerable = ascending ? items.OrderBy(item => double.Parse(item.Elapsed)) : items.OrderByDescending(item => double.Parse(item.Elapsed));
break;
case 2:
orderedEnumerable = ascending ? items.OrderBy(item => item.Status) : items.OrderByDescending(item => item.Elapsed);
break;
case 3:
orderedEnumerable = ascending ? items.OrderBy(item => item.Position) : items.OrderByDescending(item => item.PositionFirstLine);
break;
default:
throw new ArgumentOutOfRangeException(nameof(index), index, null);
}
CurrentBindingItems = rootItem.children = orderedEnumerable.Cast<TreeViewItem>().ToList();
BuildRows(rootItem);
}
protected override TreeViewItem BuildRoot()
{
var root = new TreeViewItem { depth = -1 };
var children = new List<TreeViewItem>();
TaskTracker.ForEachActiveTask((trackingId, awaiterType, status, created, stackTrace) =>
{
children.Add(new UniTaskTrackerViewItem(trackingId) { TaskType = awaiterType, Status = status.ToString(), Elapsed = (DateTime.UtcNow - created).TotalSeconds.ToString("00.00"), Position = stackTrace });
});
CurrentBindingItems = children;
root.children = CurrentBindingItems as List<TreeViewItem>;
return root;
}
protected override bool CanMultiSelect(TreeViewItem item)
{
return false;
}
protected override void RowGUI(RowGUIArgs args)
{
var item = args.item as UniTaskTrackerViewItem;
for (var visibleColumnIndex = 0; visibleColumnIndex < args.GetNumVisibleColumns(); visibleColumnIndex++)
{
var rect = args.GetCellRect(visibleColumnIndex);
var columnIndex = args.GetColumn(visibleColumnIndex);
var labelStyle = args.selected ? EditorStyles.whiteLabel : EditorStyles.label;
labelStyle.alignment = TextAnchor.MiddleLeft;
switch (columnIndex)
{
case 0:
EditorGUI.LabelField(rect, item.TaskType, labelStyle);
break;
case 1:
EditorGUI.LabelField(rect, item.Elapsed, labelStyle);
break;
case 2:
EditorGUI.LabelField(rect, item.Status, labelStyle);
break;
case 3:
EditorGUI.LabelField(rect, item.PositionFirstLine, labelStyle);
break;
default:
throw new ArgumentOutOfRangeException(nameof(columnIndex), columnIndex, null);
}
}
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 52e2d973a2156674e8c1c9433ed031f7
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,209 @@
#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member
using UnityEngine;
using UnityEditor;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using System;
using UnityEditor.IMGUI.Controls;
using Cysharp.Threading.Tasks.Internal;
namespace Cysharp.Threading.Tasks.Editor
{
public class UniTaskTrackerWindow : EditorWindow
{
static int interval;
static UniTaskTrackerWindow window;
[MenuItem("Window/UniTask Tracker")]
public static void OpenWindow()
{
if (window != null)
{
window.Close();
}
// will called OnEnable(singleton instance will be set).
GetWindow<UniTaskTrackerWindow>("UniTask Tracker").Show();
}
static readonly GUILayoutOption[] EmptyLayoutOption = new GUILayoutOption[0];
UniTaskTrackerTreeView treeView;
object splitterState;
void OnEnable()
{
window = this; // set singleton.
splitterState = SplitterGUILayout.CreateSplitterState(new float[] { 75f, 25f }, new int[] { 32, 32 }, null);
treeView = new UniTaskTrackerTreeView();
TaskTracker.EditorEnableState.EnableAutoReload = EditorPrefs.GetBool(TaskTracker.EnableAutoReloadKey, false);
TaskTracker.EditorEnableState.EnableTracking = EditorPrefs.GetBool(TaskTracker.EnableTrackingKey, false);
TaskTracker.EditorEnableState.EnableStackTrace = EditorPrefs.GetBool(TaskTracker.EnableStackTraceKey, false);
}
void OnGUI()
{
// Head
RenderHeadPanel();
// Splittable
SplitterGUILayout.BeginVerticalSplit(this.splitterState, EmptyLayoutOption);
{
// Column Tabble
RenderTable();
// StackTrace details
RenderDetailsPanel();
}
SplitterGUILayout.EndVerticalSplit();
}
#region HeadPanel
public static bool EnableAutoReload => TaskTracker.EditorEnableState.EnableAutoReload;
public static bool EnableTracking => TaskTracker.EditorEnableState.EnableTracking;
public static bool EnableStackTrace => TaskTracker.EditorEnableState.EnableStackTrace;
static readonly GUIContent EnableAutoReloadHeadContent = EditorGUIUtility.TrTextContent("Enable AutoReload", "Reload automatically.", (Texture)null);
static readonly GUIContent ReloadHeadContent = EditorGUIUtility.TrTextContent("Reload", "Reload View.", (Texture)null);
static readonly GUIContent GCHeadContent = EditorGUIUtility.TrTextContent("GC.Collect", "Invoke GC.Collect.", (Texture)null);
static readonly GUIContent EnableTrackingHeadContent = EditorGUIUtility.TrTextContent("Enable Tracking", "Start to track async/await UniTask. Performance impact: low", (Texture)null);
static readonly GUIContent EnableStackTraceHeadContent = EditorGUIUtility.TrTextContent("Enable StackTrace", "Capture StackTrace when task is started. Performance impact: high", (Texture)null);
// [Enable Tracking] | [Enable StackTrace]
void RenderHeadPanel()
{
EditorGUILayout.BeginVertical(EmptyLayoutOption);
EditorGUILayout.BeginHorizontal(EditorStyles.toolbar, EmptyLayoutOption);
if (GUILayout.Toggle(EnableAutoReload, EnableAutoReloadHeadContent, EditorStyles.toolbarButton, EmptyLayoutOption) != EnableAutoReload)
{
TaskTracker.EditorEnableState.EnableAutoReload = !EnableAutoReload;
}
if (GUILayout.Toggle(EnableTracking, EnableTrackingHeadContent, EditorStyles.toolbarButton, EmptyLayoutOption) != EnableTracking)
{
TaskTracker.EditorEnableState.EnableTracking = !EnableTracking;
}
if (GUILayout.Toggle(EnableStackTrace, EnableStackTraceHeadContent, EditorStyles.toolbarButton, EmptyLayoutOption) != EnableStackTrace)
{
TaskTracker.EditorEnableState.EnableStackTrace = !EnableStackTrace;
}
GUILayout.FlexibleSpace();
if (GUILayout.Button(ReloadHeadContent, EditorStyles.toolbarButton, EmptyLayoutOption))
{
TaskTracker.CheckAndResetDirty();
treeView.ReloadAndSort();
Repaint();
}
if (GUILayout.Button(GCHeadContent, EditorStyles.toolbarButton, EmptyLayoutOption))
{
GC.Collect(0);
}
EditorGUILayout.EndHorizontal();
EditorGUILayout.EndVertical();
}
#endregion
#region TableColumn
Vector2 tableScroll;
GUIStyle tableListStyle;
void RenderTable()
{
if (tableListStyle == null)
{
tableListStyle = new GUIStyle("CN Box");
tableListStyle.margin.top = 0;
tableListStyle.padding.left = 3;
}
EditorGUILayout.BeginVertical(tableListStyle, EmptyLayoutOption);
this.tableScroll = EditorGUILayout.BeginScrollView(this.tableScroll, new GUILayoutOption[]
{
GUILayout.ExpandWidth(true),
GUILayout.MaxWidth(2000f)
});
var controlRect = EditorGUILayout.GetControlRect(new GUILayoutOption[]
{
GUILayout.ExpandHeight(true),
GUILayout.ExpandWidth(true)
});
treeView?.OnGUI(controlRect);
EditorGUILayout.EndScrollView();
EditorGUILayout.EndVertical();
}
private void Update()
{
if (EnableAutoReload)
{
if (interval++ % 120 == 0)
{
if (TaskTracker.CheckAndResetDirty())
{
treeView.ReloadAndSort();
Repaint();
}
}
}
}
#endregion
#region Details
static GUIStyle detailsStyle;
Vector2 detailsScroll;
void RenderDetailsPanel()
{
if (detailsStyle == null)
{
detailsStyle = new GUIStyle("CN Message");
detailsStyle.wordWrap = false;
detailsStyle.stretchHeight = true;
detailsStyle.margin.right = 15;
}
string message = "";
var selected = treeView.state.selectedIDs;
if (selected.Count > 0)
{
var first = selected[0];
var item = treeView.CurrentBindingItems.FirstOrDefault(x => x.id == first) as UniTaskTrackerViewItem;
if (item != null)
{
message = item.Position;
}
}
detailsScroll = EditorGUILayout.BeginScrollView(this.detailsScroll, EmptyLayoutOption);
var vector = detailsStyle.CalcSize(new GUIContent(message));
EditorGUILayout.SelectableLabel(message, detailsStyle, new GUILayoutOption[]
{
GUILayout.ExpandHeight(true),
GUILayout.ExpandWidth(true),
GUILayout.MinWidth(vector.x),
GUILayout.MinHeight(vector.y)
});
EditorGUILayout.EndScrollView();
}
#endregion
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 5bee3e3860e37484aa3b861bf76d129f
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -1,5 +1,5 @@
fileFormatVersion: 2
guid: 28fe15fabe5753d478e6c794bc5272dd
guid: 8c4f9b8a894ef584587a8cec0ee08362
folderAsset: yes
DefaultImporter:
externalObjects: {}

View File

@@ -0,0 +1,245 @@
#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member
using System;
using System.Threading;
namespace Cysharp.Threading.Tasks
{
public class AsyncLazy
{
static Action<object> continuation = SetCompletionSource;
Func<UniTask> taskFactory;
UniTaskCompletionSource completionSource;
UniTask.Awaiter awaiter;
object syncLock;
bool initialized;
public AsyncLazy(Func<UniTask> taskFactory)
{
this.taskFactory = taskFactory;
this.completionSource = new UniTaskCompletionSource();
this.syncLock = new object();
this.initialized = false;
}
internal AsyncLazy(UniTask task)
{
this.taskFactory = null;
this.completionSource = new UniTaskCompletionSource();
this.syncLock = null;
this.initialized = true;
var awaiter = task.GetAwaiter();
if (awaiter.IsCompleted)
{
SetCompletionSource(awaiter);
}
else
{
this.awaiter = awaiter;
awaiter.SourceOnCompleted(continuation, this);
}
}
public UniTask Task
{
get
{
EnsureInitialized();
return completionSource.Task;
}
}
public UniTask.Awaiter GetAwaiter() => Task.GetAwaiter();
void EnsureInitialized()
{
if (Volatile.Read(ref initialized))
{
return;
}
EnsureInitializedCore();
}
void EnsureInitializedCore()
{
lock (syncLock)
{
if (!Volatile.Read(ref initialized))
{
var f = Interlocked.Exchange(ref taskFactory, null);
if (f != null)
{
var task = f();
var awaiter = task.GetAwaiter();
if (awaiter.IsCompleted)
{
SetCompletionSource(awaiter);
}
else
{
this.awaiter = awaiter;
awaiter.SourceOnCompleted(continuation, this);
}
Volatile.Write(ref initialized, true);
}
}
}
}
void SetCompletionSource(in UniTask.Awaiter awaiter)
{
try
{
awaiter.GetResult();
completionSource.TrySetResult();
}
catch (Exception ex)
{
completionSource.TrySetException(ex);
}
}
static void SetCompletionSource(object state)
{
var self = (AsyncLazy)state;
try
{
self.awaiter.GetResult();
self.completionSource.TrySetResult();
}
catch (Exception ex)
{
self.completionSource.TrySetException(ex);
}
finally
{
self.awaiter = default;
}
}
}
public class AsyncLazy<T>
{
static Action<object> continuation = SetCompletionSource;
Func<UniTask<T>> taskFactory;
UniTaskCompletionSource<T> completionSource;
UniTask<T>.Awaiter awaiter;
object syncLock;
bool initialized;
public AsyncLazy(Func<UniTask<T>> taskFactory)
{
this.taskFactory = taskFactory;
this.completionSource = new UniTaskCompletionSource<T>();
this.syncLock = new object();
this.initialized = false;
}
internal AsyncLazy(UniTask<T> task)
{
this.taskFactory = null;
this.completionSource = new UniTaskCompletionSource<T>();
this.syncLock = null;
this.initialized = true;
var awaiter = task.GetAwaiter();
if (awaiter.IsCompleted)
{
SetCompletionSource(awaiter);
}
else
{
this.awaiter = awaiter;
awaiter.SourceOnCompleted(continuation, this);
}
}
public UniTask<T> Task
{
get
{
EnsureInitialized();
return completionSource.Task;
}
}
public UniTask<T>.Awaiter GetAwaiter() => Task.GetAwaiter();
void EnsureInitialized()
{
if (Volatile.Read(ref initialized))
{
return;
}
EnsureInitializedCore();
}
void EnsureInitializedCore()
{
lock (syncLock)
{
if (!Volatile.Read(ref initialized))
{
var f = Interlocked.Exchange(ref taskFactory, null);
if (f != null)
{
var task = f();
var awaiter = task.GetAwaiter();
if (awaiter.IsCompleted)
{
SetCompletionSource(awaiter);
}
else
{
this.awaiter = awaiter;
awaiter.SourceOnCompleted(continuation, this);
}
Volatile.Write(ref initialized, true);
}
}
}
}
void SetCompletionSource(in UniTask<T>.Awaiter awaiter)
{
try
{
var result = awaiter.GetResult();
completionSource.TrySetResult(result);
}
catch (Exception ex)
{
completionSource.TrySetException(ex);
}
}
static void SetCompletionSource(object state)
{
var self = (AsyncLazy<T>)state;
try
{
var result = self.awaiter.GetResult();
self.completionSource.TrySetResult(result);
}
catch (Exception ex)
{
self.completionSource.TrySetException(ex);
}
finally
{
self.awaiter = default;
}
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 01d1404ca421466419a7db7340ff5e77
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,644 @@
using System;
using System.Threading;
namespace Cysharp.Threading.Tasks
{
public interface IReadOnlyAsyncReactiveProperty<T> : IUniTaskAsyncEnumerable<T>
{
T Value { get; }
IUniTaskAsyncEnumerable<T> WithoutCurrent();
UniTask<T> WaitAsync(CancellationToken cancellationToken = default);
}
public interface IAsyncReactiveProperty<T> : IReadOnlyAsyncReactiveProperty<T>
{
new T Value { get; set; }
}
[Serializable]
public class AsyncReactiveProperty<T> : IAsyncReactiveProperty<T>, IDisposable
{
TriggerEvent<T> triggerEvent;
#if UNITY_2018_3_OR_NEWER
[UnityEngine.SerializeField]
#endif
T latestValue;
public T Value
{
get
{
return latestValue;
}
set
{
this.latestValue = value;
triggerEvent.SetResult(value);
}
}
public AsyncReactiveProperty(T value)
{
this.latestValue = value;
this.triggerEvent = default;
}
public IUniTaskAsyncEnumerable<T> WithoutCurrent()
{
return new WithoutCurrentEnumerable(this);
}
public IUniTaskAsyncEnumerator<T> GetAsyncEnumerator(CancellationToken cancellationToken)
{
return new Enumerator(this, cancellationToken, true);
}
public void Dispose()
{
triggerEvent.SetCompleted();
}
public static implicit operator T(AsyncReactiveProperty<T> value)
{
return value.Value;
}
public override string ToString()
{
if (isValueType) return latestValue.ToString();
return latestValue?.ToString();
}
public UniTask<T> WaitAsync(CancellationToken cancellationToken = default)
{
return new UniTask<T>(WaitAsyncSource.Create(this, cancellationToken, out var token), token);
}
static bool isValueType;
static AsyncReactiveProperty()
{
isValueType = typeof(T).IsValueType;
}
sealed class WaitAsyncSource : IUniTaskSource<T>, ITriggerHandler<T>, ITaskPoolNode<WaitAsyncSource>
{
static Action<object> cancellationCallback = CancellationCallback;
static TaskPool<WaitAsyncSource> pool;
WaitAsyncSource nextNode;
ref WaitAsyncSource ITaskPoolNode<WaitAsyncSource>.NextNode => ref nextNode;
static WaitAsyncSource()
{
TaskPool.RegisterSizeGetter(typeof(WaitAsyncSource), () => pool.Size);
}
AsyncReactiveProperty<T> parent;
CancellationToken cancellationToken;
CancellationTokenRegistration cancellationTokenRegistration;
UniTaskCompletionSourceCore<T> core;
WaitAsyncSource()
{
}
public static IUniTaskSource<T> Create(AsyncReactiveProperty<T> parent, CancellationToken cancellationToken, out short token)
{
if (cancellationToken.IsCancellationRequested)
{
return AutoResetUniTaskCompletionSource<T>.CreateFromCanceled(cancellationToken, out token);
}
if (!pool.TryPop(out var result))
{
result = new WaitAsyncSource();
}
result.parent = parent;
result.cancellationToken = cancellationToken;
if (cancellationToken.CanBeCanceled)
{
result.cancellationTokenRegistration = cancellationToken.RegisterWithoutCaptureExecutionContext(cancellationCallback, result);
}
result.parent.triggerEvent.Add(result);
TaskTracker.TrackActiveTask(result, 3);
token = result.core.Version;
return result;
}
bool TryReturn()
{
TaskTracker.RemoveTracking(this);
core.Reset();
cancellationTokenRegistration.Dispose();
cancellationTokenRegistration = default;
parent.triggerEvent.Remove(this);
parent = null;
cancellationToken = default;
return pool.TryPush(this);
}
static void CancellationCallback(object state)
{
var self = (WaitAsyncSource)state;
self.OnCanceled(self.cancellationToken);
}
// IUniTaskSource
public T GetResult(short token)
{
try
{
return core.GetResult(token);
}
finally
{
TryReturn();
}
}
void IUniTaskSource.GetResult(short token)
{
GetResult(token);
}
public void OnCompleted(Action<object> continuation, object state, short token)
{
core.OnCompleted(continuation, state, token);
}
public UniTaskStatus GetStatus(short token)
{
return core.GetStatus(token);
}
public UniTaskStatus UnsafeGetStatus()
{
return core.UnsafeGetStatus();
}
// ITriggerHandler
ITriggerHandler<T> ITriggerHandler<T>.Prev { get; set; }
ITriggerHandler<T> ITriggerHandler<T>.Next { get; set; }
public void OnCanceled(CancellationToken cancellationToken)
{
core.TrySetCanceled(cancellationToken);
}
public void OnCompleted()
{
// Complete as Cancel.
core.TrySetCanceled(CancellationToken.None);
}
public void OnError(Exception ex)
{
core.TrySetException(ex);
}
public void OnNext(T value)
{
core.TrySetResult(value);
}
}
sealed class WithoutCurrentEnumerable : IUniTaskAsyncEnumerable<T>
{
readonly AsyncReactiveProperty<T> parent;
public WithoutCurrentEnumerable(AsyncReactiveProperty<T> parent)
{
this.parent = parent;
}
public IUniTaskAsyncEnumerator<T> GetAsyncEnumerator(CancellationToken cancellationToken = default)
{
return new Enumerator(parent, cancellationToken, false);
}
}
sealed class Enumerator : MoveNextSource, IUniTaskAsyncEnumerator<T>, ITriggerHandler<T>
{
static Action<object> cancellationCallback = CancellationCallback;
readonly AsyncReactiveProperty<T> parent;
readonly CancellationToken cancellationToken;
readonly CancellationTokenRegistration cancellationTokenRegistration;
T value;
bool isDisposed;
bool firstCall;
public Enumerator(AsyncReactiveProperty<T> parent, CancellationToken cancellationToken, bool publishCurrentValue)
{
this.parent = parent;
this.cancellationToken = cancellationToken;
this.firstCall = publishCurrentValue;
parent.triggerEvent.Add(this);
TaskTracker.TrackActiveTask(this, 3);
if (cancellationToken.CanBeCanceled)
{
cancellationTokenRegistration = cancellationToken.RegisterWithoutCaptureExecutionContext(cancellationCallback, this);
}
}
public T Current => value;
ITriggerHandler<T> ITriggerHandler<T>.Prev { get; set; }
ITriggerHandler<T> ITriggerHandler<T>.Next { get; set; }
public UniTask<bool> MoveNextAsync()
{
// raise latest value on first call.
if (firstCall)
{
firstCall = false;
value = parent.Value;
return CompletedTasks.True;
}
completionSource.Reset();
return new UniTask<bool>(this, completionSource.Version);
}
public UniTask DisposeAsync()
{
if (!isDisposed)
{
isDisposed = true;
TaskTracker.RemoveTracking(this);
completionSource.TrySetCanceled(cancellationToken);
parent.triggerEvent.Remove(this);
}
return default;
}
public void OnNext(T value)
{
this.value = value;
completionSource.TrySetResult(true);
}
public void OnCanceled(CancellationToken cancellationToken)
{
DisposeAsync().Forget();
}
public void OnCompleted()
{
completionSource.TrySetResult(false);
}
public void OnError(Exception ex)
{
completionSource.TrySetException(ex);
}
static void CancellationCallback(object state)
{
var self = (Enumerator)state;
self.DisposeAsync().Forget();
}
}
}
public class ReadOnlyAsyncReactiveProperty<T> : IReadOnlyAsyncReactiveProperty<T>, IDisposable
{
TriggerEvent<T> triggerEvent;
T latestValue;
IUniTaskAsyncEnumerator<T> enumerator;
public T Value
{
get
{
return latestValue;
}
}
public ReadOnlyAsyncReactiveProperty(T initialValue, IUniTaskAsyncEnumerable<T> source, CancellationToken cancellationToken)
{
latestValue = initialValue;
ConsumeEnumerator(source, cancellationToken).Forget();
}
public ReadOnlyAsyncReactiveProperty(IUniTaskAsyncEnumerable<T> source, CancellationToken cancellationToken)
{
ConsumeEnumerator(source, cancellationToken).Forget();
}
async UniTaskVoid ConsumeEnumerator(IUniTaskAsyncEnumerable<T> source, CancellationToken cancellationToken)
{
enumerator = source.GetAsyncEnumerator(cancellationToken);
try
{
while (await enumerator.MoveNextAsync())
{
var value = enumerator.Current;
this.latestValue = value;
triggerEvent.SetResult(value);
}
}
finally
{
await enumerator.DisposeAsync();
enumerator = null;
}
}
public IUniTaskAsyncEnumerable<T> WithoutCurrent()
{
return new WithoutCurrentEnumerable(this);
}
public IUniTaskAsyncEnumerator<T> GetAsyncEnumerator(CancellationToken cancellationToken)
{
return new Enumerator(this, cancellationToken, true);
}
public void Dispose()
{
if (enumerator != null)
{
enumerator.DisposeAsync().Forget();
}
triggerEvent.SetCompleted();
}
public static implicit operator T(ReadOnlyAsyncReactiveProperty<T> value)
{
return value.Value;
}
public override string ToString()
{
if (isValueType) return latestValue.ToString();
return latestValue?.ToString();
}
public UniTask<T> WaitAsync(CancellationToken cancellationToken = default)
{
return new UniTask<T>(WaitAsyncSource.Create(this, cancellationToken, out var token), token);
}
static bool isValueType;
static ReadOnlyAsyncReactiveProperty()
{
isValueType = typeof(T).IsValueType;
}
sealed class WaitAsyncSource : IUniTaskSource<T>, ITriggerHandler<T>, ITaskPoolNode<WaitAsyncSource>
{
static Action<object> cancellationCallback = CancellationCallback;
static TaskPool<WaitAsyncSource> pool;
WaitAsyncSource nextNode;
ref WaitAsyncSource ITaskPoolNode<WaitAsyncSource>.NextNode => ref nextNode;
static WaitAsyncSource()
{
TaskPool.RegisterSizeGetter(typeof(WaitAsyncSource), () => pool.Size);
}
ReadOnlyAsyncReactiveProperty<T> parent;
CancellationToken cancellationToken;
CancellationTokenRegistration cancellationTokenRegistration;
UniTaskCompletionSourceCore<T> core;
WaitAsyncSource()
{
}
public static IUniTaskSource<T> Create(ReadOnlyAsyncReactiveProperty<T> parent, CancellationToken cancellationToken, out short token)
{
if (cancellationToken.IsCancellationRequested)
{
return AutoResetUniTaskCompletionSource<T>.CreateFromCanceled(cancellationToken, out token);
}
if (!pool.TryPop(out var result))
{
result = new WaitAsyncSource();
}
result.parent = parent;
result.cancellationToken = cancellationToken;
if (cancellationToken.CanBeCanceled)
{
result.cancellationTokenRegistration = cancellationToken.RegisterWithoutCaptureExecutionContext(cancellationCallback, result);
}
result.parent.triggerEvent.Add(result);
TaskTracker.TrackActiveTask(result, 3);
token = result.core.Version;
return result;
}
bool TryReturn()
{
TaskTracker.RemoveTracking(this);
core.Reset();
cancellationTokenRegistration.Dispose();
cancellationTokenRegistration = default;
parent.triggerEvent.Remove(this);
parent = null;
cancellationToken = default;
return pool.TryPush(this);
}
static void CancellationCallback(object state)
{
var self = (WaitAsyncSource)state;
self.OnCanceled(self.cancellationToken);
}
// IUniTaskSource
public T GetResult(short token)
{
try
{
return core.GetResult(token);
}
finally
{
TryReturn();
}
}
void IUniTaskSource.GetResult(short token)
{
GetResult(token);
}
public void OnCompleted(Action<object> continuation, object state, short token)
{
core.OnCompleted(continuation, state, token);
}
public UniTaskStatus GetStatus(short token)
{
return core.GetStatus(token);
}
public UniTaskStatus UnsafeGetStatus()
{
return core.UnsafeGetStatus();
}
// ITriggerHandler
ITriggerHandler<T> ITriggerHandler<T>.Prev { get; set; }
ITriggerHandler<T> ITriggerHandler<T>.Next { get; set; }
public void OnCanceled(CancellationToken cancellationToken)
{
core.TrySetCanceled(cancellationToken);
}
public void OnCompleted()
{
// Complete as Cancel.
core.TrySetCanceled(CancellationToken.None);
}
public void OnError(Exception ex)
{
core.TrySetException(ex);
}
public void OnNext(T value)
{
core.TrySetResult(value);
}
}
sealed class WithoutCurrentEnumerable : IUniTaskAsyncEnumerable<T>
{
readonly ReadOnlyAsyncReactiveProperty<T> parent;
public WithoutCurrentEnumerable(ReadOnlyAsyncReactiveProperty<T> parent)
{
this.parent = parent;
}
public IUniTaskAsyncEnumerator<T> GetAsyncEnumerator(CancellationToken cancellationToken = default)
{
return new Enumerator(parent, cancellationToken, false);
}
}
sealed class Enumerator : MoveNextSource, IUniTaskAsyncEnumerator<T>, ITriggerHandler<T>
{
static Action<object> cancellationCallback = CancellationCallback;
readonly ReadOnlyAsyncReactiveProperty<T> parent;
readonly CancellationToken cancellationToken;
readonly CancellationTokenRegistration cancellationTokenRegistration;
T value;
bool isDisposed;
bool firstCall;
public Enumerator(ReadOnlyAsyncReactiveProperty<T> parent, CancellationToken cancellationToken, bool publishCurrentValue)
{
this.parent = parent;
this.cancellationToken = cancellationToken;
this.firstCall = publishCurrentValue;
parent.triggerEvent.Add(this);
TaskTracker.TrackActiveTask(this, 3);
if (cancellationToken.CanBeCanceled)
{
cancellationTokenRegistration = cancellationToken.RegisterWithoutCaptureExecutionContext(cancellationCallback, this);
}
}
public T Current => value;
ITriggerHandler<T> ITriggerHandler<T>.Prev { get; set; }
ITriggerHandler<T> ITriggerHandler<T>.Next { get; set; }
public UniTask<bool> MoveNextAsync()
{
// raise latest value on first call.
if (firstCall)
{
firstCall = false;
value = parent.Value;
return CompletedTasks.True;
}
completionSource.Reset();
return new UniTask<bool>(this, completionSource.Version);
}
public UniTask DisposeAsync()
{
if (!isDisposed)
{
isDisposed = true;
TaskTracker.RemoveTracking(this);
completionSource.TrySetCanceled(cancellationToken);
parent.triggerEvent.Remove(this);
}
return default;
}
public void OnNext(T value)
{
this.value = value;
completionSource.TrySetResult(true);
}
public void OnCanceled(CancellationToken cancellationToken)
{
DisposeAsync().Forget();
}
public void OnCompleted()
{
completionSource.TrySetResult(false);
}
public void OnError(Exception ex)
{
completionSource.TrySetException(ex);
}
static void CancellationCallback(object state)
{
var self = (Enumerator)state;
self.DisposeAsync().Forget();
}
}
}
public static class StateExtensions
{
public static ReadOnlyAsyncReactiveProperty<T> ToReadOnlyAsyncReactiveProperty<T>(this IUniTaskAsyncEnumerable<T> source, CancellationToken cancellationToken)
{
return new ReadOnlyAsyncReactiveProperty<T>(source, cancellationToken);
}
public static ReadOnlyAsyncReactiveProperty<T> ToReadOnlyAsyncReactiveProperty<T>(this IUniTaskAsyncEnumerable<T> source, T initialValue, CancellationToken cancellationToken)
{
return new ReadOnlyAsyncReactiveProperty<T>(initialValue, source, cancellationToken);
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 8ef320b87f537ee4fb2282e765dc6166
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,26 @@
#pragma warning disable CS1591 // Missing XML comment for publicly visible type or
using System;
namespace Cysharp.Threading.Tasks
{
public readonly struct AsyncUnit : IEquatable<AsyncUnit>
{
public static readonly AsyncUnit Default = new AsyncUnit();
public override int GetHashCode()
{
return 0;
}
public bool Equals(AsyncUnit other)
{
return true;
}
public override string ToString()
{
return "()";
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 4f95ac245430d304bb5128d13b6becc8
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,23 @@
#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member
using System.Collections.Generic;
using System.Threading;
namespace Cysharp.Threading.Tasks
{
public class CancellationTokenEqualityComparer : IEqualityComparer<CancellationToken>
{
public static readonly IEqualityComparer<CancellationToken> Default = new CancellationTokenEqualityComparer();
public bool Equals(CancellationToken x, CancellationToken y)
{
return x.Equals(y);
}
public int GetHashCode(CancellationToken obj)
{
return obj.GetHashCode();
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 7d739f510b125b74fa7290ac4335e46e
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,182 @@
#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member
using System;
using System.Runtime.CompilerServices;
using System.Threading;
namespace Cysharp.Threading.Tasks
{
public static class CancellationTokenExtensions
{
static readonly Action<object> cancellationTokenCallback = Callback;
static readonly Action<object> disposeCallback = DisposeCallback;
public static CancellationToken ToCancellationToken(this UniTask task)
{
var cts = new CancellationTokenSource();
ToCancellationTokenCore(task, cts).Forget();
return cts.Token;
}
public static CancellationToken ToCancellationToken(this UniTask task, CancellationToken linkToken)
{
if (linkToken.IsCancellationRequested)
{
return linkToken;
}
if (!linkToken.CanBeCanceled)
{
return ToCancellationToken(task);
}
var cts = CancellationTokenSource.CreateLinkedTokenSource(linkToken);
ToCancellationTokenCore(task, cts).Forget();
return cts.Token;
}
public static CancellationToken ToCancellationToken<T>(this UniTask<T> task)
{
return ToCancellationToken(task.AsUniTask());
}
public static CancellationToken ToCancellationToken<T>(this UniTask<T> task, CancellationToken linkToken)
{
return ToCancellationToken(task.AsUniTask(), linkToken);
}
static async UniTaskVoid ToCancellationTokenCore(UniTask task, CancellationTokenSource cts)
{
try
{
await task;
}
catch (Exception ex)
{
UniTaskScheduler.PublishUnobservedTaskException(ex);
}
cts.Cancel();
cts.Dispose();
}
public static (UniTask, CancellationTokenRegistration) ToUniTask(this CancellationToken cancellationToken)
{
if (cancellationToken.IsCancellationRequested)
{
return (UniTask.FromCanceled(cancellationToken), default(CancellationTokenRegistration));
}
var promise = new UniTaskCompletionSource();
return (promise.Task, cancellationToken.RegisterWithoutCaptureExecutionContext(cancellationTokenCallback, promise));
}
static void Callback(object state)
{
var promise = (UniTaskCompletionSource)state;
promise.TrySetResult();
}
public static CancellationTokenAwaitable WaitUntilCanceled(this CancellationToken cancellationToken)
{
return new CancellationTokenAwaitable(cancellationToken);
}
public static CancellationTokenRegistration RegisterWithoutCaptureExecutionContext(this CancellationToken cancellationToken, Action callback)
{
var restoreFlow = false;
if (!ExecutionContext.IsFlowSuppressed())
{
ExecutionContext.SuppressFlow();
restoreFlow = true;
}
try
{
return cancellationToken.Register(callback, false);
}
finally
{
if (restoreFlow)
{
ExecutionContext.RestoreFlow();
}
}
}
public static CancellationTokenRegistration RegisterWithoutCaptureExecutionContext(this CancellationToken cancellationToken, Action<object> callback, object state)
{
var restoreFlow = false;
if (!ExecutionContext.IsFlowSuppressed())
{
ExecutionContext.SuppressFlow();
restoreFlow = true;
}
try
{
return cancellationToken.Register(callback, state, false);
}
finally
{
if (restoreFlow)
{
ExecutionContext.RestoreFlow();
}
}
}
public static CancellationTokenRegistration AddTo(this IDisposable disposable, CancellationToken cancellationToken)
{
return cancellationToken.RegisterWithoutCaptureExecutionContext(disposeCallback, disposable);
}
static void DisposeCallback(object state)
{
var d = (IDisposable)state;
d.Dispose();
}
}
public struct CancellationTokenAwaitable
{
CancellationToken cancellationToken;
public CancellationTokenAwaitable(CancellationToken cancellationToken)
{
this.cancellationToken = cancellationToken;
}
public Awaiter GetAwaiter()
{
return new Awaiter(cancellationToken);
}
public struct Awaiter : ICriticalNotifyCompletion
{
CancellationToken cancellationToken;
public Awaiter(CancellationToken cancellationToken)
{
this.cancellationToken = cancellationToken;
}
public bool IsCompleted => !cancellationToken.CanBeCanceled || cancellationToken.IsCancellationRequested;
public void GetResult()
{
}
public void OnCompleted(Action continuation)
{
UnsafeOnCompleted(continuation);
}
public void UnsafeOnCompleted(Action continuation)
{
cancellationToken.RegisterWithoutCaptureExecutionContext(continuation);
}
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 4be7209f04146bd45ac5ee775a5f7c26
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,44 @@
#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member
using System.Threading;
using UnityEngine;
using Cysharp.Threading.Tasks.Triggers;
using System;
using Cysharp.Threading.Tasks.Internal;
namespace Cysharp.Threading.Tasks
{
public static partial class CancellationTokenSourceExtensions
{
readonly static Action<object> CancelCancellationTokenSourceStateDelegate = new Action<object>(CancelCancellationTokenSourceState);
static void CancelCancellationTokenSourceState(object state)
{
var cts = (CancellationTokenSource)state;
cts.Cancel();
}
public static IDisposable CancelAfterSlim(this CancellationTokenSource cts, int millisecondsDelay, DelayType delayType = DelayType.DeltaTime, PlayerLoopTiming delayTiming = PlayerLoopTiming.Update)
{
return CancelAfterSlim(cts, TimeSpan.FromMilliseconds(millisecondsDelay), delayType, delayTiming);
}
public static IDisposable CancelAfterSlim(this CancellationTokenSource cts, TimeSpan delayTimeSpan, DelayType delayType = DelayType.DeltaTime, PlayerLoopTiming delayTiming = PlayerLoopTiming.Update)
{
return PlayerLoopTimer.StartNew(delayTimeSpan, false, delayType, delayTiming, cts.Token, CancelCancellationTokenSourceStateDelegate, cts);
}
public static void RegisterRaiseCancelOnDestroy(this CancellationTokenSource cts, Component component)
{
RegisterRaiseCancelOnDestroy(cts, component.gameObject);
}
public static void RegisterRaiseCancelOnDestroy(this CancellationTokenSource cts, GameObject gameObject)
{
var trigger = gameObject.GetAsyncDestroyTrigger();
trigger.CancellationToken.RegisterWithoutCaptureExecutionContext(CancelCancellationTokenSourceStateDelegate, cts);
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 22d85d07f1e70ab42a7a4c25bd65e661
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,450 @@
using System;
using System.Collections.Generic;
using System.Threading;
namespace Cysharp.Threading.Tasks
{
public static class Channel
{
public static Channel<T> CreateSingleConsumerUnbounded<T>()
{
return new SingleConsumerUnboundedChannel<T>();
}
}
public abstract class Channel<TWrite, TRead>
{
public ChannelReader<TRead> Reader { get; protected set; }
public ChannelWriter<TWrite> Writer { get; protected set; }
public static implicit operator ChannelReader<TRead>(Channel<TWrite, TRead> channel) => channel.Reader;
public static implicit operator ChannelWriter<TWrite>(Channel<TWrite, TRead> channel) => channel.Writer;
}
public abstract class Channel<T> : Channel<T, T>
{
}
public abstract class ChannelReader<T>
{
public abstract bool TryRead(out T item);
public abstract UniTask<bool> WaitToReadAsync(CancellationToken cancellationToken = default(CancellationToken));
public abstract UniTask Completion { get; }
public virtual UniTask<T> ReadAsync(CancellationToken cancellationToken = default(CancellationToken))
{
if (this.TryRead(out var item))
{
return UniTask.FromResult(item);
}
return ReadAsyncCore(cancellationToken);
}
async UniTask<T> ReadAsyncCore(CancellationToken cancellationToken = default(CancellationToken))
{
if (await WaitToReadAsync(cancellationToken))
{
if (TryRead(out var item))
{
return item;
}
}
throw new ChannelClosedException();
}
public abstract IUniTaskAsyncEnumerable<T> ReadAllAsync(CancellationToken cancellationToken = default(CancellationToken));
}
public abstract class ChannelWriter<T>
{
public abstract bool TryWrite(T item);
public abstract bool TryComplete(Exception error = null);
public void Complete(Exception error = null)
{
if (!TryComplete(error))
{
throw new ChannelClosedException();
}
}
}
public partial class ChannelClosedException : InvalidOperationException
{
public ChannelClosedException() :
base("Channel is already closed.")
{ }
public ChannelClosedException(string message) : base(message) { }
public ChannelClosedException(Exception innerException) :
base("Channel is already closed", innerException)
{ }
public ChannelClosedException(string message, Exception innerException) : base(message, innerException) { }
}
internal class SingleConsumerUnboundedChannel<T> : Channel<T>
{
readonly Queue<T> items;
readonly SingleConsumerUnboundedChannelReader readerSource;
UniTaskCompletionSource completedTaskSource;
UniTask completedTask;
Exception completionError;
bool closed;
public SingleConsumerUnboundedChannel()
{
items = new Queue<T>();
Writer = new SingleConsumerUnboundedChannelWriter(this);
readerSource = new SingleConsumerUnboundedChannelReader(this);
Reader = readerSource;
}
sealed class SingleConsumerUnboundedChannelWriter : ChannelWriter<T>
{
readonly SingleConsumerUnboundedChannel<T> parent;
public SingleConsumerUnboundedChannelWriter(SingleConsumerUnboundedChannel<T> parent)
{
this.parent = parent;
}
public override bool TryWrite(T item)
{
bool waiting;
lock (parent.items)
{
if (parent.closed) return false;
parent.items.Enqueue(item);
waiting = parent.readerSource.isWaiting;
}
if (waiting)
{
parent.readerSource.SingalContinuation();
}
return true;
}
public override bool TryComplete(Exception error = null)
{
bool waiting;
lock (parent.items)
{
if (parent.closed) return false;
parent.closed = true;
waiting = parent.readerSource.isWaiting;
if (parent.items.Count == 0)
{
if (error == null)
{
if (parent.completedTaskSource != null)
{
parent.completedTaskSource.TrySetResult();
}
else
{
parent.completedTask = UniTask.CompletedTask;
}
}
else
{
if (parent.completedTaskSource != null)
{
parent.completedTaskSource.TrySetException(error);
}
else
{
parent.completedTask = UniTask.FromException(error);
}
}
if (waiting)
{
parent.readerSource.SingalCompleted(error);
}
}
parent.completionError = error;
}
return true;
}
}
sealed class SingleConsumerUnboundedChannelReader : ChannelReader<T>, IUniTaskSource<bool>
{
readonly Action<object> CancellationCallbackDelegate = CancellationCallback;
readonly SingleConsumerUnboundedChannel<T> parent;
CancellationToken cancellationToken;
CancellationTokenRegistration cancellationTokenRegistration;
UniTaskCompletionSourceCore<bool> core;
internal bool isWaiting;
public SingleConsumerUnboundedChannelReader(SingleConsumerUnboundedChannel<T> parent)
{
this.parent = parent;
TaskTracker.TrackActiveTask(this, 4);
}
public override UniTask Completion
{
get
{
if (parent.completedTaskSource != null) return parent.completedTaskSource.Task;
if (parent.closed)
{
return parent.completedTask;
}
parent.completedTaskSource = new UniTaskCompletionSource();
return parent.completedTaskSource.Task;
}
}
public override bool TryRead(out T item)
{
lock (parent.items)
{
if (parent.items.Count != 0)
{
item = parent.items.Dequeue();
// complete when all value was consumed.
if (parent.closed && parent.items.Count == 0)
{
if (parent.completionError != null)
{
if (parent.completedTaskSource != null)
{
parent.completedTaskSource.TrySetException(parent.completionError);
}
else
{
parent.completedTask = UniTask.FromException(parent.completionError);
}
}
else
{
if (parent.completedTaskSource != null)
{
parent.completedTaskSource.TrySetResult();
}
else
{
parent.completedTask = UniTask.CompletedTask;
}
}
}
}
else
{
item = default;
return false;
}
}
return true;
}
public override UniTask<bool> WaitToReadAsync(CancellationToken cancellationToken)
{
if (cancellationToken.IsCancellationRequested)
{
return UniTask.FromCanceled<bool>(cancellationToken);
}
lock (parent.items)
{
if (parent.items.Count != 0)
{
return CompletedTasks.True;
}
if (parent.closed)
{
if (parent.completionError == null)
{
return CompletedTasks.False;
}
else
{
return UniTask.FromException<bool>(parent.completionError);
}
}
cancellationTokenRegistration.Dispose();
core.Reset();
isWaiting = true;
this.cancellationToken = cancellationToken;
if (this.cancellationToken.CanBeCanceled)
{
cancellationTokenRegistration = this.cancellationToken.RegisterWithoutCaptureExecutionContext(CancellationCallbackDelegate, this);
}
return new UniTask<bool>(this, core.Version);
}
}
public void SingalContinuation()
{
core.TrySetResult(true);
}
public void SingalCancellation(CancellationToken cancellationToken)
{
TaskTracker.RemoveTracking(this);
core.TrySetCanceled(cancellationToken);
}
public void SingalCompleted(Exception error)
{
if (error != null)
{
TaskTracker.RemoveTracking(this);
core.TrySetException(error);
}
else
{
TaskTracker.RemoveTracking(this);
core.TrySetResult(false);
}
}
public override IUniTaskAsyncEnumerable<T> ReadAllAsync(CancellationToken cancellationToken = default)
{
return new ReadAllAsyncEnumerable(this, cancellationToken);
}
bool IUniTaskSource<bool>.GetResult(short token)
{
return core.GetResult(token);
}
void IUniTaskSource.GetResult(short token)
{
core.GetResult(token);
}
UniTaskStatus IUniTaskSource.GetStatus(short token)
{
return core.GetStatus(token);
}
void IUniTaskSource.OnCompleted(Action<object> continuation, object state, short token)
{
core.OnCompleted(continuation, state, token);
}
UniTaskStatus IUniTaskSource.UnsafeGetStatus()
{
return core.UnsafeGetStatus();
}
static void CancellationCallback(object state)
{
var self = (SingleConsumerUnboundedChannelReader)state;
self.SingalCancellation(self.cancellationToken);
}
sealed class ReadAllAsyncEnumerable : IUniTaskAsyncEnumerable<T>, IUniTaskAsyncEnumerator<T>
{
readonly Action<object> CancellationCallback1Delegate = CancellationCallback1;
readonly Action<object> CancellationCallback2Delegate = CancellationCallback2;
readonly SingleConsumerUnboundedChannelReader parent;
CancellationToken cancellationToken1;
CancellationToken cancellationToken2;
CancellationTokenRegistration cancellationTokenRegistration1;
CancellationTokenRegistration cancellationTokenRegistration2;
T current;
bool cacheValue;
bool running;
public ReadAllAsyncEnumerable(SingleConsumerUnboundedChannelReader parent, CancellationToken cancellationToken)
{
this.parent = parent;
this.cancellationToken1 = cancellationToken;
}
public IUniTaskAsyncEnumerator<T> GetAsyncEnumerator(CancellationToken cancellationToken = default)
{
if (running)
{
throw new InvalidOperationException("Enumerator is already running, does not allow call GetAsyncEnumerator twice.");
}
if (this.cancellationToken1 != cancellationToken)
{
this.cancellationToken2 = cancellationToken;
}
if (this.cancellationToken1.CanBeCanceled)
{
this.cancellationTokenRegistration1 = this.cancellationToken1.RegisterWithoutCaptureExecutionContext(CancellationCallback1Delegate, this);
}
if (this.cancellationToken2.CanBeCanceled)
{
this.cancellationTokenRegistration2 = this.cancellationToken2.RegisterWithoutCaptureExecutionContext(CancellationCallback2Delegate, this);
}
running = true;
return this;
}
public T Current
{
get
{
if (cacheValue)
{
return current;
}
parent.TryRead(out current);
return current;
}
}
public UniTask<bool> MoveNextAsync()
{
cacheValue = false;
return parent.WaitToReadAsync(CancellationToken.None); // ok to use None, registered in ctor.
}
public UniTask DisposeAsync()
{
cancellationTokenRegistration1.Dispose();
cancellationTokenRegistration2.Dispose();
return default;
}
static void CancellationCallback1(object state)
{
var self = (ReadAllAsyncEnumerable)state;
self.parent.SingalCancellation(self.cancellationToken1);
}
static void CancellationCallback2(object state)
{
var self = (ReadAllAsyncEnumerable)state;
self.parent.SingalCancellation(self.cancellationToken2);
}
}
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 5ceb3107bbdd1f14eb39091273798360
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -1,5 +1,6 @@
fileFormatVersion: 2
guid: 7a0bb33169d95ec499136d59cb25918b
guid: 581d422ac5b39a647bfbb2d0c40176b0
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:

View File

@@ -0,0 +1,17 @@
#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member
#pragma warning disable CS0436
namespace System.Runtime.CompilerServices
{
internal sealed class AsyncMethodBuilderAttribute : Attribute
{
public Type BuilderType { get; }
public AsyncMethodBuilderAttribute(Type builderType)
{
BuilderType = builderType;
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 02ce354d37b10454e8376062f7cbe57a
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,269 @@
#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member
using System;
using System.Diagnostics;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using System.Security;
namespace Cysharp.Threading.Tasks.CompilerServices
{
[StructLayout(LayoutKind.Auto)]
public struct AsyncUniTaskMethodBuilder
{
IStateMachineRunnerPromise runnerPromise;
Exception ex;
// 1. Static Create method.
[DebuggerHidden]
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static AsyncUniTaskMethodBuilder Create()
{
return default;
}
// 2. TaskLike Task property.
public UniTask Task
{
[DebuggerHidden]
[MethodImpl(MethodImplOptions.AggressiveInlining)]
get
{
if (runnerPromise != null)
{
return runnerPromise.Task;
}
else if (ex != null)
{
return UniTask.FromException(ex);
}
else
{
return UniTask.CompletedTask;
}
}
}
// 3. SetException
[DebuggerHidden]
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public void SetException(Exception exception)
{
if (runnerPromise == null)
{
ex = exception;
}
else
{
runnerPromise.SetException(exception);
}
}
// 4. SetResult
[DebuggerHidden]
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public void SetResult()
{
if (runnerPromise != null)
{
runnerPromise.SetResult();
}
}
// 5. AwaitOnCompleted
[DebuggerHidden]
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public void AwaitOnCompleted<TAwaiter, TStateMachine>(ref TAwaiter awaiter, ref TStateMachine stateMachine)
where TAwaiter : INotifyCompletion
where TStateMachine : IAsyncStateMachine
{
if (runnerPromise == null)
{
AsyncUniTask<TStateMachine>.SetStateMachine(ref stateMachine, ref runnerPromise);
}
awaiter.OnCompleted(runnerPromise.MoveNext);
}
// 6. AwaitUnsafeOnCompleted
[DebuggerHidden]
[SecuritySafeCritical]
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public void AwaitUnsafeOnCompleted<TAwaiter, TStateMachine>(ref TAwaiter awaiter, ref TStateMachine stateMachine)
where TAwaiter : ICriticalNotifyCompletion
where TStateMachine : IAsyncStateMachine
{
if (runnerPromise == null)
{
AsyncUniTask<TStateMachine>.SetStateMachine(ref stateMachine, ref runnerPromise);
}
awaiter.UnsafeOnCompleted(runnerPromise.MoveNext);
}
// 7. Start
[DebuggerHidden]
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public void Start<TStateMachine>(ref TStateMachine stateMachine)
where TStateMachine : IAsyncStateMachine
{
stateMachine.MoveNext();
}
// 8. SetStateMachine
[DebuggerHidden]
public void SetStateMachine(IAsyncStateMachine stateMachine)
{
// don't use boxed stateMachine.
}
#if DEBUG || !UNITY_2018_3_OR_NEWER
// Important for IDE debugger.
object debuggingId;
private object ObjectIdForDebugger
{
get
{
if (debuggingId == null)
{
debuggingId = new object();
}
return debuggingId;
}
}
#endif
}
[StructLayout(LayoutKind.Auto)]
public struct AsyncUniTaskMethodBuilder<T>
{
IStateMachineRunnerPromise<T> runnerPromise;
Exception ex;
T result;
// 1. Static Create method.
[DebuggerHidden]
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static AsyncUniTaskMethodBuilder<T> Create()
{
return default;
}
// 2. TaskLike Task property.
public UniTask<T> Task
{
[DebuggerHidden]
[MethodImpl(MethodImplOptions.AggressiveInlining)]
get
{
if (runnerPromise != null)
{
return runnerPromise.Task;
}
else if (ex != null)
{
return UniTask.FromException<T>(ex);
}
else
{
return UniTask.FromResult(result);
}
}
}
// 3. SetException
[DebuggerHidden]
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public void SetException(Exception exception)
{
if (runnerPromise == null)
{
ex = exception;
}
else
{
runnerPromise.SetException(exception);
}
}
// 4. SetResult
[DebuggerHidden]
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public void SetResult(T result)
{
if (runnerPromise == null)
{
this.result = result;
}
else
{
runnerPromise.SetResult(result);
}
}
// 5. AwaitOnCompleted
[DebuggerHidden]
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public void AwaitOnCompleted<TAwaiter, TStateMachine>(ref TAwaiter awaiter, ref TStateMachine stateMachine)
where TAwaiter : INotifyCompletion
where TStateMachine : IAsyncStateMachine
{
if (runnerPromise == null)
{
AsyncUniTask<TStateMachine, T>.SetStateMachine(ref stateMachine, ref runnerPromise);
}
awaiter.OnCompleted(runnerPromise.MoveNext);
}
// 6. AwaitUnsafeOnCompleted
[DebuggerHidden]
[SecuritySafeCritical]
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public void AwaitUnsafeOnCompleted<TAwaiter, TStateMachine>(ref TAwaiter awaiter, ref TStateMachine stateMachine)
where TAwaiter : ICriticalNotifyCompletion
where TStateMachine : IAsyncStateMachine
{
if (runnerPromise == null)
{
AsyncUniTask<TStateMachine, T>.SetStateMachine(ref stateMachine, ref runnerPromise);
}
awaiter.UnsafeOnCompleted(runnerPromise.MoveNext);
}
// 7. Start
[DebuggerHidden]
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public void Start<TStateMachine>(ref TStateMachine stateMachine)
where TStateMachine : IAsyncStateMachine
{
stateMachine.MoveNext();
}
// 8. SetStateMachine
[DebuggerHidden]
public void SetStateMachine(IAsyncStateMachine stateMachine)
{
// don't use boxed stateMachine.
}
#if DEBUG || !UNITY_2018_3_OR_NEWER
// Important for IDE debugger.
object debuggingId;
private object ObjectIdForDebugger
{
get
{
if (debuggingId == null)
{
debuggingId = new object();
}
return debuggingId;
}
}
#endif
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 68d72a45afdec574ebc26e7de2c38330
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,137 @@
#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member
using System;
using System.Diagnostics;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using System.Security;
namespace Cysharp.Threading.Tasks.CompilerServices
{
[StructLayout(LayoutKind.Auto)]
public struct AsyncUniTaskVoidMethodBuilder
{
IStateMachineRunner runner;
// 1. Static Create method.
[DebuggerHidden]
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static AsyncUniTaskVoidMethodBuilder Create()
{
return default;
}
// 2. TaskLike Task property(void)
public UniTaskVoid Task
{
[DebuggerHidden]
[MethodImpl(MethodImplOptions.AggressiveInlining)]
get
{
return default;
}
}
// 3. SetException
[DebuggerHidden]
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public void SetException(Exception exception)
{
// runner is finished, return first.
if (runner != null)
{
#if ENABLE_IL2CPP
// workaround for IL2CPP bug.
PlayerLoopHelper.AddContinuation(PlayerLoopTiming.LastPostLateUpdate, runner.ReturnAction);
#else
runner.Return();
#endif
runner = null;
}
UniTaskScheduler.PublishUnobservedTaskException(exception);
}
// 4. SetResult
[DebuggerHidden]
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public void SetResult()
{
// runner is finished, return.
if (runner != null)
{
#if ENABLE_IL2CPP
// workaround for IL2CPP bug.
PlayerLoopHelper.AddContinuation(PlayerLoopTiming.LastPostLateUpdate, runner.ReturnAction);
#else
runner.Return();
#endif
runner = null;
}
}
// 5. AwaitOnCompleted
[DebuggerHidden]
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public void AwaitOnCompleted<TAwaiter, TStateMachine>(ref TAwaiter awaiter, ref TStateMachine stateMachine)
where TAwaiter : INotifyCompletion
where TStateMachine : IAsyncStateMachine
{
if (runner == null)
{
AsyncUniTaskVoid<TStateMachine>.SetStateMachine(ref stateMachine, ref runner);
}
awaiter.OnCompleted(runner.MoveNext);
}
// 6. AwaitUnsafeOnCompleted
[DebuggerHidden]
[SecuritySafeCritical]
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public void AwaitUnsafeOnCompleted<TAwaiter, TStateMachine>(ref TAwaiter awaiter, ref TStateMachine stateMachine)
where TAwaiter : ICriticalNotifyCompletion
where TStateMachine : IAsyncStateMachine
{
if (runner == null)
{
AsyncUniTaskVoid<TStateMachine>.SetStateMachine(ref stateMachine, ref runner);
}
awaiter.UnsafeOnCompleted(runner.MoveNext);
}
// 7. Start
[DebuggerHidden]
public void Start<TStateMachine>(ref TStateMachine stateMachine)
where TStateMachine : IAsyncStateMachine
{
stateMachine.MoveNext();
}
// 8. SetStateMachine
[DebuggerHidden]
public void SetStateMachine(IAsyncStateMachine stateMachine)
{
// don't use boxed stateMachine.
}
#if DEBUG || !UNITY_2018_3_OR_NEWER
// Important for IDE debugger.
object debuggingId;
private object ObjectIdForDebugger
{
get
{
if (debuggingId == null)
{
debuggingId = new object();
}
return debuggingId;
}
}
#endif
}
}

Some files were not shown because too many files have changed in this diff Show More