完成阶段 4
This commit is contained in:
parent
f053c9ad0d
commit
ff9ee1291f
|
|
@ -0,0 +1,246 @@
|
|||
---
|
||||
name: openspec-bulk-archive-change
|
||||
description: Archive multiple completed changes at once. Use when archiving several parallel changes.
|
||||
license: MIT
|
||||
compatibility: Requires openspec CLI.
|
||||
metadata:
|
||||
author: openspec
|
||||
version: "1.0"
|
||||
generatedBy: "1.2.0"
|
||||
---
|
||||
|
||||
Archive multiple completed changes in a single operation.
|
||||
|
||||
This skill allows you to batch-archive changes, handling spec conflicts intelligently by checking the codebase to determine what's actually implemented.
|
||||
|
||||
**Input**: None required (prompts for selection)
|
||||
|
||||
**Steps**
|
||||
|
||||
1. **Get active changes**
|
||||
|
||||
Run `openspec list --json` to get all active changes.
|
||||
|
||||
If no active changes exist, inform user and stop.
|
||||
|
||||
2. **Prompt for change selection**
|
||||
|
||||
Use **AskUserQuestion tool** with multi-select to let user choose changes:
|
||||
- Show each change with its schema
|
||||
- Include an option for "All changes"
|
||||
- Allow any number of selections (1+ works, 2+ is the typical use case)
|
||||
|
||||
**IMPORTANT**: Do NOT auto-select. Always let the user choose.
|
||||
|
||||
3. **Batch validation - gather status for all selected changes**
|
||||
|
||||
For each selected change, collect:
|
||||
|
||||
a. **Artifact status** - Run `openspec status --change "<name>" --json`
|
||||
- Parse `schemaName` and `artifacts` list
|
||||
- Note which artifacts are `done` vs other states
|
||||
|
||||
b. **Task completion** - Read `openspec/changes/<name>/tasks.md`
|
||||
- Count `- [ ]` (incomplete) vs `- [x]` (complete)
|
||||
- If no tasks file exists, note as "No tasks"
|
||||
|
||||
c. **Delta specs** - Check `openspec/changes/<name>/specs/` directory
|
||||
- List which capability specs exist
|
||||
- For each, extract requirement names (lines matching `### Requirement: <name>`)
|
||||
|
||||
4. **Detect spec conflicts**
|
||||
|
||||
Build a map of `capability -> [changes that touch it]`:
|
||||
|
||||
```
|
||||
auth -> [change-a, change-b] <- CONFLICT (2+ changes)
|
||||
api -> [change-c] <- OK (only 1 change)
|
||||
```
|
||||
|
||||
A conflict exists when 2+ selected changes have delta specs for the same capability.
|
||||
|
||||
5. **Resolve conflicts agentically**
|
||||
|
||||
**For each conflict**, investigate the codebase:
|
||||
|
||||
a. **Read the delta specs** from each conflicting change to understand what each claims to add/modify
|
||||
|
||||
b. **Search the codebase** for implementation evidence:
|
||||
- Look for code implementing requirements from each delta spec
|
||||
- Check for related files, functions, or tests
|
||||
|
||||
c. **Determine resolution**:
|
||||
- If only one change is actually implemented -> sync that one's specs
|
||||
- If both implemented -> apply in chronological order (older first, newer overwrites)
|
||||
- If neither implemented -> skip spec sync, warn user
|
||||
|
||||
d. **Record resolution** for each conflict:
|
||||
- Which change's specs to apply
|
||||
- In what order (if both)
|
||||
- Rationale (what was found in codebase)
|
||||
|
||||
6. **Show consolidated status table**
|
||||
|
||||
Display a table summarizing all changes:
|
||||
|
||||
```
|
||||
| Change | Artifacts | Tasks | Specs | Conflicts | Status |
|
||||
|---------------------|-----------|-------|---------|-----------|--------|
|
||||
| schema-management | Done | 5/5 | 2 delta | None | Ready |
|
||||
| project-config | Done | 3/3 | 1 delta | None | Ready |
|
||||
| add-oauth | Done | 4/4 | 1 delta | auth (!) | Ready* |
|
||||
| add-verify-skill | 1 left | 2/5 | None | None | Warn |
|
||||
```
|
||||
|
||||
For conflicts, show the resolution:
|
||||
```
|
||||
* Conflict resolution:
|
||||
- auth spec: Will apply add-oauth then add-jwt (both implemented, chronological order)
|
||||
```
|
||||
|
||||
For incomplete changes, show warnings:
|
||||
```
|
||||
Warnings:
|
||||
- add-verify-skill: 1 incomplete artifact, 3 incomplete tasks
|
||||
```
|
||||
|
||||
7. **Confirm batch operation**
|
||||
|
||||
Use **AskUserQuestion tool** with a single confirmation:
|
||||
|
||||
- "Archive N changes?" with options based on status
|
||||
- Options might include:
|
||||
- "Archive all N changes"
|
||||
- "Archive only N ready changes (skip incomplete)"
|
||||
- "Cancel"
|
||||
|
||||
If there are incomplete changes, make clear they'll be archived with warnings.
|
||||
|
||||
8. **Execute archive for each confirmed change**
|
||||
|
||||
Process changes in the determined order (respecting conflict resolution):
|
||||
|
||||
a. **Sync specs** if delta specs exist:
|
||||
- Use the openspec-sync-specs approach (agent-driven intelligent merge)
|
||||
- For conflicts, apply in resolved order
|
||||
- Track if sync was done
|
||||
|
||||
b. **Perform the archive**:
|
||||
```bash
|
||||
mkdir -p openspec/changes/archive
|
||||
mv openspec/changes/<name> openspec/changes/archive/YYYY-MM-DD-<name>
|
||||
```
|
||||
|
||||
c. **Track outcome** for each change:
|
||||
- Success: archived successfully
|
||||
- Failed: error during archive (record error)
|
||||
- Skipped: user chose not to archive (if applicable)
|
||||
|
||||
9. **Display summary**
|
||||
|
||||
Show final results:
|
||||
|
||||
```
|
||||
## Bulk Archive Complete
|
||||
|
||||
Archived 3 changes:
|
||||
- schema-management-cli -> archive/2026-01-19-schema-management-cli/
|
||||
- project-config -> archive/2026-01-19-project-config/
|
||||
- add-oauth -> archive/2026-01-19-add-oauth/
|
||||
|
||||
Skipped 1 change:
|
||||
- add-verify-skill (user chose not to archive incomplete)
|
||||
|
||||
Spec sync summary:
|
||||
- 4 delta specs synced to main specs
|
||||
- 1 conflict resolved (auth: applied both in chronological order)
|
||||
```
|
||||
|
||||
If any failures:
|
||||
```
|
||||
Failed 1 change:
|
||||
- some-change: Archive directory already exists
|
||||
```
|
||||
|
||||
**Conflict Resolution Examples**
|
||||
|
||||
Example 1: Only one implemented
|
||||
```
|
||||
Conflict: specs/auth/spec.md touched by [add-oauth, add-jwt]
|
||||
|
||||
Checking add-oauth:
|
||||
- Delta adds "OAuth Provider Integration" requirement
|
||||
- Searching codebase... found src/auth/oauth.ts implementing OAuth flow
|
||||
|
||||
Checking add-jwt:
|
||||
- Delta adds "JWT Token Handling" requirement
|
||||
- Searching codebase... no JWT implementation found
|
||||
|
||||
Resolution: Only add-oauth is implemented. Will sync add-oauth specs only.
|
||||
```
|
||||
|
||||
Example 2: Both implemented
|
||||
```
|
||||
Conflict: specs/api/spec.md touched by [add-rest-api, add-graphql]
|
||||
|
||||
Checking add-rest-api (created 2026-01-10):
|
||||
- Delta adds "REST Endpoints" requirement
|
||||
- Searching codebase... found src/api/rest.ts
|
||||
|
||||
Checking add-graphql (created 2026-01-15):
|
||||
- Delta adds "GraphQL Schema" requirement
|
||||
- Searching codebase... found src/api/graphql.ts
|
||||
|
||||
Resolution: Both implemented. Will apply add-rest-api specs first,
|
||||
then add-graphql specs (chronological order, newer takes precedence).
|
||||
```
|
||||
|
||||
**Output On Success**
|
||||
|
||||
```
|
||||
## Bulk Archive Complete
|
||||
|
||||
Archived N changes:
|
||||
- <change-1> -> archive/YYYY-MM-DD-<change-1>/
|
||||
- <change-2> -> archive/YYYY-MM-DD-<change-2>/
|
||||
|
||||
Spec sync summary:
|
||||
- N delta specs synced to main specs
|
||||
- No conflicts (or: M conflicts resolved)
|
||||
```
|
||||
|
||||
**Output On Partial Success**
|
||||
|
||||
```
|
||||
## Bulk Archive Complete (partial)
|
||||
|
||||
Archived N changes:
|
||||
- <change-1> -> archive/YYYY-MM-DD-<change-1>/
|
||||
|
||||
Skipped M changes:
|
||||
- <change-2> (user chose not to archive incomplete)
|
||||
|
||||
Failed K changes:
|
||||
- <change-3>: Archive directory already exists
|
||||
```
|
||||
|
||||
**Output When No Changes**
|
||||
|
||||
```
|
||||
## No Changes to Archive
|
||||
|
||||
No active changes found. Create a new change to get started.
|
||||
```
|
||||
|
||||
**Guardrails**
|
||||
- Allow any number of changes (1+ is fine, 2+ is the typical use case)
|
||||
- Always prompt for selection, never auto-select
|
||||
- Detect spec conflicts early and resolve by checking codebase
|
||||
- When both changes are implemented, apply specs in chronological order
|
||||
- Skip spec sync only when implementation is missing (warn user)
|
||||
- Show clear per-change status before confirming
|
||||
- Use single confirmation for entire batch
|
||||
- Track and report all outcomes (success/skip/fail)
|
||||
- Preserve .openspec.yaml when moving to archive
|
||||
- Archive directory target uses current date: YYYY-MM-DD-<name>
|
||||
- If archive target exists, fail that change but continue with others
|
||||
|
|
@ -0,0 +1,118 @@
|
|||
---
|
||||
name: openspec-continue-change
|
||||
description: Continue working on an OpenSpec change by creating the next artifact. Use when the user wants to progress their change, create the next artifact, or continue their workflow.
|
||||
license: MIT
|
||||
compatibility: Requires openspec CLI.
|
||||
metadata:
|
||||
author: openspec
|
||||
version: "1.0"
|
||||
generatedBy: "1.2.0"
|
||||
---
|
||||
|
||||
Continue working on a change by creating the next artifact.
|
||||
|
||||
**Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
|
||||
|
||||
**Steps**
|
||||
|
||||
1. **If no change name provided, prompt for selection**
|
||||
|
||||
Run `openspec list --json` to get available changes sorted by most recently modified. Then use the **AskUserQuestion tool** to let the user select which change to work on.
|
||||
|
||||
Present the top 3-4 most recently modified changes as options, showing:
|
||||
- Change name
|
||||
- Schema (from `schema` field if present, otherwise "spec-driven")
|
||||
- Status (e.g., "0/5 tasks", "complete", "no tasks")
|
||||
- How recently it was modified (from `lastModified` field)
|
||||
|
||||
Mark the most recently modified change as "(Recommended)" since it's likely what the user wants to continue.
|
||||
|
||||
**IMPORTANT**: Do NOT guess or auto-select a change. Always let the user choose.
|
||||
|
||||
2. **Check current status**
|
||||
```bash
|
||||
openspec status --change "<name>" --json
|
||||
```
|
||||
Parse the JSON to understand current state. The response includes:
|
||||
- `schemaName`: The workflow schema being used (e.g., "spec-driven")
|
||||
- `artifacts`: Array of artifacts with their status ("done", "ready", "blocked")
|
||||
- `isComplete`: Boolean indicating if all artifacts are complete
|
||||
|
||||
3. **Act based on status**:
|
||||
|
||||
---
|
||||
|
||||
**If all artifacts are complete (`isComplete: true`)**:
|
||||
- Congratulate the user
|
||||
- Show final status including the schema used
|
||||
- Suggest: "All artifacts created! You can now implement this change or archive it."
|
||||
- STOP
|
||||
|
||||
---
|
||||
|
||||
**If artifacts are ready to create** (status shows artifacts with `status: "ready"`):
|
||||
- Pick the FIRST artifact with `status: "ready"` from the status output
|
||||
- Get its instructions:
|
||||
```bash
|
||||
openspec instructions <artifact-id> --change "<name>" --json
|
||||
```
|
||||
- Parse the JSON. The key fields are:
|
||||
- `context`: Project background (constraints for you - do NOT include in output)
|
||||
- `rules`: Artifact-specific rules (constraints for you - do NOT include in output)
|
||||
- `template`: The structure to use for your output file
|
||||
- `instruction`: Schema-specific guidance
|
||||
- `outputPath`: Where to write the artifact
|
||||
- `dependencies`: Completed artifacts to read for context
|
||||
- **Create the artifact file**:
|
||||
- Read any completed dependency files for context
|
||||
- Use `template` as the structure - fill in its sections
|
||||
- Apply `context` and `rules` as constraints when writing - but do NOT copy them into the file
|
||||
- Write to the output path specified in instructions
|
||||
- Show what was created and what's now unlocked
|
||||
- STOP after creating ONE artifact
|
||||
|
||||
---
|
||||
|
||||
**If no artifacts are ready (all blocked)**:
|
||||
- This shouldn't happen with a valid schema
|
||||
- Show status and suggest checking for issues
|
||||
|
||||
4. **After creating an artifact, show progress**
|
||||
```bash
|
||||
openspec status --change "<name>"
|
||||
```
|
||||
|
||||
**Output**
|
||||
|
||||
After each invocation, show:
|
||||
- Which artifact was created
|
||||
- Schema workflow being used
|
||||
- Current progress (N/M complete)
|
||||
- What artifacts are now unlocked
|
||||
- Prompt: "Want to continue? Just ask me to continue or tell me what to do next."
|
||||
|
||||
**Artifact Creation Guidelines**
|
||||
|
||||
The artifact types and their purpose depend on the schema. Use the `instruction` field from the instructions output to understand what to create.
|
||||
|
||||
Common artifact patterns:
|
||||
|
||||
**spec-driven schema** (proposal → specs → design → tasks):
|
||||
- **proposal.md**: Ask user about the change if not clear. Fill in Why, What Changes, Capabilities, Impact.
|
||||
- The Capabilities section is critical - each capability listed will need a spec file.
|
||||
- **specs/<capability>/spec.md**: Create one spec per capability listed in the proposal's Capabilities section (use the capability name, not the change name).
|
||||
- **design.md**: Document technical decisions, architecture, and implementation approach.
|
||||
- **tasks.md**: Break down implementation into checkboxed tasks.
|
||||
|
||||
For other schemas, follow the `instruction` field from the CLI output.
|
||||
|
||||
**Guardrails**
|
||||
- Create ONE artifact per invocation
|
||||
- Always read dependency artifacts before creating a new one
|
||||
- Never skip artifacts or create out of order
|
||||
- If context is unclear, ask the user before creating
|
||||
- Verify the artifact file exists after writing before marking progress
|
||||
- Use the schema's artifact sequence, don't assume specific artifact names
|
||||
- **IMPORTANT**: `context` and `rules` are constraints for YOU, not content for the file
|
||||
- Do NOT copy `<context>`, `<rules>`, `<project_context>` blocks into the artifact
|
||||
- These guide what you write, but should never appear in the output
|
||||
|
|
@ -0,0 +1,101 @@
|
|||
---
|
||||
name: openspec-ff-change
|
||||
description: Fast-forward through OpenSpec artifact creation. Use when the user wants to quickly create all artifacts needed for implementation without stepping through each one individually.
|
||||
license: MIT
|
||||
compatibility: Requires openspec CLI.
|
||||
metadata:
|
||||
author: openspec
|
||||
version: "1.0"
|
||||
generatedBy: "1.2.0"
|
||||
---
|
||||
|
||||
Fast-forward through artifact creation - generate everything needed to start implementation in one go.
|
||||
|
||||
**Input**: The user's request should include a change name (kebab-case) OR a description of what they want to build.
|
||||
|
||||
**Steps**
|
||||
|
||||
1. **If no clear input provided, ask what they want to build**
|
||||
|
||||
Use the **AskUserQuestion tool** (open-ended, no preset options) to ask:
|
||||
> "What change do you want to work on? Describe what you want to build or fix."
|
||||
|
||||
From their description, derive a kebab-case name (e.g., "add user authentication" → `add-user-auth`).
|
||||
|
||||
**IMPORTANT**: Do NOT proceed without understanding what the user wants to build.
|
||||
|
||||
2. **Create the change directory**
|
||||
```bash
|
||||
openspec new change "<name>"
|
||||
```
|
||||
This creates a scaffolded change at `openspec/changes/<name>/`.
|
||||
|
||||
3. **Get the artifact build order**
|
||||
```bash
|
||||
openspec status --change "<name>" --json
|
||||
```
|
||||
Parse the JSON to get:
|
||||
- `applyRequires`: array of artifact IDs needed before implementation (e.g., `["tasks"]`)
|
||||
- `artifacts`: list of all artifacts with their status and dependencies
|
||||
|
||||
4. **Create artifacts in sequence until apply-ready**
|
||||
|
||||
Use the **TodoWrite tool** to track progress through the artifacts.
|
||||
|
||||
Loop through artifacts in dependency order (artifacts with no pending dependencies first):
|
||||
|
||||
a. **For each artifact that is `ready` (dependencies satisfied)**:
|
||||
- Get instructions:
|
||||
```bash
|
||||
openspec instructions <artifact-id> --change "<name>" --json
|
||||
```
|
||||
- The instructions JSON includes:
|
||||
- `context`: Project background (constraints for you - do NOT include in output)
|
||||
- `rules`: Artifact-specific rules (constraints for you - do NOT include in output)
|
||||
- `template`: The structure to use for your output file
|
||||
- `instruction`: Schema-specific guidance for this artifact type
|
||||
- `outputPath`: Where to write the artifact
|
||||
- `dependencies`: Completed artifacts to read for context
|
||||
- Read any completed dependency files for context
|
||||
- Create the artifact file using `template` as the structure
|
||||
- Apply `context` and `rules` as constraints - but do NOT copy them into the file
|
||||
- Show brief progress: "✓ Created <artifact-id>"
|
||||
|
||||
b. **Continue until all `applyRequires` artifacts are complete**
|
||||
- After creating each artifact, re-run `openspec status --change "<name>" --json`
|
||||
- Check if every artifact ID in `applyRequires` has `status: "done"` in the artifacts array
|
||||
- Stop when all `applyRequires` artifacts are done
|
||||
|
||||
c. **If an artifact requires user input** (unclear context):
|
||||
- Use **AskUserQuestion tool** to clarify
|
||||
- Then continue with creation
|
||||
|
||||
5. **Show final status**
|
||||
```bash
|
||||
openspec status --change "<name>"
|
||||
```
|
||||
|
||||
**Output**
|
||||
|
||||
After completing all artifacts, summarize:
|
||||
- Change name and location
|
||||
- List of artifacts created with brief descriptions
|
||||
- What's ready: "All artifacts created! Ready for implementation."
|
||||
- Prompt: "Run `/opsx:apply` or ask me to implement to start working on the tasks."
|
||||
|
||||
**Artifact Creation Guidelines**
|
||||
|
||||
- Follow the `instruction` field from `openspec instructions` for each artifact type
|
||||
- The schema defines what each artifact should contain - follow it
|
||||
- Read dependency artifacts for context before creating new ones
|
||||
- Use `template` as the structure for your output file - fill in its sections
|
||||
- **IMPORTANT**: `context` and `rules` are constraints for YOU, not content for the file
|
||||
- Do NOT copy `<context>`, `<rules>`, `<project_context>` blocks into the artifact
|
||||
- These guide what you write, but should never appear in the output
|
||||
|
||||
**Guardrails**
|
||||
- Create ALL artifacts needed for implementation (as defined by schema's `apply.requires`)
|
||||
- Always read dependency artifacts before creating a new one
|
||||
- If context is critically unclear, ask the user - but prefer making reasonable decisions to keep momentum
|
||||
- If a change with that name already exists, suggest continuing that change instead
|
||||
- Verify each artifact file exists after writing before proceeding to next
|
||||
|
|
@ -0,0 +1,74 @@
|
|||
---
|
||||
name: openspec-new-change
|
||||
description: Start a new OpenSpec change using the experimental artifact workflow. Use when the user wants to create a new feature, fix, or modification with a structured step-by-step approach.
|
||||
license: MIT
|
||||
compatibility: Requires openspec CLI.
|
||||
metadata:
|
||||
author: openspec
|
||||
version: "1.0"
|
||||
generatedBy: "1.2.0"
|
||||
---
|
||||
|
||||
Start a new change using the experimental artifact-driven approach.
|
||||
|
||||
**Input**: The user's request should include a change name (kebab-case) OR a description of what they want to build.
|
||||
|
||||
**Steps**
|
||||
|
||||
1. **If no clear input provided, ask what they want to build**
|
||||
|
||||
Use the **AskUserQuestion tool** (open-ended, no preset options) to ask:
|
||||
> "What change do you want to work on? Describe what you want to build or fix."
|
||||
|
||||
From their description, derive a kebab-case name (e.g., "add user authentication" → `add-user-auth`).
|
||||
|
||||
**IMPORTANT**: Do NOT proceed without understanding what the user wants to build.
|
||||
|
||||
2. **Determine the workflow schema**
|
||||
|
||||
Use the default schema (omit `--schema`) unless the user explicitly requests a different workflow.
|
||||
|
||||
**Use a different schema only if the user mentions:**
|
||||
- A specific schema name → use `--schema <name>`
|
||||
- "show workflows" or "what workflows" → run `openspec schemas --json` and let them choose
|
||||
|
||||
**Otherwise**: Omit `--schema` to use the default.
|
||||
|
||||
3. **Create the change directory**
|
||||
```bash
|
||||
openspec new change "<name>"
|
||||
```
|
||||
Add `--schema <name>` only if the user requested a specific workflow.
|
||||
This creates a scaffolded change at `openspec/changes/<name>/` with the selected schema.
|
||||
|
||||
4. **Show the artifact status**
|
||||
```bash
|
||||
openspec status --change "<name>"
|
||||
```
|
||||
This shows which artifacts need to be created and which are ready (dependencies satisfied).
|
||||
|
||||
5. **Get instructions for the first artifact**
|
||||
The first artifact depends on the schema (e.g., `proposal` for spec-driven).
|
||||
Check the status output to find the first artifact with status "ready".
|
||||
```bash
|
||||
openspec instructions <first-artifact-id> --change "<name>"
|
||||
```
|
||||
This outputs the template and context for creating the first artifact.
|
||||
|
||||
6. **STOP and wait for user direction**
|
||||
|
||||
**Output**
|
||||
|
||||
After completing the steps, summarize:
|
||||
- Change name and location
|
||||
- Schema/workflow being used and its artifact sequence
|
||||
- Current status (0/N artifacts complete)
|
||||
- The template for the first artifact
|
||||
- Prompt: "Ready to create the first artifact? Just describe what this change is about and I'll draft it, or ask me to continue."
|
||||
|
||||
**Guardrails**
|
||||
- Do NOT create any artifacts yet - just show the instructions
|
||||
- Do NOT advance beyond showing the first artifact template
|
||||
- If the name is invalid (not kebab-case), ask for a valid name
|
||||
- If a change with that name already exists, suggest continuing that change instead
|
||||
- Pass --schema if using a non-default workflow
|
||||
|
|
@ -0,0 +1,554 @@
|
|||
---
|
||||
name: openspec-onboard
|
||||
description: Guided onboarding for OpenSpec - walk through a complete workflow cycle with narration and real codebase work.
|
||||
license: MIT
|
||||
compatibility: Requires openspec CLI.
|
||||
metadata:
|
||||
author: openspec
|
||||
version: "1.0"
|
||||
generatedBy: "1.2.0"
|
||||
---
|
||||
|
||||
Guide the user through their first complete OpenSpec workflow cycle. This is a teaching experience—you'll do real work in their codebase while explaining each step.
|
||||
|
||||
---
|
||||
|
||||
## Preflight
|
||||
|
||||
Before starting, check if the OpenSpec CLI is installed:
|
||||
|
||||
```bash
|
||||
# Unix/macOS
|
||||
openspec --version 2>&1 || echo "CLI_NOT_INSTALLED"
|
||||
# Windows (PowerShell)
|
||||
# if (Get-Command openspec -ErrorAction SilentlyContinue) { openspec --version } else { echo "CLI_NOT_INSTALLED" }
|
||||
```
|
||||
|
||||
**If CLI not installed:**
|
||||
> OpenSpec CLI is not installed. Install it first, then come back to `/opsx:onboard`.
|
||||
|
||||
Stop here if not installed.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Welcome
|
||||
|
||||
Display:
|
||||
|
||||
```
|
||||
## Welcome to OpenSpec!
|
||||
|
||||
I'll walk you through a complete change cycle—from idea to implementation—using a real task in your codebase. Along the way, you'll learn the workflow by doing it.
|
||||
|
||||
**What we'll do:**
|
||||
1. Pick a small, real task in your codebase
|
||||
2. Explore the problem briefly
|
||||
3. Create a change (the container for our work)
|
||||
4. Build the artifacts: proposal → specs → design → tasks
|
||||
5. Implement the tasks
|
||||
6. Archive the completed change
|
||||
|
||||
**Time:** ~15-20 minutes
|
||||
|
||||
Let's start by finding something to work on.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Task Selection
|
||||
|
||||
### Codebase Analysis
|
||||
|
||||
Scan the codebase for small improvement opportunities. Look for:
|
||||
|
||||
1. **TODO/FIXME comments** - Search for `TODO`, `FIXME`, `HACK`, `XXX` in code files
|
||||
2. **Missing error handling** - `catch` blocks that swallow errors, risky operations without try-catch
|
||||
3. **Functions without tests** - Cross-reference `src/` with test directories
|
||||
4. **Type issues** - `any` types in TypeScript files (`: any`, `as any`)
|
||||
5. **Debug artifacts** - `console.log`, `console.debug`, `debugger` statements in non-debug code
|
||||
6. **Missing validation** - User input handlers without validation
|
||||
|
||||
Also check recent git activity:
|
||||
```bash
|
||||
# Unix/macOS
|
||||
git log --oneline -10 2>/dev/null || echo "No git history"
|
||||
# Windows (PowerShell)
|
||||
# git log --oneline -10 2>$null; if ($LASTEXITCODE -ne 0) { echo "No git history" }
|
||||
```
|
||||
|
||||
### Present Suggestions
|
||||
|
||||
From your analysis, present 3-4 specific suggestions:
|
||||
|
||||
```
|
||||
## Task Suggestions
|
||||
|
||||
Based on scanning your codebase, here are some good starter tasks:
|
||||
|
||||
**1. [Most promising task]**
|
||||
Location: `src/path/to/file.ts:42`
|
||||
Scope: ~1-2 files, ~20-30 lines
|
||||
Why it's good: [brief reason]
|
||||
|
||||
**2. [Second task]**
|
||||
Location: `src/another/file.ts`
|
||||
Scope: ~1 file, ~15 lines
|
||||
Why it's good: [brief reason]
|
||||
|
||||
**3. [Third task]**
|
||||
Location: [location]
|
||||
Scope: [estimate]
|
||||
Why it's good: [brief reason]
|
||||
|
||||
**4. Something else?**
|
||||
Tell me what you'd like to work on.
|
||||
|
||||
Which task interests you? (Pick a number or describe your own)
|
||||
```
|
||||
|
||||
**If nothing found:** Fall back to asking what the user wants to build:
|
||||
> I didn't find obvious quick wins in your codebase. What's something small you've been meaning to add or fix?
|
||||
|
||||
### Scope Guardrail
|
||||
|
||||
If the user picks or describes something too large (major feature, multi-day work):
|
||||
|
||||
```
|
||||
That's a valuable task, but it's probably larger than ideal for your first OpenSpec run-through.
|
||||
|
||||
For learning the workflow, smaller is better—it lets you see the full cycle without getting stuck in implementation details.
|
||||
|
||||
**Options:**
|
||||
1. **Slice it smaller** - What's the smallest useful piece of [their task]? Maybe just [specific slice]?
|
||||
2. **Pick something else** - One of the other suggestions, or a different small task?
|
||||
3. **Do it anyway** - If you really want to tackle this, we can. Just know it'll take longer.
|
||||
|
||||
What would you prefer?
|
||||
```
|
||||
|
||||
Let the user override if they insist—this is a soft guardrail.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Explore Demo
|
||||
|
||||
Once a task is selected, briefly demonstrate explore mode:
|
||||
|
||||
```
|
||||
Before we create a change, let me quickly show you **explore mode**—it's how you think through problems before committing to a direction.
|
||||
```
|
||||
|
||||
Spend 1-2 minutes investigating the relevant code:
|
||||
- Read the file(s) involved
|
||||
- Draw a quick ASCII diagram if it helps
|
||||
- Note any considerations
|
||||
|
||||
```
|
||||
## Quick Exploration
|
||||
|
||||
[Your brief analysis—what you found, any considerations]
|
||||
|
||||
┌─────────────────────────────────────────┐
|
||||
│ [Optional: ASCII diagram if helpful] │
|
||||
└─────────────────────────────────────────┘
|
||||
|
||||
Explore mode (`/opsx:explore`) is for this kind of thinking—investigating before implementing. You can use it anytime you need to think through a problem.
|
||||
|
||||
Now let's create a change to hold our work.
|
||||
```
|
||||
|
||||
**PAUSE** - Wait for user acknowledgment before proceeding.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: Create the Change
|
||||
|
||||
**EXPLAIN:**
|
||||
```
|
||||
## Creating a Change
|
||||
|
||||
A "change" in OpenSpec is a container for all the thinking and planning around a piece of work. It lives in `openspec/changes/<name>/` and holds your artifacts—proposal, specs, design, tasks.
|
||||
|
||||
Let me create one for our task.
|
||||
```
|
||||
|
||||
**DO:** Create the change with a derived kebab-case name:
|
||||
```bash
|
||||
openspec new change "<derived-name>"
|
||||
```
|
||||
|
||||
**SHOW:**
|
||||
```
|
||||
Created: `openspec/changes/<name>/`
|
||||
|
||||
The folder structure:
|
||||
```
|
||||
openspec/changes/<name>/
|
||||
├── proposal.md ← Why we're doing this (empty, we'll fill it)
|
||||
├── design.md ← How we'll build it (empty)
|
||||
├── specs/ ← Detailed requirements (empty)
|
||||
└── tasks.md ← Implementation checklist (empty)
|
||||
```
|
||||
|
||||
Now let's fill in the first artifact—the proposal.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: Proposal
|
||||
|
||||
**EXPLAIN:**
|
||||
```
|
||||
## The Proposal
|
||||
|
||||
The proposal captures **why** we're making this change and **what** it involves at a high level. It's the "elevator pitch" for the work.
|
||||
|
||||
I'll draft one based on our task.
|
||||
```
|
||||
|
||||
**DO:** Draft the proposal content (don't save yet):
|
||||
|
||||
```
|
||||
Here's a draft proposal:
|
||||
|
||||
---
|
||||
|
||||
## Why
|
||||
|
||||
[1-2 sentences explaining the problem/opportunity]
|
||||
|
||||
## What Changes
|
||||
|
||||
[Bullet points of what will be different]
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
- `<capability-name>`: [brief description]
|
||||
|
||||
### Modified Capabilities
|
||||
<!-- If modifying existing behavior -->
|
||||
|
||||
## Impact
|
||||
|
||||
- `src/path/to/file.ts`: [what changes]
|
||||
- [other files if applicable]
|
||||
|
||||
---
|
||||
|
||||
Does this capture the intent? I can adjust before we save it.
|
||||
```
|
||||
|
||||
**PAUSE** - Wait for user approval/feedback.
|
||||
|
||||
After approval, save the proposal:
|
||||
```bash
|
||||
openspec instructions proposal --change "<name>" --json
|
||||
```
|
||||
Then write the content to `openspec/changes/<name>/proposal.md`.
|
||||
|
||||
```
|
||||
Proposal saved. This is your "why" document—you can always come back and refine it as understanding evolves.
|
||||
|
||||
Next up: specs.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: Specs
|
||||
|
||||
**EXPLAIN:**
|
||||
```
|
||||
## Specs
|
||||
|
||||
Specs define **what** we're building in precise, testable terms. They use a requirement/scenario format that makes expected behavior crystal clear.
|
||||
|
||||
For a small task like this, we might only need one spec file.
|
||||
```
|
||||
|
||||
**DO:** Create the spec file:
|
||||
```bash
|
||||
# Unix/macOS
|
||||
mkdir -p openspec/changes/<name>/specs/<capability-name>
|
||||
# Windows (PowerShell)
|
||||
# New-Item -ItemType Directory -Force -Path "openspec/changes/<name>/specs/<capability-name>"
|
||||
```
|
||||
|
||||
Draft the spec content:
|
||||
|
||||
```
|
||||
Here's the spec:
|
||||
|
||||
---
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: <Name>
|
||||
|
||||
<Description of what the system should do>
|
||||
|
||||
#### Scenario: <Scenario name>
|
||||
|
||||
- **WHEN** <trigger condition>
|
||||
- **THEN** <expected outcome>
|
||||
- **AND** <additional outcome if needed>
|
||||
|
||||
---
|
||||
|
||||
This format—WHEN/THEN/AND—makes requirements testable. You can literally read them as test cases.
|
||||
```
|
||||
|
||||
Save to `openspec/changes/<name>/specs/<capability>/spec.md`.
|
||||
|
||||
---
|
||||
|
||||
## Phase 7: Design
|
||||
|
||||
**EXPLAIN:**
|
||||
```
|
||||
## Design
|
||||
|
||||
The design captures **how** we'll build it—technical decisions, tradeoffs, approach.
|
||||
|
||||
For small changes, this might be brief. That's fine—not every change needs deep design discussion.
|
||||
```
|
||||
|
||||
**DO:** Draft design.md:
|
||||
|
||||
```
|
||||
Here's the design:
|
||||
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
[Brief context about the current state]
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- [What we're trying to achieve]
|
||||
|
||||
**Non-Goals:**
|
||||
- [What's explicitly out of scope]
|
||||
|
||||
## Decisions
|
||||
|
||||
### Decision 1: [Key decision]
|
||||
|
||||
[Explanation of approach and rationale]
|
||||
|
||||
---
|
||||
|
||||
For a small task, this captures the key decisions without over-engineering.
|
||||
```
|
||||
|
||||
Save to `openspec/changes/<name>/design.md`.
|
||||
|
||||
---
|
||||
|
||||
## Phase 8: Tasks
|
||||
|
||||
**EXPLAIN:**
|
||||
```
|
||||
## Tasks
|
||||
|
||||
Finally, we break the work into implementation tasks—checkboxes that drive the apply phase.
|
||||
|
||||
These should be small, clear, and in logical order.
|
||||
```
|
||||
|
||||
**DO:** Generate tasks based on specs and design:
|
||||
|
||||
```
|
||||
Here are the implementation tasks:
|
||||
|
||||
---
|
||||
|
||||
## 1. [Category or file]
|
||||
|
||||
- [ ] 1.1 [Specific task]
|
||||
- [ ] 1.2 [Specific task]
|
||||
|
||||
## 2. Verify
|
||||
|
||||
- [ ] 2.1 [Verification step]
|
||||
|
||||
---
|
||||
|
||||
Each checkbox becomes a unit of work in the apply phase. Ready to implement?
|
||||
```
|
||||
|
||||
**PAUSE** - Wait for user to confirm they're ready to implement.
|
||||
|
||||
Save to `openspec/changes/<name>/tasks.md`.
|
||||
|
||||
---
|
||||
|
||||
## Phase 9: Apply (Implementation)
|
||||
|
||||
**EXPLAIN:**
|
||||
```
|
||||
## Implementation
|
||||
|
||||
Now we implement each task, checking them off as we go. I'll announce each one and occasionally note how the specs/design informed the approach.
|
||||
```
|
||||
|
||||
**DO:** For each task:
|
||||
|
||||
1. Announce: "Working on task N: [description]"
|
||||
2. Implement the change in the codebase
|
||||
3. Reference specs/design naturally: "The spec says X, so I'm doing Y"
|
||||
4. Mark complete in tasks.md: `- [ ]` → `- [x]`
|
||||
5. Brief status: "✓ Task N complete"
|
||||
|
||||
Keep narration light—don't over-explain every line of code.
|
||||
|
||||
After all tasks:
|
||||
|
||||
```
|
||||
## Implementation Complete
|
||||
|
||||
All tasks done:
|
||||
- [x] Task 1
|
||||
- [x] Task 2
|
||||
- [x] ...
|
||||
|
||||
The change is implemented! One more step—let's archive it.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 10: Archive
|
||||
|
||||
**EXPLAIN:**
|
||||
```
|
||||
## Archiving
|
||||
|
||||
When a change is complete, we archive it. This moves it from `openspec/changes/` to `openspec/changes/archive/YYYY-MM-DD-<name>/`.
|
||||
|
||||
Archived changes become your project's decision history—you can always find them later to understand why something was built a certain way.
|
||||
```
|
||||
|
||||
**DO:**
|
||||
```bash
|
||||
openspec archive "<name>"
|
||||
```
|
||||
|
||||
**SHOW:**
|
||||
```
|
||||
Archived to: `openspec/changes/archive/YYYY-MM-DD-<name>/`
|
||||
|
||||
The change is now part of your project's history. The code is in your codebase, the decision record is preserved.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 11: Recap & Next Steps
|
||||
|
||||
```
|
||||
## Congratulations!
|
||||
|
||||
You just completed a full OpenSpec cycle:
|
||||
|
||||
1. **Explore** - Thought through the problem
|
||||
2. **New** - Created a change container
|
||||
3. **Proposal** - Captured WHY
|
||||
4. **Specs** - Defined WHAT in detail
|
||||
5. **Design** - Decided HOW
|
||||
6. **Tasks** - Broke it into steps
|
||||
7. **Apply** - Implemented the work
|
||||
8. **Archive** - Preserved the record
|
||||
|
||||
This same rhythm works for any size change—a small fix or a major feature.
|
||||
|
||||
---
|
||||
|
||||
## Command Reference
|
||||
|
||||
**Core workflow:**
|
||||
|
||||
| Command | What it does |
|
||||
|---------|--------------|
|
||||
| `/opsx:propose` | Create a change and generate all artifacts |
|
||||
| `/opsx:explore` | Think through problems before/during work |
|
||||
| `/opsx:apply` | Implement tasks from a change |
|
||||
| `/opsx:archive` | Archive a completed change |
|
||||
|
||||
**Additional commands:**
|
||||
|
||||
| Command | What it does |
|
||||
|---------|--------------|
|
||||
| `/opsx:new` | Start a new change, step through artifacts one at a time |
|
||||
| `/opsx:continue` | Continue working on an existing change |
|
||||
| `/opsx:ff` | Fast-forward: create all artifacts at once |
|
||||
| `/opsx:verify` | Verify implementation matches artifacts |
|
||||
|
||||
---
|
||||
|
||||
## What's Next?
|
||||
|
||||
Try `/opsx:propose` on something you actually want to build. You've got the rhythm now!
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Graceful Exit Handling
|
||||
|
||||
### User wants to stop mid-way
|
||||
|
||||
If the user says they need to stop, want to pause, or seem disengaged:
|
||||
|
||||
```
|
||||
No problem! Your change is saved at `openspec/changes/<name>/`.
|
||||
|
||||
To pick up where we left off later:
|
||||
- `/opsx:continue <name>` - Resume artifact creation
|
||||
- `/opsx:apply <name>` - Jump to implementation (if tasks exist)
|
||||
|
||||
The work won't be lost. Come back whenever you're ready.
|
||||
```
|
||||
|
||||
Exit gracefully without pressure.
|
||||
|
||||
### User just wants command reference
|
||||
|
||||
If the user says they just want to see the commands or skip the tutorial:
|
||||
|
||||
```
|
||||
## OpenSpec Quick Reference
|
||||
|
||||
**Core workflow:**
|
||||
|
||||
| Command | What it does |
|
||||
|---------|--------------|
|
||||
| `/opsx:propose <name>` | Create a change and generate all artifacts |
|
||||
| `/opsx:explore` | Think through problems (no code changes) |
|
||||
| `/opsx:apply <name>` | Implement tasks |
|
||||
| `/opsx:archive <name>` | Archive when done |
|
||||
|
||||
**Additional commands:**
|
||||
|
||||
| Command | What it does |
|
||||
|---------|--------------|
|
||||
| `/opsx:new <name>` | Start a new change, step by step |
|
||||
| `/opsx:continue <name>` | Continue an existing change |
|
||||
| `/opsx:ff <name>` | Fast-forward: all artifacts at once |
|
||||
| `/opsx:verify <name>` | Verify implementation |
|
||||
|
||||
Try `/opsx:propose` to start your first change.
|
||||
```
|
||||
|
||||
Exit gracefully.
|
||||
|
||||
---
|
||||
|
||||
## Guardrails
|
||||
|
||||
- **Follow the EXPLAIN → DO → SHOW → PAUSE pattern** at key transitions (after explore, after proposal draft, after tasks, after archive)
|
||||
- **Keep narration light** during implementation—teach without lecturing
|
||||
- **Don't skip phases** even if the change is small—the goal is teaching the workflow
|
||||
- **Pause for acknowledgment** at marked points, but don't over-pause
|
||||
- **Handle exits gracefully**—never pressure the user to continue
|
||||
- **Use real codebase tasks**—don't simulate or use fake examples
|
||||
- **Adjust scope gently**—guide toward smaller tasks but respect user choice
|
||||
|
|
@ -0,0 +1,138 @@
|
|||
---
|
||||
name: openspec-sync-specs
|
||||
description: Sync delta specs from a change to main specs. Use when the user wants to update main specs with changes from a delta spec, without archiving the change.
|
||||
license: MIT
|
||||
compatibility: Requires openspec CLI.
|
||||
metadata:
|
||||
author: openspec
|
||||
version: "1.0"
|
||||
generatedBy: "1.2.0"
|
||||
---
|
||||
|
||||
Sync delta specs from a change to main specs.
|
||||
|
||||
This is an **agent-driven** operation - you will read delta specs and directly edit main specs to apply the changes. This allows intelligent merging (e.g., adding a scenario without copying the entire requirement).
|
||||
|
||||
**Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
|
||||
|
||||
**Steps**
|
||||
|
||||
1. **If no change name provided, prompt for selection**
|
||||
|
||||
Run `openspec list --json` to get available changes. Use the **AskUserQuestion tool** to let the user select.
|
||||
|
||||
Show changes that have delta specs (under `specs/` directory).
|
||||
|
||||
**IMPORTANT**: Do NOT guess or auto-select a change. Always let the user choose.
|
||||
|
||||
2. **Find delta specs**
|
||||
|
||||
Look for delta spec files in `openspec/changes/<name>/specs/*/spec.md`.
|
||||
|
||||
Each delta spec file contains sections like:
|
||||
- `## ADDED Requirements` - New requirements to add
|
||||
- `## MODIFIED Requirements` - Changes to existing requirements
|
||||
- `## REMOVED Requirements` - Requirements to remove
|
||||
- `## RENAMED Requirements` - Requirements to rename (FROM:/TO: format)
|
||||
|
||||
If no delta specs found, inform user and stop.
|
||||
|
||||
3. **For each delta spec, apply changes to main specs**
|
||||
|
||||
For each capability with a delta spec at `openspec/changes/<name>/specs/<capability>/spec.md`:
|
||||
|
||||
a. **Read the delta spec** to understand the intended changes
|
||||
|
||||
b. **Read the main spec** at `openspec/specs/<capability>/spec.md` (may not exist yet)
|
||||
|
||||
c. **Apply changes intelligently**:
|
||||
|
||||
**ADDED Requirements:**
|
||||
- If requirement doesn't exist in main spec → add it
|
||||
- If requirement already exists → update it to match (treat as implicit MODIFIED)
|
||||
|
||||
**MODIFIED Requirements:**
|
||||
- Find the requirement in main spec
|
||||
- Apply the changes - this can be:
|
||||
- Adding new scenarios (don't need to copy existing ones)
|
||||
- Modifying existing scenarios
|
||||
- Changing the requirement description
|
||||
- Preserve scenarios/content not mentioned in the delta
|
||||
|
||||
**REMOVED Requirements:**
|
||||
- Remove the entire requirement block from main spec
|
||||
|
||||
**RENAMED Requirements:**
|
||||
- Find the FROM requirement, rename to TO
|
||||
|
||||
d. **Create new main spec** if capability doesn't exist yet:
|
||||
- Create `openspec/specs/<capability>/spec.md`
|
||||
- Add Purpose section (can be brief, mark as TBD)
|
||||
- Add Requirements section with the ADDED requirements
|
||||
|
||||
4. **Show summary**
|
||||
|
||||
After applying all changes, summarize:
|
||||
- Which capabilities were updated
|
||||
- What changes were made (requirements added/modified/removed/renamed)
|
||||
|
||||
**Delta Spec Format Reference**
|
||||
|
||||
```markdown
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: New Feature
|
||||
The system SHALL do something new.
|
||||
|
||||
#### Scenario: Basic case
|
||||
- **WHEN** user does X
|
||||
- **THEN** system does Y
|
||||
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Existing Feature
|
||||
#### Scenario: New scenario to add
|
||||
- **WHEN** user does A
|
||||
- **THEN** system does B
|
||||
|
||||
## REMOVED Requirements
|
||||
|
||||
### Requirement: Deprecated Feature
|
||||
|
||||
## RENAMED Requirements
|
||||
|
||||
- FROM: `### Requirement: Old Name`
|
||||
- TO: `### Requirement: New Name`
|
||||
```
|
||||
|
||||
**Key Principle: Intelligent Merging**
|
||||
|
||||
Unlike programmatic merging, you can apply **partial updates**:
|
||||
- To add a scenario, just include that scenario under MODIFIED - don't copy existing scenarios
|
||||
- The delta represents *intent*, not a wholesale replacement
|
||||
- Use your judgment to merge changes sensibly
|
||||
|
||||
**Output On Success**
|
||||
|
||||
```
|
||||
## Specs Synced: <change-name>
|
||||
|
||||
Updated main specs:
|
||||
|
||||
**<capability-1>**:
|
||||
- Added requirement: "New Feature"
|
||||
- Modified requirement: "Existing Feature" (added 1 scenario)
|
||||
|
||||
**<capability-2>**:
|
||||
- Created new spec file
|
||||
- Added requirement: "Another Feature"
|
||||
|
||||
Main specs are now updated. The change remains active - archive when implementation is complete.
|
||||
```
|
||||
|
||||
**Guardrails**
|
||||
- Read both delta and main specs before making changes
|
||||
- Preserve existing content not mentioned in delta
|
||||
- If something is unclear, ask for clarification
|
||||
- Show what you're changing as you go
|
||||
- The operation should be idempotent - running twice should give same result
|
||||
|
|
@ -0,0 +1,168 @@
|
|||
---
|
||||
name: openspec-verify-change
|
||||
description: Verify implementation matches change artifacts. Use when the user wants to validate that implementation is complete, correct, and coherent before archiving.
|
||||
license: MIT
|
||||
compatibility: Requires openspec CLI.
|
||||
metadata:
|
||||
author: openspec
|
||||
version: "1.0"
|
||||
generatedBy: "1.2.0"
|
||||
---
|
||||
|
||||
Verify that an implementation matches the change artifacts (specs, tasks, design).
|
||||
|
||||
**Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
|
||||
|
||||
**Steps**
|
||||
|
||||
1. **If no change name provided, prompt for selection**
|
||||
|
||||
Run `openspec list --json` to get available changes. Use the **AskUserQuestion tool** to let the user select.
|
||||
|
||||
Show changes that have implementation tasks (tasks artifact exists).
|
||||
Include the schema used for each change if available.
|
||||
Mark changes with incomplete tasks as "(In Progress)".
|
||||
|
||||
**IMPORTANT**: Do NOT guess or auto-select a change. Always let the user choose.
|
||||
|
||||
2. **Check status to understand the schema**
|
||||
```bash
|
||||
openspec status --change "<name>" --json
|
||||
```
|
||||
Parse the JSON to understand:
|
||||
- `schemaName`: The workflow being used (e.g., "spec-driven")
|
||||
- Which artifacts exist for this change
|
||||
|
||||
3. **Get the change directory and load artifacts**
|
||||
|
||||
```bash
|
||||
openspec instructions apply --change "<name>" --json
|
||||
```
|
||||
|
||||
This returns the change directory and context files. Read all available artifacts from `contextFiles`.
|
||||
|
||||
4. **Initialize verification report structure**
|
||||
|
||||
Create a report structure with three dimensions:
|
||||
- **Completeness**: Track tasks and spec coverage
|
||||
- **Correctness**: Track requirement implementation and scenario coverage
|
||||
- **Coherence**: Track design adherence and pattern consistency
|
||||
|
||||
Each dimension can have CRITICAL, WARNING, or SUGGESTION issues.
|
||||
|
||||
5. **Verify Completeness**
|
||||
|
||||
**Task Completion**:
|
||||
- If tasks.md exists in contextFiles, read it
|
||||
- Parse checkboxes: `- [ ]` (incomplete) vs `- [x]` (complete)
|
||||
- Count complete vs total tasks
|
||||
- If incomplete tasks exist:
|
||||
- Add CRITICAL issue for each incomplete task
|
||||
- Recommendation: "Complete task: <description>" or "Mark as done if already implemented"
|
||||
|
||||
**Spec Coverage**:
|
||||
- If delta specs exist in `openspec/changes/<name>/specs/`:
|
||||
- Extract all requirements (marked with "### Requirement:")
|
||||
- For each requirement:
|
||||
- Search codebase for keywords related to the requirement
|
||||
- Assess if implementation likely exists
|
||||
- If requirements appear unimplemented:
|
||||
- Add CRITICAL issue: "Requirement not found: <requirement name>"
|
||||
- Recommendation: "Implement requirement X: <description>"
|
||||
|
||||
6. **Verify Correctness**
|
||||
|
||||
**Requirement Implementation Mapping**:
|
||||
- For each requirement from delta specs:
|
||||
- Search codebase for implementation evidence
|
||||
- If found, note file paths and line ranges
|
||||
- Assess if implementation matches requirement intent
|
||||
- If divergence detected:
|
||||
- Add WARNING: "Implementation may diverge from spec: <details>"
|
||||
- Recommendation: "Review <file>:<lines> against requirement X"
|
||||
|
||||
**Scenario Coverage**:
|
||||
- For each scenario in delta specs (marked with "#### Scenario:"):
|
||||
- Check if conditions are handled in code
|
||||
- Check if tests exist covering the scenario
|
||||
- If scenario appears uncovered:
|
||||
- Add WARNING: "Scenario not covered: <scenario name>"
|
||||
- Recommendation: "Add test or implementation for scenario: <description>"
|
||||
|
||||
7. **Verify Coherence**
|
||||
|
||||
**Design Adherence**:
|
||||
- If design.md exists in contextFiles:
|
||||
- Extract key decisions (look for sections like "Decision:", "Approach:", "Architecture:")
|
||||
- Verify implementation follows those decisions
|
||||
- If contradiction detected:
|
||||
- Add WARNING: "Design decision not followed: <decision>"
|
||||
- Recommendation: "Update implementation or revise design.md to match reality"
|
||||
- If no design.md: Skip design adherence check, note "No design.md to verify against"
|
||||
|
||||
**Code Pattern Consistency**:
|
||||
- Review new code for consistency with project patterns
|
||||
- Check file naming, directory structure, coding style
|
||||
- If significant deviations found:
|
||||
- Add SUGGESTION: "Code pattern deviation: <details>"
|
||||
- Recommendation: "Consider following project pattern: <example>"
|
||||
|
||||
8. **Generate Verification Report**
|
||||
|
||||
**Summary Scorecard**:
|
||||
```
|
||||
## Verification Report: <change-name>
|
||||
|
||||
### Summary
|
||||
| Dimension | Status |
|
||||
|--------------|------------------|
|
||||
| Completeness | X/Y tasks, N reqs|
|
||||
| Correctness | M/N reqs covered |
|
||||
| Coherence | Followed/Issues |
|
||||
```
|
||||
|
||||
**Issues by Priority**:
|
||||
|
||||
1. **CRITICAL** (Must fix before archive):
|
||||
- Incomplete tasks
|
||||
- Missing requirement implementations
|
||||
- Each with specific, actionable recommendation
|
||||
|
||||
2. **WARNING** (Should fix):
|
||||
- Spec/design divergences
|
||||
- Missing scenario coverage
|
||||
- Each with specific recommendation
|
||||
|
||||
3. **SUGGESTION** (Nice to fix):
|
||||
- Pattern inconsistencies
|
||||
- Minor improvements
|
||||
- Each with specific recommendation
|
||||
|
||||
**Final Assessment**:
|
||||
- If CRITICAL issues: "X critical issue(s) found. Fix before archiving."
|
||||
- If only warnings: "No critical issues. Y warning(s) to consider. Ready for archive (with noted improvements)."
|
||||
- If all clear: "All checks passed. Ready for archive."
|
||||
|
||||
**Verification Heuristics**
|
||||
|
||||
- **Completeness**: Focus on objective checklist items (checkboxes, requirements list)
|
||||
- **Correctness**: Use keyword search, file path analysis, reasonable inference - don't require perfect certainty
|
||||
- **Coherence**: Look for glaring inconsistencies, don't nitpick style
|
||||
- **False Positives**: When uncertain, prefer SUGGESTION over WARNING, WARNING over CRITICAL
|
||||
- **Actionability**: Every issue must have a specific recommendation with file/line references where applicable
|
||||
|
||||
**Graceful Degradation**
|
||||
|
||||
- If only tasks.md exists: verify task completion only, skip spec/design checks
|
||||
- If tasks + specs exist: verify completeness and correctness, skip design
|
||||
- If full artifacts: verify all three dimensions
|
||||
- Always note which checks were skipped and why
|
||||
|
||||
**Output Format**
|
||||
|
||||
Use clear markdown with:
|
||||
- Table for summary scorecard
|
||||
- Grouped lists for issues (CRITICAL/WARNING/SUGGESTION)
|
||||
- Code references in format: `file.ts:123`
|
||||
- Specific, actionable recommendations
|
||||
- No vague suggestions like "consider reviewing"
|
||||
|
|
@ -87,3 +87,6 @@ crashlytics-build.properties
|
|||
~$*.xlsx
|
||||
Assets/GameMain/Configs/ResourceBuilder.xml
|
||||
/.dotnet
|
||||
|
||||
/.dotnet-home
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,14 @@
|
|||
using System;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Network.NetworkApplication
|
||||
{
|
||||
public interface INetworkMessageDispatcher
|
||||
{
|
||||
int PendingCount { get; }
|
||||
|
||||
void Enqueue(Func<Task> workItem);
|
||||
|
||||
Task<int> DrainAsync(int maxItems = int.MaxValue);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
fileFormatVersion: 2
|
||||
guid: d26d19f5e4031fd4089d620dc62d5159
|
||||
guid: 688ae8436f5f43fa88382700ef9c5e58
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
using System;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Network.NetworkApplication
|
||||
{
|
||||
public sealed class ImmediateNetworkMessageDispatcher : INetworkMessageDispatcher
|
||||
{
|
||||
public int PendingCount => 0;
|
||||
|
||||
public void Enqueue(Func<Task> workItem)
|
||||
{
|
||||
if (workItem == null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(workItem));
|
||||
}
|
||||
|
||||
workItem().GetAwaiter().GetResult();
|
||||
}
|
||||
|
||||
public Task<int> DrainAsync(int maxItems = int.MaxValue)
|
||||
{
|
||||
if (maxItems <= 0)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(maxItems));
|
||||
}
|
||||
|
||||
return Task.FromResult(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
fileFormatVersion: 2
|
||||
guid: c1b99c76091f41299af26864a9a25fb0
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Network.NetworkApplication
|
||||
{
|
||||
public sealed class MainThreadNetworkDispatcher : INetworkMessageDispatcher
|
||||
{
|
||||
private readonly ConcurrentQueue<Func<Task>> pendingWork = new();
|
||||
|
||||
public int PendingCount => pendingWork.Count;
|
||||
|
||||
public void Enqueue(Func<Task> workItem)
|
||||
{
|
||||
if (workItem == null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(workItem));
|
||||
}
|
||||
|
||||
pendingWork.Enqueue(workItem);
|
||||
}
|
||||
|
||||
public async Task<int> DrainAsync(int maxItems = int.MaxValue)
|
||||
{
|
||||
if (maxItems <= 0)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(maxItems));
|
||||
}
|
||||
|
||||
var processed = 0;
|
||||
|
||||
while (processed < maxItems && pendingWork.TryDequeue(out var workItem))
|
||||
{
|
||||
await workItem();
|
||||
processed++;
|
||||
}
|
||||
|
||||
return processed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 3dc7b1ecbad541ea86a9d700dd5148e0
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
using System;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Net;
|
||||
using System.Threading.Tasks;
|
||||
|
|
@ -11,16 +11,22 @@ namespace Network.NetworkApplication
|
|||
public class MessageManager
|
||||
{
|
||||
private readonly ITransport transport;
|
||||
private readonly INetworkMessageDispatcher dispatcher;
|
||||
|
||||
private readonly Dictionary<MessageType, Func<byte[], IPEndPoint, Task>> handlers =
|
||||
new();
|
||||
|
||||
public MessageManager(ITransport transport)
|
||||
public MessageManager(ITransport transport, INetworkMessageDispatcher dispatcher)
|
||||
{
|
||||
this.transport = transport ?? throw new ArgumentNullException(nameof(transport));
|
||||
this.transport.OnReceive += OnTransportReceiveAsync;
|
||||
this.dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher));
|
||||
this.transport.OnReceive += OnTransportReceive;
|
||||
}
|
||||
|
||||
public INetworkMessageDispatcher Dispatcher => dispatcher;
|
||||
|
||||
public int PendingMessageCount => dispatcher.PendingCount;
|
||||
|
||||
public void RegisterHandler(MessageType type, IMessageHandler handler)
|
||||
{
|
||||
if (handler == null)
|
||||
|
|
@ -94,7 +100,12 @@ namespace Network.NetworkApplication
|
|||
transport.SendToAll(envelope.ToByteArray());
|
||||
}
|
||||
|
||||
private async void OnTransportReceiveAsync(byte[] data, IPEndPoint sender)
|
||||
public Task<int> DrainPendingMessagesAsync(int maxMessages = int.MaxValue)
|
||||
{
|
||||
return dispatcher.DrainAsync(maxMessages);
|
||||
}
|
||||
|
||||
private void OnTransportReceive(byte[] data, IPEndPoint sender)
|
||||
{
|
||||
try
|
||||
{
|
||||
|
|
@ -104,7 +115,8 @@ namespace Network.NetworkApplication
|
|||
|
||||
if (handlers.TryGetValue(type, out var handler))
|
||||
{
|
||||
await handler(envelope.Payload.ToByteArray(), sender);
|
||||
var payload = envelope.Payload.ToByteArray();
|
||||
dispatcher.Enqueue(() => DispatchAsync(handler, payload, sender, type));
|
||||
}
|
||||
else
|
||||
{
|
||||
|
|
@ -116,5 +128,21 @@ namespace Network.NetworkApplication
|
|||
Console.WriteLine($"[MessageManager] 消息处理错误:{ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task DispatchAsync(
|
||||
Func<byte[], IPEndPoint, Task> handler,
|
||||
byte[] payload,
|
||||
IPEndPoint sender,
|
||||
MessageType type)
|
||||
{
|
||||
try
|
||||
{
|
||||
await handler(payload, sender);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[MessageManager] Handler 执行错误:{type} -> {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,34 @@
|
|||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using Network.NetworkTransport;
|
||||
|
||||
namespace Network.NetworkApplication
|
||||
{
|
||||
public sealed class SharedNetworkRuntime
|
||||
{
|
||||
public SharedNetworkRuntime(ITransport transport, INetworkMessageDispatcher dispatcher)
|
||||
{
|
||||
Transport = transport ?? throw new ArgumentNullException(nameof(transport));
|
||||
MessageManager = new MessageManager(transport, dispatcher ?? throw new ArgumentNullException(nameof(dispatcher)));
|
||||
}
|
||||
|
||||
public ITransport Transport { get; }
|
||||
|
||||
public MessageManager MessageManager { get; }
|
||||
|
||||
public Task StartAsync()
|
||||
{
|
||||
return Transport.StartAsync();
|
||||
}
|
||||
|
||||
public void Stop()
|
||||
{
|
||||
Transport.Stop();
|
||||
}
|
||||
|
||||
public Task<int> DrainPendingMessagesAsync(int maxMessages = int.MaxValue)
|
||||
{
|
||||
return MessageManager.DrainPendingMessagesAsync(maxMessages);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 314c8d46e1ae4d9eb4914d0cac7bb628
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 9cf2571f026e4872b3c07033fd0c21a9
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using Network.NetworkApplication;
|
||||
using Network.NetworkTransport;
|
||||
|
||||
namespace Network.NetworkHost
|
||||
{
|
||||
public sealed class ServerNetworkHost
|
||||
{
|
||||
private readonly SharedNetworkRuntime runtime;
|
||||
|
||||
public ServerNetworkHost(ITransport transport, INetworkMessageDispatcher dispatcher = null)
|
||||
{
|
||||
runtime = new SharedNetworkRuntime(
|
||||
transport ?? throw new ArgumentNullException(nameof(transport)),
|
||||
dispatcher ?? new ImmediateNetworkMessageDispatcher());
|
||||
}
|
||||
|
||||
public MessageManager MessageManager => runtime.MessageManager;
|
||||
|
||||
public ITransport Transport => runtime.Transport;
|
||||
|
||||
public Task StartAsync()
|
||||
{
|
||||
return runtime.StartAsync();
|
||||
}
|
||||
|
||||
public void Stop()
|
||||
{
|
||||
runtime.Stop();
|
||||
}
|
||||
|
||||
public Task<int> DrainPendingMessagesAsync(int maxMessages = int.MaxValue)
|
||||
{
|
||||
return runtime.DrainPendingMessagesAsync(maxMessages);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 89f7ce53cdf54dc9ac44f14eaf11cf5d
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -1,158 +0,0 @@
|
|||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Network.NetworkTransport
|
||||
{
|
||||
public class ReliableUdpTransport : ITransport
|
||||
{
|
||||
private readonly UdpClient _client;
|
||||
private readonly IPEndPoint _defaultRemoteEndPoint;
|
||||
private readonly bool _isServer;
|
||||
|
||||
// Stage one keeps this class name for compatibility while collapsing it to plain UDP.
|
||||
private readonly ConcurrentDictionary<string, IPEndPoint> _knownRemoteEndPoints = new();
|
||||
|
||||
private volatile bool _isRunning;
|
||||
|
||||
public event Action<byte[], IPEndPoint> OnReceive;
|
||||
|
||||
private Task _receiveTask = Task.CompletedTask;
|
||||
|
||||
public ReliableUdpTransport(int listenPort)
|
||||
{
|
||||
_client = new UdpClient(listenPort);
|
||||
_isServer = true;
|
||||
Console.WriteLine($"[Transport] 服务端模式,监听端口: {listenPort}");
|
||||
}
|
||||
|
||||
public ReliableUdpTransport(string serverIP, int serverPort)
|
||||
{
|
||||
_client = new UdpClient(0);
|
||||
_defaultRemoteEndPoint = new IPEndPoint(IPAddress.Parse(serverIP), serverPort);
|
||||
_isServer = false;
|
||||
Console.WriteLine($"[Transport] 客户端模式,目标: {_defaultRemoteEndPoint}");
|
||||
}
|
||||
|
||||
public async Task StartAsync()
|
||||
{
|
||||
if (_isRunning)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_knownRemoteEndPoints.Clear();
|
||||
_isRunning = true;
|
||||
Console.WriteLine("[Transport] 传输层启动");
|
||||
_receiveTask = ReceiveLoop();
|
||||
await Task.Yield();
|
||||
}
|
||||
|
||||
public void Stop()
|
||||
{
|
||||
if (!_isRunning)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_isRunning = false;
|
||||
_client.Close();
|
||||
_knownRemoteEndPoints.Clear();
|
||||
Console.WriteLine("[Transport] 传输层停止");
|
||||
}
|
||||
|
||||
public void Send(byte[] data)
|
||||
{
|
||||
if (_defaultRemoteEndPoint == null)
|
||||
{
|
||||
throw new InvalidOperationException("Default remote endpoint is not configured.");
|
||||
}
|
||||
|
||||
SendTo(data, _defaultRemoteEndPoint);
|
||||
}
|
||||
|
||||
public void SendTo(byte[] data, IPEndPoint target)
|
||||
{
|
||||
if (data == null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(data));
|
||||
}
|
||||
|
||||
if (target == null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(target));
|
||||
}
|
||||
|
||||
EnsureRunning();
|
||||
RememberRemote(target);
|
||||
_client.Send(data, data.Length, target);
|
||||
Console.WriteLine($"[Transport] 发送数据到 {target}");
|
||||
}
|
||||
|
||||
public void SendToAll(byte[] data)
|
||||
{
|
||||
if (data == null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(data));
|
||||
}
|
||||
|
||||
EnsureRunning();
|
||||
|
||||
if (!_isServer)
|
||||
{
|
||||
throw new InvalidOperationException("SendToAll is only supported in server mode.");
|
||||
}
|
||||
|
||||
foreach (var remoteEndPoint in _knownRemoteEndPoints.Values)
|
||||
{
|
||||
_client.Send(data, data.Length, remoteEndPoint);
|
||||
Console.WriteLine($"[Transport] 广播数据到 {remoteEndPoint}");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ReceiveLoop()
|
||||
{
|
||||
while (_isRunning)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await _client.ReceiveAsync();
|
||||
RememberRemote(result.RemoteEndPoint);
|
||||
OnReceive?.Invoke(result.Buffer, result.RemoteEndPoint);
|
||||
}
|
||||
catch (ObjectDisposedException) when (!_isRunning)
|
||||
{
|
||||
return;
|
||||
}
|
||||
catch (SocketException) when (!_isRunning)
|
||||
{
|
||||
return;
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Console.WriteLine($"[Transport] 接收错误:{e.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void EnsureRunning()
|
||||
{
|
||||
if (!_isRunning)
|
||||
{
|
||||
throw new InvalidOperationException("Transport has not been started.");
|
||||
}
|
||||
}
|
||||
|
||||
private void RememberRemote(IPEndPoint remoteEndPoint)
|
||||
{
|
||||
if (remoteEndPoint == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_knownRemoteEndPoints[remoteEndPoint.ToString()] = remoteEndPoint;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
using System.Collections;
|
||||
using System.Collections;
|
||||
using System.Net;
|
||||
using System.Threading.Tasks;
|
||||
using Network.Defines;
|
||||
using Network.NetworkApplication;
|
||||
using Network.NetworkTransport;
|
||||
|
|
@ -8,11 +9,13 @@ using Vector3 = UnityEngine.Vector3;
|
|||
|
||||
public class NetworkManager : MonoBehaviour
|
||||
{
|
||||
private const int MaxNetworkMessagesPerFrame = 32;
|
||||
|
||||
public static NetworkManager Instance;
|
||||
private ITransport _transport;
|
||||
private MessageManager _messageManager;
|
||||
private SharedNetworkRuntime _networkRuntime;
|
||||
private IPEndPoint _serverPoint;
|
||||
private uint _sequence = 0;
|
||||
private Task _networkDrainTask = Task.CompletedTask;
|
||||
[SerializeField] private GameObject _wrongWindow;
|
||||
|
||||
private void Awake()
|
||||
|
|
@ -23,9 +26,11 @@ public class NetworkManager : MonoBehaviour
|
|||
|
||||
private IEnumerator InitNetwork()
|
||||
{
|
||||
_transport = new KcpTransport("127.0.0.1", 8080);
|
||||
var transport = new KcpTransport("127.0.0.1", 8080);
|
||||
var dispatcher = new MainThreadNetworkDispatcher();
|
||||
_networkRuntime = new SharedNetworkRuntime(transport, dispatcher);
|
||||
|
||||
var startTask = _transport.StartAsync();
|
||||
var startTask = _networkRuntime.StartAsync();
|
||||
yield return new WaitUntil(() => startTask.IsCompleted);
|
||||
|
||||
if (startTask.IsFaulted)
|
||||
|
|
@ -34,14 +39,33 @@ public class NetworkManager : MonoBehaviour
|
|||
yield break;
|
||||
}
|
||||
|
||||
_messageManager = new MessageManager(_transport);
|
||||
RegisterHandler();
|
||||
StartCoroutine(Heartbeat());
|
||||
}
|
||||
|
||||
private void Update()
|
||||
{
|
||||
if (_networkRuntime == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!_networkDrainTask.IsCompleted)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (_networkDrainTask.IsFaulted)
|
||||
{
|
||||
Debug.LogException(_networkDrainTask.Exception);
|
||||
}
|
||||
|
||||
_networkDrainTask = _networkRuntime.DrainPendingMessagesAsync(MaxNetworkMessagesPerFrame);
|
||||
}
|
||||
|
||||
private void OnDestroy()
|
||||
{
|
||||
_transport?.Stop();
|
||||
_networkRuntime?.Stop();
|
||||
if (Instance == this)
|
||||
{
|
||||
Instance = null;
|
||||
|
|
@ -55,7 +79,7 @@ public class NetworkManager : MonoBehaviour
|
|||
if (_serverPoint != null)
|
||||
{
|
||||
var heartbeat = new Heartbeat();
|
||||
_messageManager.SendMessage(heartbeat, MessageType.Heartbeat, _serverPoint);
|
||||
_networkRuntime.MessageManager.SendMessage(heartbeat, MessageType.Heartbeat, _serverPoint);
|
||||
}
|
||||
|
||||
yield return new WaitForSeconds(2.0f);
|
||||
|
|
@ -64,11 +88,11 @@ public class NetworkManager : MonoBehaviour
|
|||
|
||||
private void RegisterHandler()
|
||||
{
|
||||
_messageManager.RegisterHandler(MessageType.LoginResponse, HandleLoginResponse);
|
||||
_messageManager.RegisterHandler(MessageType.PlayerState, HandlePlayerState);
|
||||
_messageManager.RegisterHandler(MessageType.HeartbeatResponse, HandleHeartbeatResponse);
|
||||
_messageManager.RegisterHandler(MessageType.LogoutRequest, HandleLogoutRequest);
|
||||
_messageManager.RegisterHandler(MessageType.PlayerJoin, HandlePlayerJoin);
|
||||
_networkRuntime.MessageManager.RegisterHandler(MessageType.LoginResponse, HandleLoginResponse);
|
||||
_networkRuntime.MessageManager.RegisterHandler(MessageType.PlayerState, HandlePlayerState);
|
||||
_networkRuntime.MessageManager.RegisterHandler(MessageType.HeartbeatResponse, HandleHeartbeatResponse);
|
||||
_networkRuntime.MessageManager.RegisterHandler(MessageType.LogoutRequest, HandleLogoutRequest);
|
||||
_networkRuntime.MessageManager.RegisterHandler(MessageType.PlayerJoin, HandlePlayerJoin);
|
||||
}
|
||||
|
||||
private void HandleLoginResponse(byte[] data, IPEndPoint sender)
|
||||
|
|
@ -123,13 +147,13 @@ public class NetworkManager : MonoBehaviour
|
|||
PlayerId = playerId,
|
||||
Input = ProtoExtensions.ToProtoVector3(input)
|
||||
};
|
||||
_messageManager.SendMessage(message, MessageType.PlayerInput);
|
||||
_networkRuntime.MessageManager.SendMessage(message, MessageType.PlayerInput);
|
||||
Debug.Log($"PlayerMoveSeq: {_sequence++}");
|
||||
}
|
||||
|
||||
public void SendPlayerInput(PlayerInput message)
|
||||
{
|
||||
_messageManager.SendMessage(message, MessageType.PlayerInput);
|
||||
_networkRuntime.MessageManager.SendMessage(message, MessageType.PlayerInput);
|
||||
Debug.Log($"PlayerMoveSeq: {_sequence++}");
|
||||
}
|
||||
|
||||
|
|
@ -140,7 +164,7 @@ public class NetworkManager : MonoBehaviour
|
|||
PlayerId = playerId,
|
||||
Speed = speed
|
||||
};
|
||||
_messageManager.SendMessage(request, MessageType.LoginRequest);
|
||||
_networkRuntime.MessageManager.SendMessage(request, MessageType.LoginRequest);
|
||||
Debug.Log($"Sent login request to player {playerId}");
|
||||
}
|
||||
|
||||
|
|
@ -150,7 +174,7 @@ public class NetworkManager : MonoBehaviour
|
|||
{
|
||||
PlayerId = playerId
|
||||
};
|
||||
_messageManager.SendMessage(request, MessageType.LogoutRequest);
|
||||
_networkRuntime.MessageManager.SendMessage(request, MessageType.LogoutRequest);
|
||||
Debug.Log($"Sent logout request to player {playerId}");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
using System;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Net;
|
||||
using System.Threading.Tasks;
|
||||
using Google.Protobuf;
|
||||
|
|
@ -17,7 +18,7 @@ namespace Tests.EditMode.Network
|
|||
public void SendMessage_WithoutTarget_UsesDefaultSend()
|
||||
{
|
||||
var transport = new FakeTransport();
|
||||
var manager = new MessageManager(transport);
|
||||
var manager = new MessageManager(transport, new MainThreadNetworkDispatcher());
|
||||
var message = new Heartbeat();
|
||||
|
||||
manager.SendMessage(message, MessageType.Heartbeat);
|
||||
|
|
@ -35,7 +36,7 @@ namespace Tests.EditMode.Network
|
|||
public void SendMessage_WithTarget_UsesExplicitSend()
|
||||
{
|
||||
var transport = new FakeTransport();
|
||||
var manager = new MessageManager(transport);
|
||||
var manager = new MessageManager(transport, new MainThreadNetworkDispatcher());
|
||||
var message = new LoginRequest
|
||||
{
|
||||
PlayerId = "player-1",
|
||||
|
|
@ -57,7 +58,7 @@ namespace Tests.EditMode.Network
|
|||
public void BroadcastMessage_UsesBroadcastSend()
|
||||
{
|
||||
var transport = new FakeTransport();
|
||||
var manager = new MessageManager(transport);
|
||||
var manager = new MessageManager(transport, new MainThreadNetworkDispatcher());
|
||||
var message = new Heartbeat();
|
||||
|
||||
manager.BroadcastMessage(message, MessageType.Heartbeat);
|
||||
|
|
@ -71,10 +72,10 @@ namespace Tests.EditMode.Network
|
|||
}
|
||||
|
||||
[Test]
|
||||
public void Receive_ValidEnvelope_DispatchesRegisteredHandler()
|
||||
public void Receive_ValidEnvelope_IsDeferredUntilDrain()
|
||||
{
|
||||
var transport = new FakeTransport();
|
||||
var manager = new MessageManager(transport);
|
||||
var manager = new MessageManager(transport, new MainThreadNetworkDispatcher());
|
||||
var handled = false;
|
||||
IPEndPoint receivedSender = null;
|
||||
byte[] receivedPayload = null;
|
||||
|
|
@ -89,26 +90,33 @@ namespace Tests.EditMode.Network
|
|||
|
||||
transport.EmitReceive(BuildEnvelope(MessageType.Heartbeat, message), Sender);
|
||||
|
||||
Assert.That(handled, Is.False);
|
||||
Assert.That(manager.PendingMessageCount, Is.EqualTo(1));
|
||||
|
||||
manager.DrainPendingMessagesAsync().GetAwaiter().GetResult();
|
||||
|
||||
Assert.That(handled, Is.True);
|
||||
Assert.That(receivedSender, Is.EqualTo(Sender));
|
||||
Assert.That(receivedPayload, Is.EqualTo(message.ToByteArray()));
|
||||
Assert.That(manager.PendingMessageCount, Is.EqualTo(0));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Receive_UnregisteredMessage_DoesNotThrow()
|
||||
{
|
||||
var transport = new FakeTransport();
|
||||
var manager = new MessageManager(transport);
|
||||
var manager = new MessageManager(transport, new MainThreadNetworkDispatcher());
|
||||
|
||||
Assert.DoesNotThrow(() =>
|
||||
transport.EmitReceive(BuildEnvelope(MessageType.Heartbeat, new Heartbeat()), Sender));
|
||||
Assert.That(manager.PendingMessageCount, Is.EqualTo(0));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Receive_InvalidBytes_DoesNotBreakFollowingDispatch()
|
||||
{
|
||||
var transport = new FakeTransport();
|
||||
var manager = new MessageManager(transport);
|
||||
var manager = new MessageManager(transport, new MainThreadNetworkDispatcher());
|
||||
var handledCount = 0;
|
||||
|
||||
manager.RegisterHandler(MessageType.Heartbeat, (payload, sender) =>
|
||||
|
|
@ -119,9 +127,37 @@ namespace Tests.EditMode.Network
|
|||
Assert.DoesNotThrow(() => transport.EmitReceive(new byte[] { 0x01, 0x02, 0x03 }, Sender));
|
||||
transport.EmitReceive(BuildEnvelope(MessageType.Heartbeat, new Heartbeat()), Sender);
|
||||
|
||||
Assert.That(handledCount, Is.EqualTo(0));
|
||||
Assert.That(manager.PendingMessageCount, Is.EqualTo(1));
|
||||
|
||||
manager.DrainPendingMessagesAsync().GetAwaiter().GetResult();
|
||||
|
||||
Assert.That(handledCount, Is.EqualTo(1));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Receive_MultipleMessages_PreserveEnqueueOrder()
|
||||
{
|
||||
var transport = new FakeTransport();
|
||||
var manager = new MessageManager(transport, new MainThreadNetworkDispatcher());
|
||||
var handledSpeeds = new List<int>();
|
||||
|
||||
manager.RegisterHandler(MessageType.LoginRequest, (payload, sender) =>
|
||||
{
|
||||
handledSpeeds.Add(LoginRequest.Parser.ParseFrom(payload).Speed);
|
||||
});
|
||||
|
||||
transport.EmitReceive(BuildEnvelope(MessageType.LoginRequest, new LoginRequest { PlayerId = "a", Speed = 1 }), Sender);
|
||||
transport.EmitReceive(BuildEnvelope(MessageType.LoginRequest, new LoginRequest { PlayerId = "b", Speed = 2 }), Sender);
|
||||
|
||||
Assert.That(handledSpeeds, Is.Empty);
|
||||
Assert.That(manager.PendingMessageCount, Is.EqualTo(2));
|
||||
|
||||
manager.DrainPendingMessagesAsync().GetAwaiter().GetResult();
|
||||
|
||||
Assert.That(handledSpeeds, Is.EqualTo(new[] { 1, 2 }));
|
||||
}
|
||||
|
||||
private static byte[] BuildEnvelope(MessageType type, IMessage payload)
|
||||
{
|
||||
return new Envelope
|
||||
|
|
|
|||
|
|
@ -0,0 +1,136 @@
|
|||
using System;
|
||||
using System.Net;
|
||||
using System.Threading.Tasks;
|
||||
using Google.Protobuf;
|
||||
using Network.Defines;
|
||||
using Network.NetworkApplication;
|
||||
using Network.NetworkHost;
|
||||
using Network.NetworkTransport;
|
||||
using NUnit.Framework;
|
||||
|
||||
namespace Tests.EditMode.Network
|
||||
{
|
||||
public class SharedNetworkFoundationTests
|
||||
{
|
||||
private static readonly IPEndPoint Sender = new(IPAddress.Loopback, 9000);
|
||||
|
||||
[Test]
|
||||
public void SharedNetworkRuntime_UsesInjectedDispatcherForDeferredClientStyleDispatch()
|
||||
{
|
||||
var transport = new FakeTransport();
|
||||
var runtime = new SharedNetworkRuntime(transport, new MainThreadNetworkDispatcher());
|
||||
var handled = false;
|
||||
|
||||
runtime.MessageManager.RegisterHandler(MessageType.Heartbeat, (payload, sender) =>
|
||||
{
|
||||
handled = true;
|
||||
});
|
||||
|
||||
transport.EmitReceive(BuildEnvelope(MessageType.Heartbeat, new Heartbeat()), Sender);
|
||||
|
||||
Assert.That(handled, Is.False);
|
||||
Assert.That(runtime.MessageManager.PendingMessageCount, Is.EqualTo(1));
|
||||
|
||||
runtime.DrainPendingMessagesAsync().GetAwaiter().GetResult();
|
||||
|
||||
Assert.That(handled, Is.True);
|
||||
Assert.That(runtime.MessageManager.PendingMessageCount, Is.EqualTo(0));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ServerNetworkHost_UsesImmediateDispatcherByDefault()
|
||||
{
|
||||
var transport = new FakeTransport();
|
||||
var host = new ServerNetworkHost(transport);
|
||||
var handled = false;
|
||||
|
||||
host.MessageManager.RegisterHandler(MessageType.Heartbeat, (payload, sender) =>
|
||||
{
|
||||
handled = true;
|
||||
});
|
||||
|
||||
transport.EmitReceive(BuildEnvelope(MessageType.Heartbeat, new Heartbeat()), Sender);
|
||||
|
||||
Assert.That(handled, Is.True);
|
||||
Assert.That(host.MessageManager.PendingMessageCount, Is.EqualTo(0));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SharedHosts_PreserveEnvelopeProtocolContract()
|
||||
{
|
||||
var transport = new FakeTransport();
|
||||
var runtime = new SharedNetworkRuntime(transport, new ImmediateNetworkMessageDispatcher());
|
||||
var message = new LoginRequest
|
||||
{
|
||||
PlayerId = "shared-player",
|
||||
Speed = 7
|
||||
};
|
||||
|
||||
runtime.MessageManager.SendMessage(message, MessageType.LoginRequest, Sender);
|
||||
|
||||
var envelope = Envelope.Parser.ParseFrom(transport.LastSendToData);
|
||||
Assert.That(envelope.Type, Is.EqualTo((int)MessageType.LoginRequest));
|
||||
Assert.That(LoginRequest.Parser.ParseFrom(envelope.Payload).PlayerId, Is.EqualTo("shared-player"));
|
||||
Assert.That(LoginRequest.Parser.ParseFrom(envelope.Payload).Speed, Is.EqualTo(7));
|
||||
Assert.That(transport.LastSendTarget, Is.EqualTo(Sender));
|
||||
}
|
||||
|
||||
private static byte[] BuildEnvelope(MessageType type, IMessage payload)
|
||||
{
|
||||
return new Envelope
|
||||
{
|
||||
Type = (int)type,
|
||||
Payload = payload.ToByteString()
|
||||
}.ToByteArray();
|
||||
}
|
||||
|
||||
private sealed class FakeTransport : ITransport
|
||||
{
|
||||
public byte[] LastSendToData { get; private set; }
|
||||
|
||||
public IPEndPoint LastSendTarget { get; private set; }
|
||||
|
||||
public event Action<byte[], IPEndPoint> OnReceive;
|
||||
|
||||
public Task StartAsync()
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public void Stop()
|
||||
{
|
||||
}
|
||||
|
||||
public void Send(byte[] data)
|
||||
{
|
||||
}
|
||||
|
||||
public void SendTo(byte[] data, IPEndPoint target)
|
||||
{
|
||||
LastSendToData = Copy(data);
|
||||
LastSendTarget = target;
|
||||
}
|
||||
|
||||
public void SendToAll(byte[] data)
|
||||
{
|
||||
}
|
||||
|
||||
public void EmitReceive(byte[] data, IPEndPoint sender)
|
||||
{
|
||||
OnReceive?.Invoke(Copy(data), sender);
|
||||
}
|
||||
|
||||
private static byte[] Copy(byte[] data)
|
||||
{
|
||||
if (data == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var copy = new byte[data.Length];
|
||||
Array.Copy(data, copy, data.Length);
|
||||
return copy;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
fileFormatVersion: 2
|
||||
guid: cb9381c1693943c79457fc3fe9174e4a
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
using System;
|
||||
using System.Linq;
|
||||
using Network.NetworkTransport;
|
||||
using NUnit.Framework;
|
||||
|
||||
namespace Tests.EditMode.Network
|
||||
{
|
||||
public class TransportArchitectureTests
|
||||
{
|
||||
[Test]
|
||||
public void KcpTransport_ImplementsITransport()
|
||||
{
|
||||
Assert.That(typeof(ITransport).IsAssignableFrom(typeof(KcpTransport)), Is.True);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ReliableUdpTransport_IsNotAvailable()
|
||||
{
|
||||
var reliableUdpTransportType = AppDomain.CurrentDomain
|
||||
.GetAssemblies()
|
||||
.Select(assembly => assembly.GetType("Network.NetworkTransport.ReliableUdpTransport", throwOnError: false))
|
||||
.FirstOrDefault(type => type != null);
|
||||
|
||||
Assert.That(reliableUdpTransportType, Is.Null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
fileFormatVersion: 2
|
||||
guid: acd10ddd335680e44bce8246024e7f6a
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
321
CodeX-TODO.md
321
CodeX-TODO.md
|
|
@ -1,284 +1,79 @@
|
|||
# KCP 网络底层调整 TODO
|
||||
# KCP 网络底层调整 TODO
|
||||
|
||||
## 目标
|
||||
## 当前阶段状态
|
||||
|
||||
将当前项目的网络底层从“自写可靠 UDP + ACK/重传/会话”调整为“UDP 承载 KCP + 明确的传输层/会话层/消息层/同步层分层”,避免职责重叠,并为后续的同步优化、重连、监控打基础。
|
||||
- 阶段 1 已完成:`ITransport` 已统一为 `Send` / `SendTo` / `SendToAll` / `OnReceive` / `StartAsync` / `Stop` 这一套稳定接口。
|
||||
- 阶段 2 已完成:`KcpTransport` 已落地,`NetworkManager` 默认使用 KCP 作为运行时可靠传输实现,相关编辑器测试已经覆盖默认会话、多远端隔离、广播与停止清理。
|
||||
- 阶段 3 已完成:遗留的 `ReliableUdpTransport` 兼容入口已经移除,项目中不再保留第二个“可靠 UDP”实现名义。
|
||||
- 阶段 4 已完成:网络线程与 Unity 主线程之间已经建立显式分发边界,传输回调不再直接执行业务 handler。
|
||||
- 阶段 5 及以后尚未开始:连接生命周期、QoS/同步优化、监控指标仍然是后续工作。
|
||||
|
||||
## 当前现状
|
||||
## 当前真实现状
|
||||
|
||||
- `Assets/Scripts/Network/NetworkTransport/ReliableUdpTransport.cs` 已经引入 `Kcp-CSharp.dll`,但主体逻辑仍然是自定义可靠 UDP。
|
||||
- 当前传输层仍保留以下逻辑:
|
||||
- 自定义 `Packet`
|
||||
- 自定义 ACK
|
||||
- 自定义重传
|
||||
- 自定义超时会话清理
|
||||
- 自定义顺序交付
|
||||
- `NetworkManager -> MessageManager -> ITransport` 的抽象还没有完全收口,接口和实现存在不一致。
|
||||
- 当前业务链路不是纯 RPC,而是:
|
||||
- 当前可靠传输链路已经是:`NetworkManager -> SharedNetworkRuntime -> MessageManager -> MainThreadNetworkDispatcher -> ITransport -> KcpTransport`。
|
||||
- 当前客户端与服务端共用的网络基础设施已经具备:
|
||||
- 共享的 `ITransport` / `KcpTransport`
|
||||
- 共享的 `MessageManager`
|
||||
- 共享的 `SharedNetworkRuntime`
|
||||
- 由宿主注入的 dispatcher 策略,而不是在消息层内硬编码 Unity 主线程语义
|
||||
- `KcpTransport` 负责:
|
||||
- 客户端默认会话
|
||||
- 服务端按远端地址隔离会话
|
||||
- KCP `Input` / `Update` / `Recv`
|
||||
- 只在拿到完整业务消息后触发 `OnReceive`
|
||||
- `MessageManager` 负责:
|
||||
- 解析 `Envelope`
|
||||
- 根据 `MessageType` 查找 handler
|
||||
- 通过宿主注入的 dispatcher 执行消息分发,而不是在收包线程直接执行 handler
|
||||
- `MainThreadNetworkDispatcher` 负责:
|
||||
- 维护线程安全 FIFO 队列
|
||||
- 在 Unity 主线程 drain 队列并执行 handler
|
||||
- `ServerNetworkHost` 当前可以作为非 Unity 宿主复用同一套网络核心,并使用非主线程 dispatcher 策略
|
||||
- `NetworkManager` 负责:
|
||||
- 在 `Update()` 中定期 drain 网络消息
|
||||
- 在主线程上触发游戏对象修改与 UI 相关逻辑
|
||||
- 当前业务链路仍然包括:
|
||||
- 登录 / 登出
|
||||
- 心跳 / 对时
|
||||
- `PlayerInput` 上行
|
||||
- `PlayerState` 下行
|
||||
- 本地预测 / 服务器校正
|
||||
- 当前尚未完成的关键架构问题:
|
||||
- 连接成功、登录成功、心跳超时、重连等状态尚未完全分层
|
||||
- 高频同步消息仍未做 QoS 拆分
|
||||
- 网络观测指标还不完整
|
||||
|
||||
## 必须调整的内容
|
||||
|
||||
### 1. 收口传输层接口
|
||||
|
||||
统一 `ITransport` 的职责,避免上层绕过抽象调用不存在的方法。
|
||||
|
||||
建议接口至少包含:
|
||||
|
||||
- `StartAsync()`
|
||||
- `Stop()`
|
||||
- `Connect(...)` 或客户端构造时明确默认远端
|
||||
- `Send(byte[] data)`
|
||||
- `SendTo(byte[] data, IPEndPoint target)`,仅服务端或特殊场景需要
|
||||
- `SendToAll(byte[] data)`,仅服务端广播需要
|
||||
- `OnReceive`
|
||||
- `OnConnected`
|
||||
- `OnDisconnected`
|
||||
- `OnError`
|
||||
|
||||
当前要处理的问题:
|
||||
|
||||
- `MessageManager` 调用了 `transport.Send(...)`,但 `ITransport` 中没有定义该接口。
|
||||
- `ReliableUdpTransport` 当前的 `SendTo(Packet, IPEndPoint)` 与接口 `SendTo(byte[], IPEndPoint)` 不一致。
|
||||
|
||||
### 2. 用 KCP 替代自定义可靠 UDP
|
||||
|
||||
KCP 接入后,以下能力不应继续由项目侧重复实现:
|
||||
|
||||
- ACK 管理
|
||||
- 重传调度
|
||||
- 收发序号维护
|
||||
- 有序交付
|
||||
- 滑动窗口
|
||||
|
||||
因此需要删除或重构以下内容:
|
||||
|
||||
- `Assets/Scripts/Network/NetworkTransport/Packet.cs`
|
||||
- `Assets/Scripts/Network/NetworkTransport/ClientSession.cs` 中基于自定义 seq/ack 的逻辑
|
||||
- `Assets/Scripts/Network/NetworkTransport/ReliableUdpTransport.cs` 中的:
|
||||
- `CheckRetransmit`
|
||||
- `HandleAckPacket`
|
||||
- 自定义 `PendingAcks`
|
||||
- 自定义重复包 / 乱序包处理
|
||||
- 自定义可靠性定时器
|
||||
|
||||
### 3. 重建会话层
|
||||
|
||||
KCP 模式下需要清晰区分:
|
||||
|
||||
- UDP Socket
|
||||
- KCP Session
|
||||
- 业务连接状态
|
||||
|
||||
建议设计:
|
||||
|
||||
- 客户端:
|
||||
- 单一默认远端
|
||||
- 单一 `KcpSession`
|
||||
- 服务端:
|
||||
- 按 `IPEndPoint + conv` 管理多个 `KcpSession`
|
||||
- 支持会话建立、心跳超时、断线清理
|
||||
|
||||
会话层至少需要管理:
|
||||
|
||||
- `conv`
|
||||
- 远端地址
|
||||
- 最后活跃时间
|
||||
- KCP 实例
|
||||
- 连接状态
|
||||
- 断开原因
|
||||
|
||||
### 4. 将网络线程与 Unity 主线程解耦
|
||||
|
||||
当前 `MessageManager.OnTransportReceiveAsync(...)` 直接进入业务 handler,而后续 handler 会继续访问:
|
||||
|
||||
- `MasterManager`
|
||||
- `Player`
|
||||
- `GameObject`
|
||||
- `UI`
|
||||
|
||||
这些逻辑不应该直接在网络接收线程执行。
|
||||
|
||||
需要改为:
|
||||
|
||||
1. 网络线程收包
|
||||
2. 解析最小必要信息
|
||||
3. 投递到线程安全队列
|
||||
4. 在 Unity `Update()` 中统一分发到业务层
|
||||
|
||||
建议新增:
|
||||
|
||||
- `MainThreadDispatcher`
|
||||
- 或 `ConcurrentQueue<Action>`
|
||||
- 或 `ConcurrentQueue<ReceivedEnvelope>`
|
||||
|
||||
### 5. 重新划分消息 QoS
|
||||
|
||||
当前所有消息看起来都走同一种可靠传输语义,这对高频同步不合理。
|
||||
|
||||
建议至少拆成两类:
|
||||
|
||||
- 强可靠消息
|
||||
- 登录
|
||||
- 登出
|
||||
- 房间管理
|
||||
- 关键系统命令
|
||||
- 高频同步消息
|
||||
- `PlayerInput`
|
||||
- `PlayerState`
|
||||
- 以后可能的快照、插值状态、非关键位置更新
|
||||
|
||||
需要明确一个原则:
|
||||
|
||||
- 如果 `PlayerState` 继续走可靠有序流,旧包阻塞会放大延迟。
|
||||
- 如果 `PlayerInput` 全部严格可靠发送,也可能产生输入堆积。
|
||||
|
||||
这部分要结合项目玩法决定:
|
||||
|
||||
- 方案 A:全部先走 KCP,先完成架构收口,再做同步优化
|
||||
- 方案 B:命令消息走 KCP,同步消息走裸 UDP / 另一条轻量通道
|
||||
|
||||
短期建议先用方案 A 收口,后续再细分。
|
||||
|
||||
### 6. 重构连接生命周期
|
||||
|
||||
需要把“传输连接”与“业务登录状态”分开。
|
||||
|
||||
建议生命周期:
|
||||
|
||||
1. 创建 UDP Socket
|
||||
2. 初始化 KCP
|
||||
3. 连接服务器 / 建立默认会话
|
||||
4. 开始收包循环和 KCP Update
|
||||
5. 发送 `LoginRequest`
|
||||
6. 收到 `LoginResponse` 后进入已登录状态
|
||||
7. 开始心跳与超时检测
|
||||
8. 超时或异常时触发断线回调
|
||||
9. 按需重连
|
||||
|
||||
不要再把“收到登录响应才知道默认服务器端点”这种逻辑和连接过程混在一起。
|
||||
|
||||
## 建议同步调整的内容
|
||||
|
||||
### 1. 对时与发送频率分离
|
||||
|
||||
当前 `MovementComponent` 通过修改 `_sendInterval` 来追赶服务器 Tick,这会把:
|
||||
|
||||
- 时钟校正
|
||||
- 发包频率
|
||||
- 同步稳定性
|
||||
|
||||
绑在一起。
|
||||
|
||||
建议改为:
|
||||
|
||||
- 固定输入发送频率
|
||||
- 单独维护客户端与服务端 Tick 偏移
|
||||
- 在校正阶段使用 replay / reconcile,而不是直接依赖发包间隔漂移
|
||||
|
||||
### 2. 增加 KCP 参数配置入口
|
||||
|
||||
建议支持配置以下参数:
|
||||
|
||||
- `NoDelay`
|
||||
- `Interval`
|
||||
- `Resend`
|
||||
- `NC`
|
||||
- `SndWnd`
|
||||
- `RcvWnd`
|
||||
- `MTU`
|
||||
- `DeadLink`
|
||||
|
||||
建议做法:
|
||||
|
||||
- 新增 `KcpTransportConfig`
|
||||
- 客户端和服务端分别可配置
|
||||
- 支持 Inspector 或 ScriptableObject 配置
|
||||
|
||||
### 3. 增加网络观测指标
|
||||
|
||||
至少需要输出:
|
||||
|
||||
- RTT
|
||||
- 重传次数
|
||||
- 待发送队列长度
|
||||
- 待接收队列长度
|
||||
- 会话数量
|
||||
- 最后活跃时间
|
||||
- 超时断线原因
|
||||
|
||||
后续排查“卡顿、抖动、延迟尖刺、重连失败”时会用到。
|
||||
|
||||
### 4. 明确服务端广播策略
|
||||
|
||||
当前 `SendToAll` 是直接遍历会话广播。接入 KCP 后要明确:
|
||||
|
||||
- 广播是否逐会话独立写入
|
||||
- 广播时是否允许慢连接拖累整体发送
|
||||
- 广播消息是否需要按类型分优先级
|
||||
|
||||
## 推荐实施步骤
|
||||
|
||||
### 阶段 1:先把抽象收口
|
||||
|
||||
1. 调整 `ITransport`,补齐上层真正需要的发送与连接接口。
|
||||
2. 让 `MessageManager` 只依赖 `ITransport` 暴露的方法,不再假设具体实现细节。
|
||||
3. 修复当前 `ReliableUdpTransport` 与 `ITransport` 的方法签名不一致问题。
|
||||
4. 让网络层先达到“接口一致、结构可替换”的状态。
|
||||
|
||||
交付标准:
|
||||
|
||||
- 上层不再直接依赖某个具体 Transport 的额外方法。
|
||||
- 业务层不关心底层是 UDP 还是 KCP。
|
||||
|
||||
### 阶段 2:引入 `KcpTransport`
|
||||
|
||||
1. 新建 `KcpTransport`,不要在旧的 `ReliableUdpTransport` 上继续打补丁。
|
||||
2. 用 `UdpClient` 只负责收发原始 UDP 数据报。
|
||||
3. 每个连接维护一个 `KcpSession`。
|
||||
4. UDP 收包后先交给对应的 KCP 实例 `Input`。
|
||||
5. 周期性驱动 KCP `Update` / `Check`。
|
||||
6. 从 KCP `Recv` 中取出完整业务消息后再触发 `OnReceive`。
|
||||
|
||||
交付标准:
|
||||
|
||||
- 传输层不再维护自定义 ACK/重传逻辑。
|
||||
- KCP 可以完成完整收发。
|
||||
## 已完成阶段回顾
|
||||
|
||||
### 阶段 3:移除旧可靠 UDP 结构
|
||||
|
||||
1. 删除或废弃 `Packet.cs`。
|
||||
2. 删除或废弃旧 `ClientSession` 中基于 seq/ack 的缓存和重传代码。
|
||||
3. 删除 `ReliableUdpTransport` 中的:
|
||||
- retransmit timer
|
||||
- ack handler
|
||||
- packet seq 交付逻辑
|
||||
4. 保留必要的会话容器与连接生命周期管理。
|
||||
已完成结果:
|
||||
|
||||
交付标准:
|
||||
|
||||
- 项目中不再同时存在“两套可靠性机制”。
|
||||
- 项目中不存在可直接实例化的 `ReliableUdpTransport`。
|
||||
- 默认运行时可靠传输路径仍然是 `KcpTransport`。
|
||||
- 文档不再描述“项目里还保留一套旧 reliable UDP 实现”。
|
||||
|
||||
### 阶段 4:主线程分发改造
|
||||
|
||||
1. 新增线程安全接收队列。
|
||||
2. 网络线程只负责:
|
||||
已完成结果:
|
||||
|
||||
1. 已新增线程安全接收队列:`Assets/Scripts/Network/NetworkApplication/MainThreadNetworkDispatcher.cs`
|
||||
2. 网络线程当前只负责:
|
||||
- 收包
|
||||
- KCP 输入输出
|
||||
- 基础错误处理
|
||||
3. Unity 主线程负责:
|
||||
- 将有效业务消息入队
|
||||
3. Unity 主线程当前负责:
|
||||
- 消息分发
|
||||
- 游戏对象修改
|
||||
- UI 更新
|
||||
|
||||
交付标准:
|
||||
交付结论:
|
||||
|
||||
- 网络消息不会直接在非主线程操作 Unity 对象。
|
||||
|
||||
## 后续阶段
|
||||
|
||||
### 阶段 5:连接与心跳改造
|
||||
|
||||
1. 明确“连接成功”和“登录成功”是两个不同状态。
|
||||
|
|
@ -314,30 +109,16 @@ KCP 模式下需要清晰区分:
|
|||
|
||||
## 推荐新增的结构
|
||||
|
||||
建议新增或重命名以下模块:
|
||||
建议后续继续补齐以下模块:
|
||||
|
||||
- `Assets/Scripts/Network/NetworkTransport/KcpTransport.cs`
|
||||
- `Assets/Scripts/Network/NetworkTransport/KcpSession.cs`
|
||||
- `Assets/Scripts/Network/NetworkTransport/KcpTransportConfig.cs`
|
||||
- `Assets/Scripts/Network/NetworkApplication/MainThreadNetworkDispatcher.cs`
|
||||
|
||||
建议废弃或重构:
|
||||
|
||||
- `Assets/Scripts/Network/NetworkTransport/ReliableUdpTransport.cs`
|
||||
- `Assets/Scripts/Network/NetworkTransport/ClientSession.cs`
|
||||
- `Assets/Scripts/Network/NetworkTransport/Packet.cs`
|
||||
|
||||
## 验收标准
|
||||
|
||||
- 上层只依赖统一的 `ITransport`。
|
||||
- 传输层不再重复实现 ACK / 重传 / 顺序控制。
|
||||
- `KcpTransport` 是唯一可靠传输实现。
|
||||
- 客户端与服务端都能正常建立 KCP 会话。
|
||||
- 登录、心跳、输入、状态同步链路可正常跑通。
|
||||
- 非主线程不再直接访问 Unity 对象。
|
||||
- 会话超时、断线、重连有明确状态与日志。
|
||||
- 高频移动同步在丢包 / 抖动场景下仍可用。
|
||||
|
||||
## 备注
|
||||
|
||||
- 当前最不建议的做法,是在现有 `ReliableUdpTransport` 上继续叠加更多 KCP 相关判断。这样会让自定义可靠 UDP 和 KCP 职责长期重叠。
|
||||
- 正确方向是:先抽象收口,再用新的 `KcpTransport` 替换旧实现。
|
||||
|
|
|
|||
|
|
@ -0,0 +1,2 @@
|
|||
schema: spec-driven
|
||||
created: 2026-03-26
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
## Context
|
||||
|
||||
`KcpTransport` already keeps socket receive and KCP update work on background tasks, but `MessageManager` subscribes directly to `ITransport.OnReceive` and immediately parses and dispatches handlers on whichever thread raised the callback. In the current project, several registered handlers mutate Unity-facing state through `MasterManager` and UI objects inside `NetworkManager`, so the absence of an explicit main-thread handoff is the main architecture gap left after stages two and three.
|
||||
|
||||
The project already has a Unity lifecycle entry point in `Assets/Scripts/NetworkManager.cs`, and `CodeX-TODO.md` explicitly recommends adding `Assets/Scripts/Network/NetworkApplication/MainThreadNetworkDispatcher.cs`. Stage four should therefore formalize a queueing boundary without changing the reliable transport contract or mixing in later connection-state concerns.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- Ensure transport receive callbacks never execute message handlers inline on background threads.
|
||||
- Introduce a thread-safe queue between transport receive and business handler execution.
|
||||
- Make Unity main thread code explicitly responsible for draining queued network messages and invoking handlers.
|
||||
- Preserve the existing `IMessageHandler` / `MessageManager.RegisterHandler` programming model so stage four remains a structural refactor rather than a gameplay rewrite.
|
||||
|
||||
**Non-Goals:**
|
||||
- Redesign KCP session management, heartbeats, reconnection, or login state handling.
|
||||
- Introduce QoS splitting for `PlayerInput` / `PlayerState`.
|
||||
- Replace the current handler registration model with a larger event bus or ECS messaging framework.
|
||||
|
||||
## Decisions
|
||||
|
||||
### 1. Add a dedicated main-thread dispatcher abstraction in the network application layer
|
||||
The change will introduce a small dispatcher component, expected at `Assets/Scripts/Network/NetworkApplication/MainThreadNetworkDispatcher.cs`, that owns a thread-safe queue of received transport payloads and exposes a drain method for the Unity main thread. This keeps thread-boundary code out of `KcpTransport` and avoids coupling transport code to Unity APIs.
|
||||
|
||||
Alternative considered: enqueue directly inside `NetworkManager` with ad-hoc delegates. Rejected because it would bury the threading contract in one scene component and make edit mode testing harder.
|
||||
|
||||
### 2. `MessageManager` becomes a queueing bridge, not the final execution site for transport callbacks
|
||||
`MessageManager` will still subscribe to `ITransport.OnReceive`, parse envelopes, and resolve registered handlers, but the transport callback path will stop awaiting handlers inline. Instead it will enqueue a dispatch work item that can later be executed on the main thread. This preserves message type routing in one place while moving handler invocation to the correct thread boundary.
|
||||
|
||||
Alternative considered: push raw bytes into the dispatcher and parse envelopes later on the main thread. Rejected because malformed payload handling and message-type routing belong with the network message layer, not with the Unity host component.
|
||||
|
||||
### 3. `NetworkManager` pumps queued network work during Unity's frame loop
|
||||
The existing `NetworkManager` MonoBehaviour is the narrowest place to guarantee execution on the Unity main thread. It should own or receive the dispatcher and call its drain method from `Update`, with an optional per-frame drain limit to avoid one spike starving a frame. This keeps stage four focused and avoids introducing a second always-on host object unless later stages need it.
|
||||
|
||||
Alternative considered: capture `SynchronizationContext` and post handler work directly. Rejected because a dedicated drain step is easier to test deterministically and makes backpressure visible.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- [Queue growth under burst traffic] -> Add a bounded per-frame drain count and log queue length when it exceeds an expected threshold.
|
||||
- [One extra frame of dispatch latency] -> Acceptable for stage four because the goal is thread safety; later QoS work can tune batching and frame budget.
|
||||
- [Partial migration where some code still dispatches inline] -> Cover the new contract with tests that assert handlers are not run during the transport callback itself and only run after an explicit drain.
|
||||
- [Unity lifecycle coupling] -> Keep the dispatcher itself Unity-agnostic so only `NetworkManager` depends on `Update`.
|
||||
|
||||
## Migration Plan
|
||||
|
||||
1. Introduce the dispatcher abstraction and message work-item representation.
|
||||
2. Refactor `MessageManager` so transport callbacks enqueue dispatch work instead of invoking handlers immediately.
|
||||
3. Integrate dispatcher draining into `NetworkManager.Update`.
|
||||
4. Add or update edit mode tests for deferred dispatch, FIFO ordering, and invalid payload isolation.
|
||||
5. Run edit mode tests and update `CodeX-TODO.md` when implementation lands.
|
||||
|
||||
## Open Questions
|
||||
|
||||
- Whether stage four should enforce a hard queue capacity or only expose queue depth for diagnostics.
|
||||
- Whether login/bootstrap messages need an explicit early drain during startup before the first regular `Update`.
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
## Why
|
||||
|
||||
`MessageManager` currently handles transport receive callbacks directly on the transport's background thread, which leaves message dispatch and downstream game state updates one refactor away from touching Unity objects off the main thread. Stage four is the point where the project needs an explicit thread boundary so later connection-state and sync work can build on a safe dispatch model.
|
||||
|
||||
## What Changes
|
||||
|
||||
- Add a main-thread network dispatch capability that queues decoded transport payloads for processing on Unity's main thread.
|
||||
- Define that transport background threads are limited to socket receive, KCP session input/update, and basic transport error handling.
|
||||
- Define that message dispatch, handler execution, game object mutation, and UI-facing reactions run only when the main-thread dispatcher drains queued messages.
|
||||
- Cover the new threading boundary with architecture-focused tests and document the runtime path expected after stage four.
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
- `network-main-thread-dispatch`: Defines the queueing and main-thread draining rules between transport receive callbacks and message handler execution.
|
||||
|
||||
### Modified Capabilities
|
||||
- None.
|
||||
|
||||
## Impact
|
||||
|
||||
- Affected code: `Assets/Scripts/Network/NetworkApplication/MessageManager.cs`, new dispatcher code under `Assets/Scripts/Network/NetworkApplication/`, and related edit mode tests.
|
||||
- Affected runtime behavior: transport callbacks stop invoking business handlers inline and instead enqueue work for a main-thread pump.
|
||||
- Dependencies: no new external packages; uses in-process thread-safe queueing and Unity-side update integration.
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
## ADDED Requirements
|
||||
|
||||
### Requirement: Transport callbacks enqueue network message dispatch work
|
||||
The network application layer SHALL place a thread-safe queue between `ITransport.OnReceive` callbacks and message handler execution. When a transport callback produces a valid application envelope, the callback path MUST enqueue dispatch work and return without invoking registered business handlers inline.
|
||||
|
||||
#### Scenario: Valid payload is deferred instead of dispatched inline
|
||||
- **WHEN** a transport implementation raises `OnReceive` with a valid encoded application message
|
||||
- **THEN** the message layer enqueues one dispatch work item for that message
|
||||
- **THEN** the registered handler is not executed during the transport callback itself
|
||||
|
||||
#### Scenario: Invalid payload does not block later queued messages
|
||||
- **WHEN** the transport callback receives malformed bytes followed by a valid application message
|
||||
- **THEN** the malformed payload is handled as an error without enqueuing executable work
|
||||
- **THEN** the later valid message can still be enqueued and processed normally
|
||||
|
||||
### Requirement: Main-thread drain executes queued handlers in receive order
|
||||
The runtime SHALL provide an explicit main-thread drain step that executes queued network dispatch work in FIFO order. Message handlers, gameplay state mutation, and UI-facing reactions triggered by received messages MUST run only through this main-thread drain path.
|
||||
|
||||
#### Scenario: Drain executes queued work on demand
|
||||
- **WHEN** one or more network messages have been enqueued from transport callbacks
|
||||
- **THEN** no registered handler runs until the main-thread dispatcher performs a drain step
|
||||
- **THEN** each queued handler executes during that drain step on the Unity main thread path
|
||||
|
||||
#### Scenario: Messages preserve receive order through the dispatcher
|
||||
- **WHEN** multiple valid messages are enqueued in sequence for the same runtime
|
||||
- **THEN** the main-thread dispatcher invokes their handlers in the same order they were enqueued
|
||||
|
||||
### Requirement: Runtime network host pumps the dispatcher each frame
|
||||
The Unity-side runtime network host SHALL integrate the dispatcher into its frame loop so queued network work is drained regularly while the network stack is running. The transport background thread responsibilities MUST remain limited to socket receive, KCP input/update, and transport-level error handling.
|
||||
|
||||
#### Scenario: Network host drains queued messages during runtime
|
||||
- **WHEN** the client runtime has started networking and a message is queued from the transport layer
|
||||
- **THEN** the runtime network host performs dispatcher draining during its Unity update loop
|
||||
- **THEN** the queued handler runs without the transport layer directly touching Unity objects
|
||||
|
||||
#### Scenario: Transport layer remains free of Unity object mutation
|
||||
- **WHEN** developers inspect the responsibilities of the transport receive path after stage four
|
||||
- **THEN** they find socket receive, KCP processing, and enqueue/error handling only
|
||||
- **THEN** Unity object mutation and UI updates are performed outside the transport callback path
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
## 1. Dispatcher Foundation
|
||||
|
||||
- [x] 1.1 Add a `MainThreadNetworkDispatcher` in `Assets/Scripts/Network/NetworkApplication/` that stores queued network work items in a thread-safe FIFO structure.
|
||||
- [x] 1.2 Define the dispatcher API needed by runtime code, including enqueueing from transport callbacks and draining from the Unity main thread.
|
||||
|
||||
## 2. Message Pipeline Refactor
|
||||
|
||||
- [x] 2.1 Refactor `MessageManager` so `ITransport.OnReceive` parses envelopes and enqueues dispatch work instead of invoking registered handlers inline.
|
||||
- [x] 2.2 Preserve current handler registration and invalid-payload handling while moving actual handler execution into the dispatcher drain path.
|
||||
|
||||
## 3. Unity Runtime Integration
|
||||
|
||||
- [x] 3.1 Integrate the dispatcher into `Assets/Scripts/NetworkManager.cs` so queued network messages are drained from the Unity frame loop.
|
||||
- [x] 3.2 Ensure transport-side responsibilities remain limited to receive, KCP processing, and enqueue/error handling, with Unity object mutation occurring only after main-thread drain.
|
||||
|
||||
## 4. Verification
|
||||
|
||||
- [x] 4.1 Add or update edit mode tests to verify receive callbacks defer handler execution until an explicit drain step and preserve FIFO ordering.
|
||||
- [x] 4.2 Run the relevant network edit mode tests/build and update `CodeX-TODO.md` to reflect stage four progress once the implementation is complete.
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
schema: spec-driven
|
||||
created: 2026-03-26
|
||||
|
|
@ -0,0 +1,68 @@
|
|||
## Context
|
||||
|
||||
当前仓库已经完成阶段二的核心目标:`NetworkManager` 默认实例化 `KcpTransport`,`MessageManager` 仅依赖 `ITransport`,并且已有编辑器测试覆盖 KCP 的默认会话、多远端隔离、广播与停止清理行为。与 TODO 文档最初描述不同,仓库里已经没有旧的 ACK/重传/seq 实现残留在 `ReliableUdpTransport` 中,该类现状只是一个基于 `UdpClient` 的 plain UDP 收发器。
|
||||
|
||||
这让阶段三的真实问题从“拆掉旧可靠 UDP 算法”变成了“拆掉旧可靠 UDP 概念和错误入口”。如果继续保留 `ReliableUdpTransport` 这个名称,后续开发者会自然假定项目中仍然存在第二套可靠传输实现,导致连接状态、QoS 分流和后续主线程分发改造继续围绕错误前提展开。因此,本次设计重点是收紧传输层边界,而不是重新做一轮 KCP 集成。
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- 让 `KcpTransport` 成为项目内唯一的可靠 `ITransport` 实现,并在代码结构上消除对旧可靠 UDP 名称的依赖。
|
||||
- 删除、退役或显式改名当前的 `ReliableUdpTransport` 兼容类,使其不再被误解为可靠传输实现。
|
||||
- 保持 `ITransport`、`MessageManager` 和现有业务消息封包逻辑不变,避免阶段三重新扩散到消息层。
|
||||
- 用测试和文档明确“可靠消息只走 KCP”这一边界,为阶段四后的连接、线程与同步优化提供稳定基线。
|
||||
|
||||
**Non-Goals:**
|
||||
- 修改 `ITransport` 接口形状,或在本次变更中引入 `OnConnected`、`OnDisconnected`、`OnError` 等新事件。
|
||||
- 处理主线程派发、会话超时、断线重连或心跳状态机。
|
||||
- 为高频同步新增裸 UDP 并行通道;如果未来需要非可靠传输,本次只保留可扩展边界,不直接实现 QoS 分流。
|
||||
- 变更 KCP 会话语义、`conv` 分配策略或既有 `KcpTransportTests` 已覆盖的阶段二行为。
|
||||
|
||||
## Decisions
|
||||
|
||||
### 1. 删除误导性的 `ReliableUdpTransport`,而不是继续保留兼容壳
|
||||
|
||||
当前 `ReliableUdpTransport` 不再提供任何可靠能力,继续保留它只会制造“项目中还有第二套可靠路径”的误解。阶段三应直接删除该类及其相关资产;如果后续确实需要裸 UDP 通道,应以明确的 `UdpTransport` 或其他非可靠命名重新引入,并在 capability 层单独建模。
|
||||
|
||||
备选方案是保留该类并加 `[Obsolete]` 标记。这个方案短期改动更小,但会长期留下错误命名和二义性,且 Unity 项目里 `Obsolete` 往往不足以阻止被继续引用,因此不作为首选。
|
||||
|
||||
### 2. 将“唯一可靠通道”写入 `kcp-transport` capability,而不是只作为实现细节
|
||||
|
||||
阶段三的核心价值在于建立新的架构边界:可靠消息只能通过 KCP 传输。这个约束会直接影响未来是否允许新增第二个可靠 transport、如何做 QoS 分流,以及如何理解登录/心跳/输入/状态链路。因此它需要进入 `openspec/specs/kcp-transport/spec.md` 的 delta,而不是只写在任务说明或代码注释里。
|
||||
|
||||
备选方案是新建一个独立 capability,例如 `transport-cleanup`。但本次没有新增对外能力,变化本质上是对现有 KCP 传输能力的边界补充,归并到 `kcp-transport` 更紧凑。
|
||||
|
||||
### 3. 仅保留稳定的 `ITransport` 抽象,不在阶段三暴露新的临时迁移接口
|
||||
|
||||
阶段三不会为了兼容旧类而引入工厂、别名接口或临时转发层。运行时代码已经通过 `ITransport` 与 `KcpTransport` 对接,说明迁移成本集中在删除遗留类与相关测试/文档,而不是上层调用点适配。保持接口不变有助于把本次改动约束在 transport 边界内完成。
|
||||
|
||||
备选方案是新增 transport factory,由 factory 负责决定 KCP 还是兼容 UDP。这会把一个已经完成切换的问题重新抽象化,没有当前收益。
|
||||
|
||||
### 4. 用测试验证“错误入口已消失”,而不重复验证阶段二已覆盖的 KCP 行为
|
||||
|
||||
阶段二已经有 `KcpTransportTests` 覆盖 KCP 可靠收发行为。阶段三新增或调整的测试应聚焦于:
|
||||
- 运行时入口仍使用 `KcpTransport`
|
||||
- 仓库中不再存在会被业务代码直接实例化的 `ReliableUdpTransport`
|
||||
- 如保留非可靠 transport,新命名和语义与可靠链路明确区分
|
||||
|
||||
这样可以避免在同一 capability 上堆积重复测试,同时把测试成本投入到真正变化的边界上。
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- [未来很快需要裸 UDP 高频同步通道] → 若需求出现,再以明确命名新增非可靠 transport,不复用 `ReliableUdpTransport` 这个遗留名称。
|
||||
- [删除类后仍有隐藏引用未被搜索到] → 在实现任务中先全仓检索 `ReliableUdpTransport`,并用编译/测试确认没有残余引用。
|
||||
- [TODO 文档与代码现实存在偏差] → 本次 proposal/design/spec 以当前代码为准,并在任务中同步更新相关文档表述,避免继续误导后续阶段。
|
||||
- [仅靠规范无法阻止后续再引入第二个可靠 transport] → 在 spec 中明确约束,并通过代码评审和后续实现测试守住边界。
|
||||
|
||||
## Migration Plan
|
||||
|
||||
1. 全仓检索并确认 `ReliableUdpTransport` 的剩余引用点、测试覆盖点和文档提及位置。
|
||||
2. 删除 `ReliableUdpTransport.cs` 及相关 `.meta`,或在确有非可靠需求时以明确的新名称替换。
|
||||
3. 调整受影响测试与文档,确保默认运行时入口和 capability 说明都只指向 `KcpTransport`。
|
||||
4. 运行网络相关测试与工程编译,确认没有因删除旧类导致的残余引用或 asmdef 资产问题。
|
||||
5. 若删除旧类后出现未预期依赖,可临时恢复文件以定位引用来源,但不恢复“可靠 UDP”命名进入主线。
|
||||
|
||||
## Open Questions
|
||||
|
||||
- 当前仓库是否还有服务端入口或外部工具脚本在工作区外引用 `ReliableUdpTransport`?
|
||||
- 如果阶段六需要非可靠同步通道,团队是否希望直接使用 `UdpTransport` 命名,还是通过更贴合业务的 QoS 名称引入?
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
## Why
|
||||
|
||||
阶段二已经把默认运行时切换到 `KcpTransport`,并且现有 `ReliableUdpTransport` 不再承载自定义 ACK、重传或乱序重组逻辑,而是退化成一个名称误导的 plain UDP 兼容实现。阶段三需要正式清理这层遗留概念,确保项目内不再并存“名义上的旧可靠 UDP”和 KCP 两套可靠传输入口,避免后续连接生命周期、主线程分发和同步优化继续建立在模糊的传输语义上。
|
||||
|
||||
## What Changes
|
||||
|
||||
- 删除或退役 `ReliableUdpTransport` 这一遗留可靠 UDP 命名与入口,避免运行时和调用方继续把它当成可靠传输实现。
|
||||
- 明确 `KcpTransport` 是项目内唯一的可靠 `ITransport` 实现,所有可靠消息链路继续通过 KCP 会话收发。
|
||||
- 清理与旧可靠 UDP 相关的残留代码、测试和文档表述,消除“双重可靠性机制仍然存在”的误导。
|
||||
- 如项目仍需保留裸 UDP 能力,使用明确的非可靠命名与职责边界,而不是沿用 `ReliableUdpTransport` 兼容壳。
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
None.
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- `kcp-transport`: 扩展传输层要求,明确 KCP 是唯一可靠传输路径,并要求遗留的 `ReliableUdpTransport` 兼容入口不再作为可靠实现保留。
|
||||
|
||||
## Impact
|
||||
|
||||
- 受影响代码:`Assets/Scripts/Network/NetworkTransport/`、`Assets/Scripts/NetworkManager.cs`、`Assets/Tests/EditMode/Network/`
|
||||
- 受影响接口:`ITransport` 形状保持不变,但可靠传输实现的选择与命名边界会进一步收紧
|
||||
- 受影响系统:客户端登录、心跳、输入上行、状态下行等所有依赖可靠消息交付的链路
|
||||
- 受影响文档:`CodeX-TODO.md` 对阶段三的实施结果、以及 OpenSpec 下的 `kcp-transport` 能力定义
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
## ADDED Requirements
|
||||
|
||||
### Requirement: KCP is the sole reliable transport implementation
|
||||
The project SHALL expose `KcpTransport` as the only reliable `ITransport` implementation used by runtime networking paths. Reliable business messages, including login, heartbeat, player input, and player state synchronization, MUST continue to flow through KCP-backed sessions rather than any legacy reliable UDP compatibility class.
|
||||
|
||||
#### Scenario: Runtime networking uses KCP for reliable delivery
|
||||
- **WHEN** the application constructs the transport used by `MessageManager` for its normal runtime networking path
|
||||
- **THEN** that transport instance is `KcpTransport`
|
||||
- **THEN** reliable business payloads are sent and received through KCP session state
|
||||
|
||||
### Requirement: Legacy reliable UDP entry points are retired
|
||||
The codebase SHALL NOT keep a directly instantiable `ReliableUdpTransport` entry point that implies a second reliable delivery mechanism. If a non-reliable UDP transport is needed in the future, it MUST use a distinct name and MUST NOT claim reliable semantics.
|
||||
|
||||
#### Scenario: Legacy reliable transport is not available to callers
|
||||
- **WHEN** developers inspect the transport implementations available to runtime code
|
||||
- **THEN** they do not find a usable `ReliableUdpTransport` class representing reliable delivery
|
||||
- **THEN** the remaining transport naming makes the reliable-versus-unreliable boundary explicit
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
## 1. Remove legacy transport entry points
|
||||
|
||||
- [x] 1.1 全仓检索 `ReliableUdpTransport` 的代码、测试和文档引用,确认删除或替换范围
|
||||
- [x] 1.2 删除 `Assets/Scripts/Network/NetworkTransport/ReliableUdpTransport.cs` 及其相关资产;如果必须保留裸 UDP,则以明确的非可靠命名重建
|
||||
|
||||
## 2. Reconcile runtime and specifications
|
||||
|
||||
- [x] 2.1 确认运行时入口和网络层装配仅使用 `KcpTransport` 作为可靠 `ITransport` 实现
|
||||
- [x] 2.2 更新受影响的 OpenSpec、TODO 或内联说明,明确项目不再保留旧可靠 UDP 入口
|
||||
|
||||
## 3. Verification
|
||||
|
||||
- [x] 3.1 调整或新增测试,验证默认可靠传输路径仍然是 `KcpTransport`,且不存在可直接使用的旧可靠 UDP 入口
|
||||
- [x] 3.2 运行网络相关测试与工程编译,确认删除遗留 transport 后无残余引用或资源错误
|
||||
|
||||
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
schema: spec-driven
|
||||
created: 2026-03-26
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
## Context
|
||||
|
||||
The project already has a reusable transport contract (`ITransport`), a KCP-based reliable transport (`KcpTransport`), and a message layer (`MessageManager`) that parses envelopes and routes handlers. However, the current runtime shape still hard-codes a Unity-oriented hosting model: `NetworkManager` is the only real host, `MessageManager` defaults to `MainThreadNetworkDispatcher`, and the main-thread pumping behavior is bundled into the same client-facing assembly that owns reusable transport code.
|
||||
|
||||
That coupling is now the main blocker to sharing one networking stack across client and server. The transport and protocol code itself is already environment-agnostic, but the hosting and dispatch assumptions are not. If a server is added without first separating those concerns, the codebase will either fork into client/server variants or introduce conditional logic in classes that should remain host-neutral.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- Define a shared network core that both client and server hosts can use without depending on Unity runtime types.
|
||||
- Introduce an explicit dispatcher abstraction so message execution policy is supplied by the host instead of being baked into `MessageManager`.
|
||||
- Preserve KCP transport behavior and the existing envelope/handler programming model while separating reusable core from host-specific orchestration.
|
||||
- Keep Unity main-thread dispatch as a supported client strategy rather than regressing stage four.
|
||||
|
||||
**Non-Goals:**
|
||||
- Build the full dedicated server application, deployment pipeline, or gameplay authority model.
|
||||
- Redesign connection/login/heartbeat state machines from stage five.
|
||||
- Change protocol formats or replace KCP with a different transport.
|
||||
|
||||
## Decisions
|
||||
|
||||
### 1. Split networking into shared core vs host-specific adapters
|
||||
The refactor will treat transport/session/message-routing code as a shared foundation and move runtime bootstrapping into host adapters. The shared layer owns `ITransport`, `KcpTransport`, envelope parsing, handler registration, and dispatch abstractions. The client host retains Unity frame-loop integration and gameplay/UI handlers; the future server host will provide its own startup and ticking model.
|
||||
|
||||
Alternative considered: keep one assembly and rely on naming conventions only. Rejected because soft boundaries will erode quickly once server-specific code starts landing.
|
||||
|
||||
### 2. Replace hard-coded main-thread dispatch with an injected dispatcher contract
|
||||
`MessageManager` should depend on an interface such as `INetworkMessageDispatcher` that can enqueue and execute handler work according to host policy. The Unity client can implement it with a queued main-thread dispatcher; a single-threaded server can implement it with immediate execution or a dedicated server loop. This keeps message parsing shared while making execution policy explicit.
|
||||
|
||||
Alternative considered: let `MessageManager` keep constructing `MainThreadNetworkDispatcher` by default and override only on the server. Rejected because a Unity default still leaks client assumptions into shared code and makes tests less honest.
|
||||
|
||||
### 3. Keep Unity main-thread dispatch as a client-host requirement, not a shared-core requirement
|
||||
Stage four's thread-safety guarantee remains valid, but it belongs to the Unity client host rather than the shared message layer. The shared capability will state that hosts provide a dispatch strategy; the existing Unity dispatch capability will be narrowed to define how the client host pumps a main-thread dispatcher implementation.
|
||||
|
||||
Alternative considered: remove the `network-main-thread-dispatch` capability entirely and fold everything into the shared-core spec. Rejected because Unity-specific frame-loop guarantees are still valuable and testable on their own.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- [Boundary churn across files and assemblies] -> Move in small slices and keep tests running after each structural step.
|
||||
- [Client regressions while introducing host abstraction] -> Preserve current Unity behavior behind a client-specific dispatcher adapter and verify with existing edit mode tests.
|
||||
- [Server host semantics chosen too early] -> Specify only the abstraction and one minimal non-Unity host path; leave richer server lifecycle work for a later change.
|
||||
- [Over-generalizing the dispatcher contract] -> Keep the interface minimal: register work, drain or execute work, and expose only what shared message routing actually needs.
|
||||
|
||||
## Migration Plan
|
||||
|
||||
1. Introduce shared host-dispatch abstractions and move message routing to depend on them.
|
||||
2. Re-home or reorganize reusable network core code so it no longer depends on Unity host classes.
|
||||
3. Rebuild the Unity client host on top of the shared core plus a main-thread dispatcher adapter.
|
||||
4. Add a minimal non-Unity host path or tests that prove the same core can run without Unity-specific pumping.
|
||||
5. Update docs and TODO status once the shared foundation is in place.
|
||||
|
||||
## Open Questions
|
||||
|
||||
- Whether the shared core should be split by folder only or by asmdef/project boundary in the first pass.
|
||||
- Whether the initial server-facing host should use immediate dispatch or a queued single-thread loop for parity with future lifecycle work.
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
## Why
|
||||
|
||||
The project's transport and message pipeline are now strong enough to serve both client and server, but the current runtime assembly still mixes reusable networking code with Unity-specific hosting concerns such as `NetworkManager` and main-thread pumping. If the client and server continue to evolve separately, transport, protocol handling, and dispatch behavior will drift and the same bugs will be fixed twice.
|
||||
|
||||
## What Changes
|
||||
|
||||
- Extract the reusable transport, session, protocol-envelope, and message-routing core into a shared client/server networking foundation.
|
||||
- Introduce a host-side dispatcher abstraction so `MessageManager` depends on an injected dispatch strategy rather than a hard-coded Unity main-thread implementation.
|
||||
- Keep Unity-specific hosting, frame-loop pumping, and gameplay/UI handlers in the client host layer while enabling a non-Unity server host to use the same networking core.
|
||||
- Add tests and documentation that prove the same shared networking layer can run under both client-style and server-style hosting paths.
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
- `shared-network-foundation`: Defines the shared transport/message infrastructure that both client and server hosts use without depending on Unity-specific runtime classes.
|
||||
|
||||
### Modified Capabilities
|
||||
- `network-main-thread-dispatch`: Refine the threading requirement so Unity main-thread dispatch is a host-specific strategy layered on top of a host-injected dispatcher abstraction, not the only message execution model.
|
||||
|
||||
## Impact
|
||||
|
||||
- Affected code: `Assets/Scripts/Network/NetworkTransport/`, `Assets/Scripts/Network/NetworkApplication/`, `Assets/Scripts/NetworkManager.cs`, and new host/dispatcher abstraction code.
|
||||
- Affected architecture: shared networking core becomes independent from Unity `MonoBehaviour` hosting; client and server each provide their own runtime host and dispatch policy.
|
||||
- Dependencies: no new external packages expected, but assembly boundaries and tests will need to be reorganized to support shared code reuse.
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Runtime network host pumps the dispatcher each frame
|
||||
The Unity-side runtime network host SHALL integrate a client-specific main-thread dispatcher implementation into its frame loop so queued network work is drained regularly while the network stack is running. That Unity dispatcher implementation MUST satisfy the shared host-dispatch abstraction used by the shared message-routing layer, while the transport background thread responsibilities remain limited to socket receive, KCP input/update, and transport-level error handling.
|
||||
|
||||
#### Scenario: Network host drains queued messages during runtime
|
||||
- **WHEN** the Unity client runtime has started networking and a message is queued from the transport layer
|
||||
- **THEN** the Unity client host performs dispatcher draining during its Unity update loop
|
||||
- **THEN** the queued handler runs without the transport layer directly touching Unity objects
|
||||
|
||||
#### Scenario: Transport layer remains free of Unity object mutation
|
||||
- **WHEN** developers inspect the responsibilities of the transport receive path after the shared networking refactor
|
||||
- **THEN** they find socket receive, KCP processing, and enqueue/error handling only
|
||||
- **THEN** Unity object mutation and UI updates are performed through the Unity host's main-thread dispatcher implementation rather than the transport callback path
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
## ADDED Requirements
|
||||
|
||||
### Requirement: Shared network core is host-agnostic
|
||||
The project SHALL provide a shared network core that contains transport, session, envelope parsing, and message-routing behavior without depending on Unity-specific runtime host classes such as `MonoBehaviour` or frame-loop callbacks. Both client and server networking hosts MUST be able to use this shared core.
|
||||
|
||||
#### Scenario: Client host uses shared network core
|
||||
- **WHEN** the Unity client constructs its runtime networking stack
|
||||
- **THEN** it uses the shared transport and message-routing core for transport startup, sending, receiving, and handler registration
|
||||
- **THEN** Unity-specific logic remains in the client host adapter rather than in the shared core classes
|
||||
|
||||
#### Scenario: Server host can use the same core without Unity types
|
||||
- **WHEN** a server-side host constructs the runtime networking stack
|
||||
- **THEN** it can use the same shared transport and message-routing core without depending on Unity host classes
|
||||
- **THEN** server-specific startup and lifetime control are provided by a separate host adapter
|
||||
|
||||
### Requirement: Message routing uses a host-provided dispatcher strategy
|
||||
The shared message-routing layer SHALL execute received business handlers through a host-provided dispatcher abstraction rather than constructing a Unity-specific dispatcher internally. The host MUST be able to choose the dispatch strategy that matches its runtime model.
|
||||
|
||||
#### Scenario: Unity client injects a queued main-thread dispatcher
|
||||
- **WHEN** the Unity client constructs the shared message-routing layer
|
||||
- **THEN** it supplies a dispatcher implementation that queues work for later execution on the Unity main thread
|
||||
- **THEN** received handlers run according to that injected client dispatch strategy
|
||||
|
||||
#### Scenario: Server host injects a non-Unity dispatch strategy
|
||||
- **WHEN** a non-Unity server host constructs the shared message-routing layer
|
||||
- **THEN** it supplies a dispatcher implementation that does not rely on Unity frame-loop semantics
|
||||
- **THEN** the shared message-routing layer still processes received messages correctly through that host-selected strategy
|
||||
|
||||
### Requirement: Shared core preserves current transport and message contracts
|
||||
The shared client/server foundation SHALL preserve the existing `ITransport` send/receive contract and the envelope-based `MessageManager` routing model so client and server hosts exchange the same business payload format through the same transport abstractions.
|
||||
|
||||
#### Scenario: Shared hosts exchange the same envelope format
|
||||
- **WHEN** a client host sends a business message through the shared core to a server host using the shared core
|
||||
- **THEN** the message is encoded using the same envelope contract on the client side
|
||||
- **THEN** the server host decodes and routes it through the shared message-routing layer without a host-specific protocol fork
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
## 1. Shared Core Boundary
|
||||
|
||||
- [x] 1.1 Introduce a host-dispatch abstraction for the message layer so shared networking code no longer constructs `MainThreadNetworkDispatcher` internally.
|
||||
- [x] 1.2 Reorganize the reusable transport/message-routing code into a shared client/server network core boundary that does not depend on Unity host classes.
|
||||
|
||||
## 2. Client Host Refactor
|
||||
|
||||
- [x] 2.1 Update the Unity client host to build the networking stack from the shared core and inject a Unity main-thread dispatcher implementation explicitly.
|
||||
- [x] 2.2 Keep current client gameplay/UI handler behavior intact while moving Unity-specific frame-loop pumping and host lifecycle logic out of the shared core.
|
||||
|
||||
## 3. Server-Oriented Reuse Path
|
||||
|
||||
- [x] 3.1 Add a minimal non-Unity host path or test-only server host that constructs the same shared networking core with a non-Unity dispatcher strategy.
|
||||
- [x] 3.2 Verify that the shared client/server path preserves the existing envelope-based protocol and `ITransport` contract without introducing a protocol fork.
|
||||
|
||||
## 4. Verification And Documentation
|
||||
|
||||
- [x] 4.1 Add or update tests to cover injected dispatcher behavior, Unity host pumping, and non-Unity host reuse of the same core.
|
||||
- [x] 4.2 Run the relevant build/tests and update `CodeX-TODO.md` or related docs to reflect that the network foundation is now shared between client and server hosts.
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
# kcp-transport Specification
|
||||
# kcp-transport Specification
|
||||
|
||||
## Purpose
|
||||
TBD - created by archiving change introduce-kcp-transport. Update Purpose after archive.
|
||||
|
|
@ -46,4 +46,18 @@ The transport SHALL continue driving KCP timers for every active session while i
|
|||
- **WHEN** the application calls `Stop()` on a running transport
|
||||
- **THEN** the transport stops receiving new UDP datagrams
|
||||
- **THEN** the transport clears its active KCP session state before shutdown completes
|
||||
### Requirement: KCP is the sole reliable transport implementation
|
||||
The project SHALL expose `KcpTransport` as the only reliable `ITransport` implementation used by runtime networking paths. Reliable business messages, including login, heartbeat, player input, and player state synchronization, MUST continue to flow through KCP-backed sessions rather than any legacy reliable UDP compatibility class.
|
||||
|
||||
#### Scenario: Runtime networking uses KCP for reliable delivery
|
||||
- **WHEN** the application constructs the transport used by `MessageManager` for its normal runtime networking path
|
||||
- **THEN** that transport instance is `KcpTransport`
|
||||
- **THEN** reliable business payloads are sent and received through KCP session state
|
||||
|
||||
### Requirement: Legacy reliable UDP entry points are retired
|
||||
The codebase SHALL NOT keep a directly instantiable `ReliableUdpTransport` entry point that implies a second reliable delivery mechanism. If a non-reliable UDP transport is needed in the future, it MUST use a distinct name and MUST NOT claim reliable semantics.
|
||||
|
||||
#### Scenario: Legacy reliable transport is not available to callers
|
||||
- **WHEN** developers inspect the transport implementations available to runtime code
|
||||
- **THEN** they do not find a usable `ReliableUdpTransport` class representing reliable delivery
|
||||
- **THEN** the remaining transport naming makes the reliable-versus-unreliable boundary explicit
|
||||
|
|
|
|||
|
|
@ -0,0 +1,43 @@
|
|||
# network-main-thread-dispatch Specification
|
||||
|
||||
## Purpose
|
||||
Define the main-thread dispatch boundary between background transport callbacks and Unity runtime message handling.
|
||||
|
||||
## Requirements
|
||||
### Requirement: Transport callbacks enqueue network message dispatch work
|
||||
The network application layer SHALL place a thread-safe queue between `ITransport.OnReceive` callbacks and message handler execution. When a transport callback produces a valid application envelope, the callback path MUST enqueue dispatch work and return without invoking registered business handlers inline.
|
||||
|
||||
#### Scenario: Valid payload is deferred instead of dispatched inline
|
||||
- **WHEN** a transport implementation raises `OnReceive` with a valid encoded application message
|
||||
- **THEN** the message layer enqueues one dispatch work item for that message
|
||||
- **THEN** the registered handler is not executed during the transport callback itself
|
||||
|
||||
#### Scenario: Invalid payload does not block later queued messages
|
||||
- **WHEN** the transport callback receives malformed bytes followed by a valid application message
|
||||
- **THEN** the malformed payload is handled as an error without enqueuing executable work
|
||||
- **THEN** the later valid message can still be enqueued and processed normally
|
||||
|
||||
### Requirement: Main-thread drain executes queued handlers in receive order
|
||||
The runtime SHALL provide an explicit main-thread drain step that executes queued network dispatch work in FIFO order. Message handlers, gameplay state mutation, and UI-facing reactions triggered by received messages MUST run only through this main-thread drain path.
|
||||
|
||||
#### Scenario: Drain executes queued work on demand
|
||||
- **WHEN** one or more network messages have been enqueued from transport callbacks
|
||||
- **THEN** no registered handler runs until the main-thread dispatcher performs a drain step
|
||||
- **THEN** each queued handler executes during that drain step on the Unity main thread path
|
||||
|
||||
#### Scenario: Messages preserve receive order through the dispatcher
|
||||
- **WHEN** multiple valid messages are enqueued in sequence for the same runtime
|
||||
- **THEN** the main-thread dispatcher invokes their handlers in the same order they were enqueued
|
||||
|
||||
### Requirement: Runtime network host pumps the dispatcher each frame
|
||||
The Unity-side runtime network host SHALL integrate a client-specific main-thread dispatcher implementation into its frame loop so queued network work is drained regularly while the network stack is running. That Unity dispatcher implementation MUST satisfy the shared host-dispatch abstraction used by the shared message-routing layer, while the transport background thread responsibilities remain limited to socket receive, KCP input/update, and transport-level error handling.
|
||||
|
||||
#### Scenario: Network host drains queued messages during runtime
|
||||
- **WHEN** the Unity client runtime has started networking and a message is queued from the transport layer
|
||||
- **THEN** the Unity client host performs dispatcher draining during its Unity update loop
|
||||
- **THEN** the queued handler runs without the transport layer directly touching Unity objects
|
||||
|
||||
#### Scenario: Transport layer remains free of Unity object mutation
|
||||
- **WHEN** developers inspect the responsibilities of the transport receive path after the shared networking refactor
|
||||
- **THEN** they find socket receive, KCP processing, and enqueue/error handling only
|
||||
- **THEN** Unity object mutation and UI updates are performed through the Unity host's main-thread dispatcher implementation rather than the transport callback path
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
# shared-network-foundation Specification
|
||||
|
||||
## Purpose
|
||||
Define the shared transport and message-routing foundation that both client and server hosts use without depending on Unity-specific runtime host classes.
|
||||
|
||||
## Requirements
|
||||
### Requirement: Shared network core is host-agnostic
|
||||
The project SHALL provide a shared network core that contains transport, session, envelope parsing, and message-routing behavior without depending on Unity-specific runtime host classes such as `MonoBehaviour` or frame-loop callbacks. Both client and server networking hosts MUST be able to use this shared core.
|
||||
|
||||
#### Scenario: Client host uses shared network core
|
||||
- **WHEN** the Unity client constructs its runtime networking stack
|
||||
- **THEN** it uses the shared transport and message-routing core for transport startup, sending, receiving, and handler registration
|
||||
- **THEN** Unity-specific logic remains in the client host adapter rather than in the shared core classes
|
||||
|
||||
#### Scenario: Server host can use the same core without Unity types
|
||||
- **WHEN** a server-side host constructs the runtime networking stack
|
||||
- **THEN** it can use the same shared transport and message-routing core without depending on Unity host classes
|
||||
- **THEN** server-specific startup and lifetime control are provided by a separate host adapter
|
||||
|
||||
### Requirement: Message routing uses a host-provided dispatcher strategy
|
||||
The shared message-routing layer SHALL execute received business handlers through a host-provided dispatcher abstraction rather than constructing a Unity-specific dispatcher internally. The host MUST be able to choose the dispatch strategy that matches its runtime model.
|
||||
|
||||
#### Scenario: Unity client injects a queued main-thread dispatcher
|
||||
- **WHEN** the Unity client constructs the shared message-routing layer
|
||||
- **THEN** it supplies a dispatcher implementation that queues work for later execution on the Unity main thread
|
||||
- **THEN** received handlers run according to that injected client dispatch strategy
|
||||
|
||||
#### Scenario: Server host injects a non-Unity dispatch strategy
|
||||
- **WHEN** a non-Unity server host constructs the shared message-routing layer
|
||||
- **THEN** it supplies a dispatcher implementation that does not rely on Unity frame-loop semantics
|
||||
- **THEN** the shared message-routing layer still processes received messages correctly through that host-selected strategy
|
||||
|
||||
### Requirement: Shared core preserves current transport and message contracts
|
||||
The shared client/server foundation SHALL preserve the existing `ITransport` send/receive contract and the envelope-based `MessageManager` routing model so client and server hosts exchange the same business payload format through the same transport abstractions.
|
||||
|
||||
#### Scenario: Shared hosts exchange the same envelope format
|
||||
- **WHEN** a client host sends a business message through the shared core to a server host using the shared core
|
||||
- **THEN** the message is encoded using the same envelope contract on the client side
|
||||
- **THEN** the server host decodes and routes it through the shared message-routing layer without a host-specific protocol fork
|
||||
Loading…
Reference in New Issue