Compare commits
25 Commits
@esengine/
...
@esengine/
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4cf868a769 | ||
|
|
afdeb00b4d | ||
|
|
764ce67742 | ||
|
|
61a13baca2 | ||
|
|
1cfa64aa0f | ||
|
|
3b978384c7 | ||
|
|
10c3891abd | ||
|
|
18af48a0fc | ||
|
|
d4cef828e1 | ||
|
|
2d46ccf896 | ||
|
|
fb8bde6485 | ||
|
|
30437dc5d5 | ||
|
|
9f84c2f870 | ||
|
|
e9ea52d9b3 | ||
|
|
0662b07445 | ||
|
|
838cda91aa | ||
|
|
a000cc07d7 | ||
|
|
1316d7de49 | ||
|
|
9c41181875 | ||
|
|
9f3f9a547a | ||
|
|
18df9d1cda | ||
|
|
9a4b3388e0 | ||
|
|
66d5dc27f7 | ||
|
|
8a3e54cb45 | ||
|
|
b6f1235239 |
2
.github/workflows/release-changesets.yml
vendored
2
.github/workflows/release-changesets.yml
vendored
@@ -56,7 +56,9 @@ jobs:
|
|||||||
pnpm --filter "@esengine/network-protocols" build
|
pnpm --filter "@esengine/network-protocols" build
|
||||||
pnpm --filter "@esengine/rpc" build
|
pnpm --filter "@esengine/rpc" build
|
||||||
pnpm --filter "@esengine/network" build
|
pnpm --filter "@esengine/network" build
|
||||||
|
pnpm --filter "@esengine/server" build
|
||||||
pnpm --filter "@esengine/cli" build
|
pnpm --filter "@esengine/cli" build
|
||||||
|
pnpm --filter "create-esengine-server" build
|
||||||
|
|
||||||
- name: Create Release Pull Request or Publish
|
- name: Create Release Pull Request or Publish
|
||||||
id: changesets
|
id: changesets
|
||||||
|
|||||||
20
README.md
20
README.md
@@ -49,7 +49,12 @@ npm install @esengine/ecs-framework
|
|||||||
| **Timer** | Timer and cooldown systems | No |
|
| **Timer** | Timer and cooldown systems | No |
|
||||||
| **Spatial** | Spatial indexing and queries (QuadTree, Grid) | No |
|
| **Spatial** | Spatial indexing and queries (QuadTree, Grid) | No |
|
||||||
| **Pathfinding** | A* and navigation mesh pathfinding | No |
|
| **Pathfinding** | A* and navigation mesh pathfinding | No |
|
||||||
| **Network** | Client/server networking with TSRPC | No |
|
| **Procgen** | Procedural generation (noise, random, sampling) | No |
|
||||||
|
| **RPC** | High-performance RPC communication framework | No |
|
||||||
|
| **Server** | Game server framework with rooms, auth, rate limiting | No |
|
||||||
|
| **Network** | Client networking with prediction, AOI, delta compression | No |
|
||||||
|
| **Transaction** | Game transaction system with Redis/Memory storage | No |
|
||||||
|
| **World Streaming** | Open world chunk loading and streaming | No |
|
||||||
|
|
||||||
> All framework modules can be used standalone with any rendering engine.
|
> All framework modules can be used standalone with any rendering engine.
|
||||||
|
|
||||||
@@ -199,7 +204,12 @@ npm install @esengine/fsm # State machines
|
|||||||
npm install @esengine/timer # Timers & cooldowns
|
npm install @esengine/timer # Timers & cooldowns
|
||||||
npm install @esengine/spatial # Spatial indexing
|
npm install @esengine/spatial # Spatial indexing
|
||||||
npm install @esengine/pathfinding # Pathfinding
|
npm install @esengine/pathfinding # Pathfinding
|
||||||
npm install @esengine/network # Networking
|
npm install @esengine/procgen # Procedural generation
|
||||||
|
npm install @esengine/rpc # RPC framework
|
||||||
|
npm install @esengine/server # Game server
|
||||||
|
npm install @esengine/network # Client networking
|
||||||
|
npm install @esengine/transaction # Transaction system
|
||||||
|
npm install @esengine/world-streaming # World streaming
|
||||||
```
|
```
|
||||||
|
|
||||||
### ESEngine Runtime (Optional)
|
### ESEngine Runtime (Optional)
|
||||||
@@ -235,7 +245,11 @@ esengine/
|
|||||||
│ │ ├── spatial/ # Spatial queries
|
│ │ ├── spatial/ # Spatial queries
|
||||||
│ │ ├── pathfinding/ # Pathfinding
|
│ │ ├── pathfinding/ # Pathfinding
|
||||||
│ │ ├── procgen/ # Procedural generation
|
│ │ ├── procgen/ # Procedural generation
|
||||||
│ │ └── network/ # Networking
|
│ │ ├── rpc/ # RPC framework
|
||||||
|
│ │ ├── server/ # Game server
|
||||||
|
│ │ ├── network/ # Client networking
|
||||||
|
│ │ ├── transaction/ # Transaction system
|
||||||
|
│ │ └── world-streaming/ # World streaming
|
||||||
│ │
|
│ │
|
||||||
│ ├── engine/ # ESEngine runtime
|
│ ├── engine/ # ESEngine runtime
|
||||||
│ ├── rendering/ # Rendering modules
|
│ ├── rendering/ # Rendering modules
|
||||||
|
|||||||
20
README_CN.md
20
README_CN.md
@@ -49,7 +49,12 @@ npm install @esengine/ecs-framework
|
|||||||
| **定时器** | 定时器和冷却系统 | 否 |
|
| **定时器** | 定时器和冷却系统 | 否 |
|
||||||
| **空间索引** | 空间查询(四叉树、网格) | 否 |
|
| **空间索引** | 空间查询(四叉树、网格) | 否 |
|
||||||
| **寻路** | A* 和导航网格寻路 | 否 |
|
| **寻路** | A* 和导航网格寻路 | 否 |
|
||||||
| **网络** | 客户端/服务端网络通信 (TSRPC) | 否 |
|
| **程序化生成** | 噪声、随机、采样等生成算法 | 否 |
|
||||||
|
| **RPC** | 高性能 RPC 通信框架 | 否 |
|
||||||
|
| **服务端** | 游戏服务器框架,支持房间、认证、速率限制 | 否 |
|
||||||
|
| **网络** | 客户端网络,支持预测、AOI、增量压缩 | 否 |
|
||||||
|
| **事务系统** | 游戏事务系统,支持 Redis/内存存储 | 否 |
|
||||||
|
| **世界流送** | 开放世界分块加载和流送 | 否 |
|
||||||
|
|
||||||
> 所有框架模块都可以独立使用,无需依赖特定渲染引擎。
|
> 所有框架模块都可以独立使用,无需依赖特定渲染引擎。
|
||||||
|
|
||||||
@@ -199,7 +204,12 @@ npm install @esengine/fsm # 状态机
|
|||||||
npm install @esengine/timer # 定时器和冷却
|
npm install @esengine/timer # 定时器和冷却
|
||||||
npm install @esengine/spatial # 空间索引
|
npm install @esengine/spatial # 空间索引
|
||||||
npm install @esengine/pathfinding # 寻路
|
npm install @esengine/pathfinding # 寻路
|
||||||
npm install @esengine/network # 网络
|
npm install @esengine/procgen # 程序化生成
|
||||||
|
npm install @esengine/rpc # RPC 框架
|
||||||
|
npm install @esengine/server # 游戏服务器
|
||||||
|
npm install @esengine/network # 客户端网络
|
||||||
|
npm install @esengine/transaction # 事务系统
|
||||||
|
npm install @esengine/world-streaming # 世界流送
|
||||||
```
|
```
|
||||||
|
|
||||||
### ESEngine 运行时(可选)
|
### ESEngine 运行时(可选)
|
||||||
@@ -235,7 +245,11 @@ esengine/
|
|||||||
│ │ ├── spatial/ # 空间查询
|
│ │ ├── spatial/ # 空间查询
|
||||||
│ │ ├── pathfinding/ # 寻路
|
│ │ ├── pathfinding/ # 寻路
|
||||||
│ │ ├── procgen/ # 程序化生成
|
│ │ ├── procgen/ # 程序化生成
|
||||||
│ │ └── network/ # 网络
|
│ │ ├── rpc/ # RPC 框架
|
||||||
|
│ │ ├── server/ # 游戏服务器
|
||||||
|
│ │ ├── network/ # 客户端网络
|
||||||
|
│ │ ├── transaction/ # 事务系统
|
||||||
|
│ │ └── world-streaming/ # 世界流送
|
||||||
│ │
|
│ │
|
||||||
│ ├── engine/ # ESEngine 运行时
|
│ ├── engine/ # ESEngine 运行时
|
||||||
│ ├── rendering/ # 渲染模块
|
│ ├── rendering/ # 渲染模块
|
||||||
|
|||||||
@@ -255,6 +255,9 @@ export default defineConfig({
|
|||||||
translations: { en: 'RPC' },
|
translations: { en: 'RPC' },
|
||||||
items: [
|
items: [
|
||||||
{ label: '概述', slug: 'modules/rpc', translations: { en: 'Overview' } },
|
{ label: '概述', slug: 'modules/rpc', translations: { en: 'Overview' } },
|
||||||
|
{ label: '服务端', slug: 'modules/rpc/server', translations: { en: 'Server' } },
|
||||||
|
{ label: '客户端', slug: 'modules/rpc/client', translations: { en: 'Client' } },
|
||||||
|
{ label: '编解码', slug: 'modules/rpc/codec', translations: { en: 'Codec' } },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -264,10 +267,37 @@ export default defineConfig({
|
|||||||
{ label: '概述', slug: 'modules/network', translations: { en: 'Overview' } },
|
{ label: '概述', slug: 'modules/network', translations: { en: 'Overview' } },
|
||||||
{ label: '客户端', slug: 'modules/network/client', translations: { en: 'Client' } },
|
{ label: '客户端', slug: 'modules/network/client', translations: { en: 'Client' } },
|
||||||
{ label: '服务器', slug: 'modules/network/server', translations: { en: 'Server' } },
|
{ label: '服务器', slug: 'modules/network/server', translations: { en: 'Server' } },
|
||||||
|
{ label: '认证系统', slug: 'modules/network/auth', translations: { en: 'Authentication' } },
|
||||||
|
{ label: '速率限制', slug: 'modules/network/rate-limit', translations: { en: 'Rate Limiting' } },
|
||||||
{ label: '状态同步', slug: 'modules/network/sync', translations: { en: 'State Sync' } },
|
{ label: '状态同步', slug: 'modules/network/sync', translations: { en: 'State Sync' } },
|
||||||
|
{ label: '客户端预测', slug: 'modules/network/prediction', translations: { en: 'Prediction' } },
|
||||||
|
{ label: 'AOI 兴趣区域', slug: 'modules/network/aoi', translations: { en: 'AOI' } },
|
||||||
|
{ label: '增量压缩', slug: 'modules/network/delta', translations: { en: 'Delta Compression' } },
|
||||||
{ label: 'API 参考', slug: 'modules/network/api', translations: { en: 'API Reference' } },
|
{ label: 'API 参考', slug: 'modules/network/api', translations: { en: 'API Reference' } },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: '事务系统',
|
||||||
|
translations: { en: 'Transaction' },
|
||||||
|
items: [
|
||||||
|
{ label: '概述', slug: 'modules/transaction', translations: { en: 'Overview' } },
|
||||||
|
{ label: '核心概念', slug: 'modules/transaction/core', translations: { en: 'Core Concepts' } },
|
||||||
|
{ label: '存储层', slug: 'modules/transaction/storage', translations: { en: 'Storage Layer' } },
|
||||||
|
{ label: '操作', slug: 'modules/transaction/operations', translations: { en: 'Operations' } },
|
||||||
|
{ label: '分布式事务', slug: 'modules/transaction/distributed', translations: { en: 'Distributed' } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '世界流式加载',
|
||||||
|
translations: { en: 'World Streaming' },
|
||||||
|
items: [
|
||||||
|
{ label: '概述', slug: 'modules/world-streaming', translations: { en: 'Overview' } },
|
||||||
|
{ label: '区块管理', slug: 'modules/world-streaming/chunk-manager', translations: { en: 'Chunk Manager' } },
|
||||||
|
{ label: '流式系统', slug: 'modules/world-streaming/streaming-system', translations: { en: 'Streaming System' } },
|
||||||
|
{ label: '序列化', slug: 'modules/world-streaming/serialization', translations: { en: 'Serialization' } },
|
||||||
|
{ label: '实际示例', slug: 'modules/world-streaming/examples', translations: { en: 'Examples' } },
|
||||||
|
],
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -292,6 +322,8 @@ export default defineConfig({
|
|||||||
{ label: '@esengine/fsm', link: 'https://github.com/esengine/esengine/blob/master/packages/framework/fsm/CHANGELOG.md', attrs: { target: '_blank' } },
|
{ label: '@esengine/fsm', link: 'https://github.com/esengine/esengine/blob/master/packages/framework/fsm/CHANGELOG.md', attrs: { target: '_blank' } },
|
||||||
{ label: '@esengine/timer', link: 'https://github.com/esengine/esengine/blob/master/packages/framework/timer/CHANGELOG.md', attrs: { target: '_blank' } },
|
{ label: '@esengine/timer', link: 'https://github.com/esengine/esengine/blob/master/packages/framework/timer/CHANGELOG.md', attrs: { target: '_blank' } },
|
||||||
{ label: '@esengine/network', link: 'https://github.com/esengine/esengine/blob/master/packages/framework/network/CHANGELOG.md', attrs: { target: '_blank' } },
|
{ label: '@esengine/network', link: 'https://github.com/esengine/esengine/blob/master/packages/framework/network/CHANGELOG.md', attrs: { target: '_blank' } },
|
||||||
|
{ label: '@esengine/transaction', link: 'https://github.com/esengine/esengine/blob/master/packages/framework/transaction/CHANGELOG.md', attrs: { target: '_blank' } },
|
||||||
|
{ label: '@esengine/rpc', link: 'https://github.com/esengine/esengine/blob/master/packages/framework/rpc/CHANGELOG.md', attrs: { target: '_blank' } },
|
||||||
{ label: '@esengine/cli', link: 'https://github.com/esengine/esengine/blob/master/packages/tools/cli/CHANGELOG.md', attrs: { target: '_blank' } },
|
{ label: '@esengine/cli', link: 'https://github.com/esengine/esengine/blob/master/packages/tools/cli/CHANGELOG.md', attrs: { target: '_blank' } },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ ESEngine provides a rich set of modules that can be imported as needed.
|
|||||||
| [Timer](/en/modules/timer/) | `@esengine/timer` | Timer and cooldown system |
|
| [Timer](/en/modules/timer/) | `@esengine/timer` | Timer and cooldown system |
|
||||||
| [Spatial](/en/modules/spatial/) | `@esengine/spatial` | Spatial queries, AOI management |
|
| [Spatial](/en/modules/spatial/) | `@esengine/spatial` | Spatial queries, AOI management |
|
||||||
| [Pathfinding](/en/modules/pathfinding/) | `@esengine/pathfinding` | A* pathfinding, NavMesh navigation |
|
| [Pathfinding](/en/modules/pathfinding/) | `@esengine/pathfinding` | A* pathfinding, NavMesh navigation |
|
||||||
|
| [World Streaming](/en/modules/world-streaming/) | `@esengine/world-streaming` | Chunk-based world streaming for open worlds |
|
||||||
|
|
||||||
### Tools
|
### Tools
|
||||||
|
|
||||||
@@ -33,6 +34,7 @@ ESEngine provides a rich set of modules that can be imported as needed.
|
|||||||
| Module | Package | Description |
|
| Module | Package | Description |
|
||||||
|--------|---------|-------------|
|
|--------|---------|-------------|
|
||||||
| [Network](/en/modules/network/) | `@esengine/network` | Multiplayer game networking |
|
| [Network](/en/modules/network/) | `@esengine/network` | Multiplayer game networking |
|
||||||
|
| [Transaction](/en/modules/transaction/) | `@esengine/transaction` | Game transactions with distributed support |
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
|
|||||||
283
docs/src/content/docs/en/modules/network/aoi.md
Normal file
283
docs/src/content/docs/en/modules/network/aoi.md
Normal file
@@ -0,0 +1,283 @@
|
|||||||
|
---
|
||||||
|
title: "Area of Interest (AOI)"
|
||||||
|
description: "View range based network entity filtering"
|
||||||
|
---
|
||||||
|
|
||||||
|
AOI (Area of Interest) is a key technique in large-scale multiplayer games for optimizing network bandwidth. By only synchronizing entities within a player's view range, network traffic can be significantly reduced.
|
||||||
|
|
||||||
|
## NetworkAOISystem
|
||||||
|
|
||||||
|
`NetworkAOISystem` provides grid-based area of interest management.
|
||||||
|
|
||||||
|
### Enable AOI
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { NetworkPlugin } from '@esengine/network';
|
||||||
|
|
||||||
|
const networkPlugin = new NetworkPlugin({
|
||||||
|
enableAOI: true,
|
||||||
|
aoiConfig: {
|
||||||
|
cellSize: 100, // Grid cell size
|
||||||
|
defaultViewRange: 500, // Default view range
|
||||||
|
enabled: true,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await Core.installPlugin(networkPlugin);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Adding Observers
|
||||||
|
|
||||||
|
Each player that needs to receive sync data must be added as an observer:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Add observer when player joins
|
||||||
|
networkPlugin.registerPrefab('player', (scene, spawn) => {
|
||||||
|
const entity = scene.createEntity(`player_${spawn.netId}`);
|
||||||
|
|
||||||
|
// ... setup components
|
||||||
|
|
||||||
|
// Add player as AOI observer
|
||||||
|
networkPlugin.addAOIObserver(
|
||||||
|
spawn.netId, // Network ID
|
||||||
|
spawn.pos.x, // Initial X position
|
||||||
|
spawn.pos.y, // Initial Y position
|
||||||
|
600 // View range (optional)
|
||||||
|
);
|
||||||
|
|
||||||
|
return entity;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Remove observer when player leaves
|
||||||
|
networkPlugin.removeAOIObserver(playerNetId);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Updating Observer Position
|
||||||
|
|
||||||
|
When a player moves, update their AOI position:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Update in game loop or sync callback
|
||||||
|
networkPlugin.updateAOIObserverPosition(playerNetId, newX, newY);
|
||||||
|
```
|
||||||
|
|
||||||
|
## AOI Configuration
|
||||||
|
|
||||||
|
| Property | Type | Default | Description |
|
||||||
|
|----------|------|---------|-------------|
|
||||||
|
| `cellSize` | `number` | 100 | Grid cell size |
|
||||||
|
| `defaultViewRange` | `number` | 500 | Default view range |
|
||||||
|
| `enabled` | `boolean` | true | Whether AOI is enabled |
|
||||||
|
|
||||||
|
### Grid Size Recommendations
|
||||||
|
|
||||||
|
Grid size should be set based on game view range:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Recommendation: cellSize = defaultViewRange / 3 to / 5
|
||||||
|
aoiConfig: {
|
||||||
|
cellSize: 100,
|
||||||
|
defaultViewRange: 500, // Grid is about 1/5 of view range
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Query Interface
|
||||||
|
|
||||||
|
### Get Visible Entities
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Get all entities visible to player
|
||||||
|
const visibleEntities = networkPlugin.getVisibleEntities(playerNetId);
|
||||||
|
console.log('Visible entities:', visibleEntities);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Check Visibility
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Check if player can see an entity
|
||||||
|
if (networkPlugin.canSee(playerNetId, targetEntityNetId)) {
|
||||||
|
// Target is in view
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Event Listening
|
||||||
|
|
||||||
|
The AOI system triggers events when entities enter/exit view:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const aoiSystem = networkPlugin.aoiSystem;
|
||||||
|
|
||||||
|
if (aoiSystem) {
|
||||||
|
aoiSystem.addListener((event) => {
|
||||||
|
if (event.type === 'enter') {
|
||||||
|
console.log(`Entity ${event.targetNetId} entered view of ${event.observerNetId}`);
|
||||||
|
// Can send entity's initial state here
|
||||||
|
} else if (event.type === 'exit') {
|
||||||
|
console.log(`Entity ${event.targetNetId} left view of ${event.observerNetId}`);
|
||||||
|
// Can cleanup resources here
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Server-Side Filtering
|
||||||
|
|
||||||
|
AOI is most commonly used server-side to filter sync data for each client:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Server-side example
|
||||||
|
import { NetworkAOISystem, createNetworkAOISystem } from '@esengine/network';
|
||||||
|
|
||||||
|
class GameServer {
|
||||||
|
private aoiSystem = createNetworkAOISystem({
|
||||||
|
cellSize: 100,
|
||||||
|
defaultViewRange: 500,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Player joins
|
||||||
|
onPlayerJoin(playerId: number, x: number, y: number) {
|
||||||
|
this.aoiSystem.addObserver(playerId, x, y);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Player moves
|
||||||
|
onPlayerMove(playerId: number, x: number, y: number) {
|
||||||
|
this.aoiSystem.updateObserverPosition(playerId, x, y);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send sync data
|
||||||
|
broadcastSync(allEntities: EntitySyncState[]) {
|
||||||
|
for (const playerId of this.players) {
|
||||||
|
// Filter using AOI
|
||||||
|
const filteredEntities = this.aoiSystem.filterSyncData(
|
||||||
|
playerId,
|
||||||
|
allEntities
|
||||||
|
);
|
||||||
|
|
||||||
|
// Send only visible entities
|
||||||
|
this.sendToPlayer(playerId, { entities: filteredEntities });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## How It Works
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ Game World │
|
||||||
|
│ ┌─────┬─────┬─────┬─────┬─────┐ │
|
||||||
|
│ │ │ │ E │ │ │ │
|
||||||
|
│ ├─────┼─────┼─────┼─────┼─────┤ E = Enemy entity │
|
||||||
|
│ │ │ P │ ● │ │ │ P = Player │
|
||||||
|
│ ├─────┼─────┼─────┼─────┼─────┤ ● = Player view center │
|
||||||
|
│ │ │ │ E │ E │ │ ○ = View range │
|
||||||
|
│ ├─────┼─────┼─────┼─────┼─────┤ │
|
||||||
|
│ │ │ │ │ │ E │ Player only sees E in view│
|
||||||
|
│ └─────┴─────┴─────┴─────┴─────┘ │
|
||||||
|
│ │
|
||||||
|
│ View range (circle): Contains 3 enemies │
|
||||||
|
│ Grid optimization: Only check cells covered by view │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Grid Optimization
|
||||||
|
|
||||||
|
AOI uses spatial grid to accelerate queries:
|
||||||
|
|
||||||
|
1. **Add Entity**: Calculate grid cell based on position
|
||||||
|
2. **View Detection**: Only check cells covered by view range
|
||||||
|
3. **Move Update**: Update cell assignment when crossing cells
|
||||||
|
4. **Event Trigger**: Detect enter/exit view
|
||||||
|
|
||||||
|
## Dynamic View Range
|
||||||
|
|
||||||
|
Different player types can have different view ranges:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Regular player
|
||||||
|
networkPlugin.addAOIObserver(playerId, x, y, 500);
|
||||||
|
|
||||||
|
// VIP player (larger view)
|
||||||
|
networkPlugin.addAOIObserver(vipPlayerId, x, y, 800);
|
||||||
|
|
||||||
|
// Adjust view range at runtime
|
||||||
|
const aoiSystem = networkPlugin.aoiSystem;
|
||||||
|
if (aoiSystem) {
|
||||||
|
aoiSystem.updateObserverViewRange(playerId, 600);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
### 1. Server-Side Usage
|
||||||
|
|
||||||
|
AOI filtering should be done server-side; clients should not trust their own AOI judgment:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Filter on server before sending
|
||||||
|
const filtered = aoiSystem.filterSyncData(playerId, entities);
|
||||||
|
sendToClient(playerId, filtered);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Edge Handling
|
||||||
|
|
||||||
|
Add buffer zone at view edge to prevent flickering:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Add immediately when entering view
|
||||||
|
// Remove with delay when exiting (keep for 1-2 extra seconds)
|
||||||
|
aoiSystem.addListener((event) => {
|
||||||
|
if (event.type === 'exit') {
|
||||||
|
setTimeout(() => {
|
||||||
|
// Re-check if really exited
|
||||||
|
if (!aoiSystem.canSee(event.observerNetId, event.targetNetId)) {
|
||||||
|
removeFromClient(event.observerNetId, event.targetNetId);
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Large Entities
|
||||||
|
|
||||||
|
Large entities (like bosses) may need special handling:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Boss is always visible to everyone
|
||||||
|
function filterWithBoss(playerId: number, entities: EntitySyncState[]) {
|
||||||
|
const filtered = aoiSystem.filterSyncData(playerId, entities);
|
||||||
|
|
||||||
|
// Add boss entity
|
||||||
|
const bossState = entities.find(e => e.netId === bossNetId);
|
||||||
|
if (bossState && !filtered.includes(bossState)) {
|
||||||
|
filtered.push(bossState);
|
||||||
|
}
|
||||||
|
|
||||||
|
return filtered;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Performance Considerations
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Large-scale game recommended config
|
||||||
|
aoiConfig: {
|
||||||
|
cellSize: 200, // Larger grid reduces cell count
|
||||||
|
defaultViewRange: 800, // Set based on actual view
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Debugging
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const aoiSystem = networkPlugin.aoiSystem;
|
||||||
|
|
||||||
|
if (aoiSystem) {
|
||||||
|
console.log('AOI enabled:', aoiSystem.enabled);
|
||||||
|
console.log('Observer count:', aoiSystem.observerCount);
|
||||||
|
|
||||||
|
// Get visible entities for specific player
|
||||||
|
const visible = aoiSystem.getVisibleEntities(playerId);
|
||||||
|
console.log('Visible entities:', visible.length);
|
||||||
|
}
|
||||||
|
```
|
||||||
506
docs/src/content/docs/en/modules/network/auth.md
Normal file
506
docs/src/content/docs/en/modules/network/auth.md
Normal file
@@ -0,0 +1,506 @@
|
|||||||
|
---
|
||||||
|
title: "Authentication"
|
||||||
|
description: "Add authentication to your game server with JWT and Session providers"
|
||||||
|
---
|
||||||
|
|
||||||
|
The `@esengine/server` package includes a pluggable authentication system that supports JWT, session-based auth, and custom providers.
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
Authentication is included in the server package:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install @esengine/server jsonwebtoken
|
||||||
|
```
|
||||||
|
|
||||||
|
> Note: `jsonwebtoken` is an optional peer dependency, required only for JWT authentication.
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### JWT Authentication
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { createServer } from '@esengine/server'
|
||||||
|
import { withAuth, createJwtAuthProvider, withRoomAuth, requireAuth } from '@esengine/server/auth'
|
||||||
|
|
||||||
|
// Create JWT provider
|
||||||
|
const jwtProvider = createJwtAuthProvider({
|
||||||
|
secret: process.env.JWT_SECRET!,
|
||||||
|
expiresIn: 3600, // 1 hour
|
||||||
|
})
|
||||||
|
|
||||||
|
// Wrap server with authentication
|
||||||
|
const server = withAuth(await createServer({ port: 3000 }), {
|
||||||
|
provider: jwtProvider,
|
||||||
|
extractCredentials: (req) => {
|
||||||
|
const url = new URL(req.url ?? '', 'http://localhost')
|
||||||
|
return url.searchParams.get('token')
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Define authenticated room
|
||||||
|
class GameRoom extends withRoomAuth(Room, { requireAuth: true }) {
|
||||||
|
onJoin(player) {
|
||||||
|
console.log(`${player.user?.name} joined!`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
server.define('game', GameRoom)
|
||||||
|
await server.start()
|
||||||
|
```
|
||||||
|
|
||||||
|
## Auth Providers
|
||||||
|
|
||||||
|
### JWT Provider
|
||||||
|
|
||||||
|
Use JSON Web Tokens for stateless authentication:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { createJwtAuthProvider } from '@esengine/server/auth'
|
||||||
|
|
||||||
|
const jwtProvider = createJwtAuthProvider({
|
||||||
|
// Required: secret key
|
||||||
|
secret: 'your-secret-key',
|
||||||
|
|
||||||
|
// Optional: algorithm (default: HS256)
|
||||||
|
algorithm: 'HS256',
|
||||||
|
|
||||||
|
// Optional: expiration in seconds (default: 3600)
|
||||||
|
expiresIn: 3600,
|
||||||
|
|
||||||
|
// Optional: issuer for validation
|
||||||
|
issuer: 'my-game-server',
|
||||||
|
|
||||||
|
// Optional: audience for validation
|
||||||
|
audience: 'my-game-client',
|
||||||
|
|
||||||
|
// Optional: custom user extraction
|
||||||
|
getUser: async (payload) => {
|
||||||
|
// Fetch user from database
|
||||||
|
return await db.users.findById(payload.sub)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Sign a token (for login endpoints)
|
||||||
|
const token = jwtProvider.sign({
|
||||||
|
sub: user.id,
|
||||||
|
name: user.name,
|
||||||
|
roles: ['player'],
|
||||||
|
})
|
||||||
|
|
||||||
|
// Decode without verification (for debugging)
|
||||||
|
const payload = jwtProvider.decode(token)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Session Provider
|
||||||
|
|
||||||
|
Use server-side sessions for stateful authentication:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { createSessionAuthProvider, type ISessionStorage } from '@esengine/server/auth'
|
||||||
|
|
||||||
|
// Custom storage implementation
|
||||||
|
const storage: ISessionStorage = {
|
||||||
|
async get<T>(key: string): Promise<T | null> {
|
||||||
|
return await redis.get(key)
|
||||||
|
},
|
||||||
|
async set<T>(key: string, value: T): Promise<void> {
|
||||||
|
await redis.set(key, value)
|
||||||
|
},
|
||||||
|
async delete(key: string): Promise<boolean> {
|
||||||
|
return await redis.del(key) > 0
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionProvider = createSessionAuthProvider({
|
||||||
|
storage,
|
||||||
|
sessionTTL: 86400000, // 24 hours in ms
|
||||||
|
|
||||||
|
// Optional: validate user on each request
|
||||||
|
validateUser: (user) => !user.banned,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Create session (for login endpoints)
|
||||||
|
const sessionId = await sessionProvider.createSession(user, {
|
||||||
|
ipAddress: req.ip,
|
||||||
|
userAgent: req.headers['user-agent'],
|
||||||
|
})
|
||||||
|
|
||||||
|
// Revoke session (for logout)
|
||||||
|
await sessionProvider.revoke(sessionId)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Server Auth Mixin
|
||||||
|
|
||||||
|
The `withAuth` function wraps your server to add authentication:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { withAuth } from '@esengine/server/auth'
|
||||||
|
|
||||||
|
const server = withAuth(baseServer, {
|
||||||
|
// Required: auth provider
|
||||||
|
provider: jwtProvider,
|
||||||
|
|
||||||
|
// Required: extract credentials from request
|
||||||
|
extractCredentials: (req) => {
|
||||||
|
// From query string
|
||||||
|
return new URL(req.url, 'http://localhost').searchParams.get('token')
|
||||||
|
|
||||||
|
// Or from headers
|
||||||
|
// return req.headers['authorization']?.replace('Bearer ', '')
|
||||||
|
},
|
||||||
|
|
||||||
|
// Optional: handle auth failure
|
||||||
|
onAuthFailed: (conn, error) => {
|
||||||
|
console.log(`Auth failed: ${error}`)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### Accessing Auth Context
|
||||||
|
|
||||||
|
After authentication, the auth context is available on connections:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { getAuthContext } from '@esengine/server/auth'
|
||||||
|
|
||||||
|
server.onConnect = (conn) => {
|
||||||
|
const auth = getAuthContext(conn)
|
||||||
|
|
||||||
|
if (auth.isAuthenticated) {
|
||||||
|
console.log(`User ${auth.userId} connected`)
|
||||||
|
console.log(`Roles: ${auth.roles}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Room Auth Mixin
|
||||||
|
|
||||||
|
The `withRoomAuth` function adds authentication checks to rooms:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { withRoomAuth, type AuthPlayer } from '@esengine/server/auth'
|
||||||
|
|
||||||
|
interface User {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
roles: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
class GameRoom extends withRoomAuth<User>(Room, {
|
||||||
|
// Require authentication to join
|
||||||
|
requireAuth: true,
|
||||||
|
|
||||||
|
// Optional: require specific roles
|
||||||
|
allowedRoles: ['player', 'premium'],
|
||||||
|
|
||||||
|
// Optional: role check mode ('any' or 'all')
|
||||||
|
roleCheckMode: 'any',
|
||||||
|
}) {
|
||||||
|
// player has .auth and .user properties
|
||||||
|
onJoin(player: AuthPlayer<User>) {
|
||||||
|
console.log(`${player.user?.name} joined`)
|
||||||
|
console.log(`Is premium: ${player.auth.hasRole('premium')}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optional: custom auth validation
|
||||||
|
async onAuth(player: AuthPlayer<User>): Promise<boolean> {
|
||||||
|
// Additional validation logic
|
||||||
|
if (player.auth.hasRole('banned')) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
@onMessage('Chat')
|
||||||
|
handleChat(data: { text: string }, player: AuthPlayer<User>) {
|
||||||
|
this.broadcast('Chat', {
|
||||||
|
from: player.user?.name ?? 'Guest',
|
||||||
|
text: data.text,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### AuthPlayer Interface
|
||||||
|
|
||||||
|
Players in auth rooms have additional properties:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface AuthPlayer<TUser> extends Player {
|
||||||
|
// Full auth context
|
||||||
|
readonly auth: IAuthContext<TUser>
|
||||||
|
|
||||||
|
// User info (shortcut for auth.user)
|
||||||
|
readonly user: TUser | null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Room Auth Helpers
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
class GameRoom extends withRoomAuth<User>(Room) {
|
||||||
|
someMethod() {
|
||||||
|
// Get player by user ID
|
||||||
|
const player = this.getPlayerByUserId('user-123')
|
||||||
|
|
||||||
|
// Get all players with a role
|
||||||
|
const admins = this.getPlayersByRole('admin')
|
||||||
|
|
||||||
|
// Get player with auth info
|
||||||
|
const authPlayer = this.getAuthPlayer(playerId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Auth Decorators
|
||||||
|
|
||||||
|
### @requireAuth
|
||||||
|
|
||||||
|
Mark message handlers as requiring authentication:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { requireAuth, requireRole, onMessage } from '@esengine/server/auth'
|
||||||
|
|
||||||
|
class GameRoom extends withRoomAuth(Room) {
|
||||||
|
@requireAuth()
|
||||||
|
@onMessage('Trade')
|
||||||
|
handleTrade(data: TradeData, player: AuthPlayer) {
|
||||||
|
// Only authenticated players can trade
|
||||||
|
}
|
||||||
|
|
||||||
|
@requireAuth({ allowGuest: true })
|
||||||
|
@onMessage('Chat')
|
||||||
|
handleChat(data: ChatData, player: AuthPlayer) {
|
||||||
|
// Guests can also chat
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### @requireRole
|
||||||
|
|
||||||
|
Require specific roles for message handlers:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
class AdminRoom extends withRoomAuth(Room) {
|
||||||
|
@requireRole('admin')
|
||||||
|
@onMessage('Ban')
|
||||||
|
handleBan(data: BanData, player: AuthPlayer) {
|
||||||
|
// Only admins can ban
|
||||||
|
}
|
||||||
|
|
||||||
|
@requireRole(['moderator', 'admin'])
|
||||||
|
@onMessage('Mute')
|
||||||
|
handleMute(data: MuteData, player: AuthPlayer) {
|
||||||
|
// Moderators OR admins can mute
|
||||||
|
}
|
||||||
|
|
||||||
|
@requireRole(['verified', 'premium'], { mode: 'all' })
|
||||||
|
@onMessage('SpecialFeature')
|
||||||
|
handleSpecial(data: any, player: AuthPlayer) {
|
||||||
|
// Requires BOTH verified AND premium roles
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Auth Context API
|
||||||
|
|
||||||
|
The auth context provides various methods for checking authentication state:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface IAuthContext<TUser> {
|
||||||
|
// Authentication state
|
||||||
|
readonly isAuthenticated: boolean
|
||||||
|
readonly user: TUser | null
|
||||||
|
readonly userId: string | null
|
||||||
|
readonly roles: ReadonlyArray<string>
|
||||||
|
readonly authenticatedAt: number | null
|
||||||
|
readonly expiresAt: number | null
|
||||||
|
|
||||||
|
// Role checking
|
||||||
|
hasRole(role: string): boolean
|
||||||
|
hasAnyRole(roles: string[]): boolean
|
||||||
|
hasAllRoles(roles: string[]): boolean
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The `AuthContext` class (implementation) also provides:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
class AuthContext<TUser> implements IAuthContext<TUser> {
|
||||||
|
// Set authentication from result
|
||||||
|
setAuthenticated(result: AuthResult<TUser>): void
|
||||||
|
|
||||||
|
// Clear authentication state
|
||||||
|
clear(): void
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
Use the mock auth provider for unit tests:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { createMockAuthProvider } from '@esengine/server/auth/testing'
|
||||||
|
|
||||||
|
// Create mock provider with preset users
|
||||||
|
const mockProvider = createMockAuthProvider({
|
||||||
|
users: [
|
||||||
|
{ id: '1', name: 'Alice', roles: ['player'] },
|
||||||
|
{ id: '2', name: 'Bob', roles: ['admin', 'player'] },
|
||||||
|
],
|
||||||
|
autoCreate: true, // Create users for unknown tokens
|
||||||
|
})
|
||||||
|
|
||||||
|
// Use in tests
|
||||||
|
const server = withAuth(testServer, {
|
||||||
|
provider: mockProvider,
|
||||||
|
extractCredentials: (req) => req.headers['x-token'],
|
||||||
|
})
|
||||||
|
|
||||||
|
// Verify with user ID as token
|
||||||
|
const result = await mockProvider.verify('1')
|
||||||
|
// result.user = { id: '1', name: 'Alice', roles: ['player'] }
|
||||||
|
|
||||||
|
// Add/remove users dynamically
|
||||||
|
mockProvider.addUser({ id: '3', name: 'Charlie', roles: ['guest'] })
|
||||||
|
mockProvider.removeUser('3')
|
||||||
|
|
||||||
|
// Revoke tokens
|
||||||
|
await mockProvider.revoke('1')
|
||||||
|
|
||||||
|
// Reset to initial state
|
||||||
|
mockProvider.clear()
|
||||||
|
```
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
Auth errors include error codes for programmatic handling:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
type AuthErrorCode =
|
||||||
|
| 'INVALID_CREDENTIALS' // Invalid username/password
|
||||||
|
| 'INVALID_TOKEN' // Token is malformed or invalid
|
||||||
|
| 'EXPIRED_TOKEN' // Token has expired
|
||||||
|
| 'USER_NOT_FOUND' // User lookup failed
|
||||||
|
| 'ACCOUNT_DISABLED' // User account is disabled
|
||||||
|
| 'RATE_LIMITED' // Too many requests
|
||||||
|
| 'INSUFFICIENT_PERMISSIONS' // Insufficient permissions
|
||||||
|
|
||||||
|
// In your auth failure handler
|
||||||
|
const server = withAuth(baseServer, {
|
||||||
|
provider: jwtProvider,
|
||||||
|
extractCredentials,
|
||||||
|
onAuthFailed: (conn, error) => {
|
||||||
|
switch (error.errorCode) {
|
||||||
|
case 'EXPIRED_TOKEN':
|
||||||
|
conn.send('AuthError', { code: 'TOKEN_EXPIRED' })
|
||||||
|
break
|
||||||
|
case 'INVALID_TOKEN':
|
||||||
|
conn.send('AuthError', { code: 'INVALID_TOKEN' })
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
conn.close()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Complete Example
|
||||||
|
|
||||||
|
Here's a complete example with JWT authentication:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// server.ts
|
||||||
|
import { createServer } from '@esengine/server'
|
||||||
|
import {
|
||||||
|
withAuth,
|
||||||
|
withRoomAuth,
|
||||||
|
createJwtAuthProvider,
|
||||||
|
requireAuth,
|
||||||
|
requireRole,
|
||||||
|
type AuthPlayer,
|
||||||
|
} from '@esengine/server/auth'
|
||||||
|
|
||||||
|
// Types
|
||||||
|
interface User {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
roles: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
// JWT Provider
|
||||||
|
const jwtProvider = createJwtAuthProvider<User>({
|
||||||
|
secret: process.env.JWT_SECRET!,
|
||||||
|
expiresIn: 3600,
|
||||||
|
getUser: async (payload) => ({
|
||||||
|
id: payload.sub as string,
|
||||||
|
name: payload.name as string,
|
||||||
|
roles: (payload.roles as string[]) ?? [],
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Create authenticated server
|
||||||
|
const server = withAuth(
|
||||||
|
await createServer({ port: 3000 }),
|
||||||
|
{
|
||||||
|
provider: jwtProvider,
|
||||||
|
extractCredentials: (req) => {
|
||||||
|
return new URL(req.url ?? '', 'http://localhost')
|
||||||
|
.searchParams.get('token')
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// Game Room with auth
|
||||||
|
class GameRoom extends withRoomAuth<User>(Room, {
|
||||||
|
requireAuth: true,
|
||||||
|
allowedRoles: ['player'],
|
||||||
|
}) {
|
||||||
|
onCreate() {
|
||||||
|
console.log('Game room created')
|
||||||
|
}
|
||||||
|
|
||||||
|
onJoin(player: AuthPlayer<User>) {
|
||||||
|
console.log(`${player.user?.name} joined!`)
|
||||||
|
this.broadcast('PlayerJoined', {
|
||||||
|
id: player.id,
|
||||||
|
name: player.user?.name,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
@requireAuth()
|
||||||
|
@onMessage('Move')
|
||||||
|
handleMove(data: { x: number; y: number }, player: AuthPlayer<User>) {
|
||||||
|
// Handle movement
|
||||||
|
}
|
||||||
|
|
||||||
|
@requireRole('admin')
|
||||||
|
@onMessage('Kick')
|
||||||
|
handleKick(data: { playerId: string }, player: AuthPlayer<User>) {
|
||||||
|
const target = this.getPlayer(data.playerId)
|
||||||
|
if (target) {
|
||||||
|
this.kick(target, 'Kicked by admin')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
server.define('game', GameRoom)
|
||||||
|
await server.start()
|
||||||
|
```
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
1. **Secure your secrets**: Never hardcode JWT secrets. Use environment variables.
|
||||||
|
|
||||||
|
2. **Set reasonable expiration**: Balance security and user experience when setting token TTL.
|
||||||
|
|
||||||
|
3. **Validate on critical actions**: Use `@requireAuth` on sensitive message handlers.
|
||||||
|
|
||||||
|
4. **Use role-based access**: Implement proper role hierarchy for admin functions.
|
||||||
|
|
||||||
|
5. **Handle token refresh**: Implement token refresh logic for long sessions.
|
||||||
|
|
||||||
|
6. **Log auth events**: Track login attempts and failures for security monitoring.
|
||||||
|
|
||||||
|
7. **Test auth flows**: Use `MockAuthProvider` to test authentication scenarios.
|
||||||
316
docs/src/content/docs/en/modules/network/delta.md
Normal file
316
docs/src/content/docs/en/modules/network/delta.md
Normal file
@@ -0,0 +1,316 @@
|
|||||||
|
---
|
||||||
|
title: "State Delta Compression"
|
||||||
|
description: "Reduce network bandwidth with incremental sync"
|
||||||
|
---
|
||||||
|
|
||||||
|
State delta compression reduces network bandwidth by only sending fields that have changed. For frequently synchronized game state, this can significantly reduce data transmission.
|
||||||
|
|
||||||
|
## StateDeltaCompressor
|
||||||
|
|
||||||
|
The `StateDeltaCompressor` class is used to compress and decompress state deltas.
|
||||||
|
|
||||||
|
### Basic Usage
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { createStateDeltaCompressor, type SyncData } from '@esengine/network';
|
||||||
|
|
||||||
|
// Create compressor
|
||||||
|
const compressor = createStateDeltaCompressor({
|
||||||
|
positionThreshold: 0.01, // Position change threshold
|
||||||
|
rotationThreshold: 0.001, // Rotation change threshold (radians)
|
||||||
|
velocityThreshold: 0.1, // Velocity change threshold
|
||||||
|
fullSnapshotInterval: 60, // Full snapshot interval (frames)
|
||||||
|
});
|
||||||
|
|
||||||
|
// Compress sync data
|
||||||
|
const syncData: SyncData = {
|
||||||
|
frame: 100,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
entities: [
|
||||||
|
{ netId: 1, pos: { x: 100, y: 200 }, rot: 0 },
|
||||||
|
{ netId: 2, pos: { x: 300, y: 400 }, rot: 1.5 },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const deltaData = compressor.compress(syncData);
|
||||||
|
// deltaData only contains changed fields
|
||||||
|
|
||||||
|
// Decompress delta data
|
||||||
|
const fullData = compressor.decompress(deltaData);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration Options
|
||||||
|
|
||||||
|
| Property | Type | Default | Description |
|
||||||
|
|----------|------|---------|-------------|
|
||||||
|
| `positionThreshold` | `number` | 0.01 | Position change threshold |
|
||||||
|
| `rotationThreshold` | `number` | 0.001 | Rotation change threshold (radians) |
|
||||||
|
| `velocityThreshold` | `number` | 0.1 | Velocity change threshold |
|
||||||
|
| `fullSnapshotInterval` | `number` | 60 | Full snapshot interval (frames) |
|
||||||
|
|
||||||
|
## Delta Flags
|
||||||
|
|
||||||
|
Bit flags indicate which fields have changed:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { DeltaFlags } from '@esengine/network';
|
||||||
|
|
||||||
|
// Flag definitions
|
||||||
|
DeltaFlags.NONE // 0 - No change
|
||||||
|
DeltaFlags.POSITION // 1 - Position changed
|
||||||
|
DeltaFlags.ROTATION // 2 - Rotation changed
|
||||||
|
DeltaFlags.VELOCITY // 4 - Velocity changed
|
||||||
|
DeltaFlags.ANGULAR_VELOCITY // 8 - Angular velocity changed
|
||||||
|
DeltaFlags.CUSTOM // 16 - Custom data changed
|
||||||
|
```
|
||||||
|
|
||||||
|
## Data Format
|
||||||
|
|
||||||
|
### Full State
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface EntitySyncState {
|
||||||
|
netId: number;
|
||||||
|
pos?: { x: number; y: number };
|
||||||
|
rot?: number;
|
||||||
|
vel?: { x: number; y: number };
|
||||||
|
angVel?: number;
|
||||||
|
custom?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Delta State
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface EntityDeltaState {
|
||||||
|
netId: number;
|
||||||
|
flags: number; // Change flags
|
||||||
|
pos?: { x: number; y: number }; // Only present when POSITION flag set
|
||||||
|
rot?: number; // Only present when ROTATION flag set
|
||||||
|
vel?: { x: number; y: number }; // Only present when VELOCITY flag set
|
||||||
|
angVel?: number; // Only present when ANGULAR_VELOCITY flag set
|
||||||
|
custom?: Record<string, unknown>; // Only present when CUSTOM flag set
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## How It Works
|
||||||
|
|
||||||
|
```
|
||||||
|
Frame 1 (full snapshot):
|
||||||
|
Entity 1: pos=(100, 200), rot=0
|
||||||
|
|
||||||
|
Frame 2 (delta):
|
||||||
|
Entity 1: flags=POSITION, pos=(101, 200) // Only X changed
|
||||||
|
|
||||||
|
Frame 3 (delta):
|
||||||
|
Entity 1: flags=0 // No change, not sent
|
||||||
|
|
||||||
|
Frame 4 (delta):
|
||||||
|
Entity 1: flags=POSITION|ROTATION, pos=(105, 200), rot=0.5
|
||||||
|
|
||||||
|
Frame 60 (forced full snapshot):
|
||||||
|
Entity 1: pos=(200, 300), rot=1.0, vel=(5, 0)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Server-Side Usage
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { createStateDeltaCompressor } from '@esengine/network';
|
||||||
|
|
||||||
|
class GameServer {
|
||||||
|
private compressor = createStateDeltaCompressor();
|
||||||
|
|
||||||
|
// Broadcast state updates
|
||||||
|
broadcastState(entities: EntitySyncState[]) {
|
||||||
|
const syncData: SyncData = {
|
||||||
|
frame: this.currentFrame,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
entities,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Compress data
|
||||||
|
const deltaData = this.compressor.compress(syncData);
|
||||||
|
|
||||||
|
// Send delta data
|
||||||
|
this.broadcast('sync', deltaData);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup when player leaves
|
||||||
|
onPlayerLeave(netId: number) {
|
||||||
|
this.compressor.removeEntity(netId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Client-Side Usage
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
class GameClient {
|
||||||
|
private compressor = createStateDeltaCompressor();
|
||||||
|
|
||||||
|
// Receive delta data
|
||||||
|
onSyncReceived(deltaData: DeltaSyncData) {
|
||||||
|
// Decompress to full state
|
||||||
|
const fullData = this.compressor.decompress(deltaData);
|
||||||
|
|
||||||
|
// Apply state
|
||||||
|
for (const entity of fullData.entities) {
|
||||||
|
this.applyEntityState(entity);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Bandwidth Savings Example
|
||||||
|
|
||||||
|
Assume each entity has the following data:
|
||||||
|
|
||||||
|
| Field | Size (bytes) |
|
||||||
|
|-------|-------------|
|
||||||
|
| netId | 4 |
|
||||||
|
| pos.x | 8 |
|
||||||
|
| pos.y | 8 |
|
||||||
|
| rot | 8 |
|
||||||
|
| vel.x | 8 |
|
||||||
|
| vel.y | 8 |
|
||||||
|
| angVel | 8 |
|
||||||
|
| **Total** | **52** |
|
||||||
|
|
||||||
|
With delta compression:
|
||||||
|
|
||||||
|
| Scenario | Original | Compressed | Savings |
|
||||||
|
|----------|----------|------------|---------|
|
||||||
|
| Only position changed | 52 | 4+1+16 = 21 | 60% |
|
||||||
|
| Only rotation changed | 52 | 4+1+8 = 13 | 75% |
|
||||||
|
| Stationary | 52 | 0 | 100% |
|
||||||
|
| Position + rotation changed | 52 | 4+1+24 = 29 | 44% |
|
||||||
|
|
||||||
|
## Forcing Full Snapshot
|
||||||
|
|
||||||
|
Some situations require sending full snapshots:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// When new player joins
|
||||||
|
compressor.forceFullSnapshot();
|
||||||
|
const data = compressor.compress(syncData);
|
||||||
|
// This will send full state
|
||||||
|
|
||||||
|
// On reconnection
|
||||||
|
compressor.clear(); // Clear history
|
||||||
|
compressor.forceFullSnapshot();
|
||||||
|
```
|
||||||
|
|
||||||
|
## Custom Data
|
||||||
|
|
||||||
|
Support for syncing custom game data:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const syncData: SyncData = {
|
||||||
|
frame: 100,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
entities: [
|
||||||
|
{
|
||||||
|
netId: 1,
|
||||||
|
pos: { x: 100, y: 200 },
|
||||||
|
custom: {
|
||||||
|
health: 80,
|
||||||
|
mana: 50,
|
||||||
|
buffs: ['speed', 'shield'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Custom data is also delta compressed
|
||||||
|
const deltaData = compressor.compress(syncData);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
### 1. Set Appropriate Thresholds
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// High precision games (e.g., competitive)
|
||||||
|
const compressor = createStateDeltaCompressor({
|
||||||
|
positionThreshold: 0.001,
|
||||||
|
rotationThreshold: 0.0001,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Casual games
|
||||||
|
const compressor = createStateDeltaCompressor({
|
||||||
|
positionThreshold: 0.1,
|
||||||
|
rotationThreshold: 0.01,
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Adjust Full Snapshot Interval
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// High reliability (unstable network)
|
||||||
|
fullSnapshotInterval: 30, // Full snapshot every 30 frames
|
||||||
|
|
||||||
|
// Low bandwidth priority
|
||||||
|
fullSnapshotInterval: 120, // Full snapshot every 120 frames
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Combine with AOI
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Filter with AOI first, then delta compress
|
||||||
|
const filteredEntities = aoiSystem.filterSyncData(playerId, allEntities);
|
||||||
|
const syncData = { frame, timestamp, entities: filteredEntities };
|
||||||
|
const deltaData = compressor.compress(syncData);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Handle Entity Removal
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Clean up compressor state when entity despawns
|
||||||
|
function onEntityDespawn(netId: number) {
|
||||||
|
compressor.removeEntity(netId);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Integration with Other Features
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────┐
|
||||||
|
│ Game State │
|
||||||
|
└────────┬────────┘
|
||||||
|
│
|
||||||
|
┌────────▼────────┐
|
||||||
|
│ AOI Filter │ ← Only process entities in view
|
||||||
|
└────────┬────────┘
|
||||||
|
│
|
||||||
|
┌────────▼────────┐
|
||||||
|
│ Delta Compress │ ← Only send changed fields
|
||||||
|
└────────┬────────┘
|
||||||
|
│
|
||||||
|
┌────────▼────────┐
|
||||||
|
│ Network Send │
|
||||||
|
└─────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## Debugging
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const compressor = createStateDeltaCompressor();
|
||||||
|
|
||||||
|
// Check compression efficiency
|
||||||
|
const original = syncData;
|
||||||
|
const compressed = compressor.compress(original);
|
||||||
|
|
||||||
|
console.log('Original entities:', original.entities.length);
|
||||||
|
console.log('Compressed entities:', compressed.entities.length);
|
||||||
|
console.log('Is full snapshot:', compressed.isFullSnapshot);
|
||||||
|
|
||||||
|
// View each entity's changes
|
||||||
|
for (const delta of compressed.entities) {
|
||||||
|
console.log(`Entity ${delta.netId}:`, {
|
||||||
|
hasPosition: !!(delta.flags & DeltaFlags.POSITION),
|
||||||
|
hasRotation: !!(delta.flags & DeltaFlags.ROTATION),
|
||||||
|
hasVelocity: !!(delta.flags & DeltaFlags.VELOCITY),
|
||||||
|
hasCustom: !!(delta.flags & DeltaFlags.CUSTOM),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -147,7 +147,10 @@ service.on('chat', (data) => {
|
|||||||
|
|
||||||
- [Client Usage](/en/modules/network/client/) - NetworkPlugin, components and systems
|
- [Client Usage](/en/modules/network/client/) - NetworkPlugin, components and systems
|
||||||
- [Server Side](/en/modules/network/server/) - GameServer and Room management
|
- [Server Side](/en/modules/network/server/) - GameServer and Room management
|
||||||
- [State Sync](/en/modules/network/sync/) - Interpolation, prediction and snapshots
|
- [State Sync](/en/modules/network/sync/) - Interpolation and snapshot buffering
|
||||||
|
- [Client Prediction](/en/modules/network/prediction/) - Input prediction and server reconciliation
|
||||||
|
- [Area of Interest (AOI)](/en/modules/network/aoi/) - View filtering and bandwidth optimization
|
||||||
|
- [Delta Compression](/en/modules/network/delta/) - State delta synchronization
|
||||||
- [API Reference](/en/modules/network/api/) - Complete API documentation
|
- [API Reference](/en/modules/network/api/) - Complete API documentation
|
||||||
|
|
||||||
## Service Tokens
|
## Service Tokens
|
||||||
@@ -159,10 +162,14 @@ import {
|
|||||||
NetworkServiceToken,
|
NetworkServiceToken,
|
||||||
NetworkSyncSystemToken,
|
NetworkSyncSystemToken,
|
||||||
NetworkSpawnSystemToken,
|
NetworkSpawnSystemToken,
|
||||||
NetworkInputSystemToken
|
NetworkInputSystemToken,
|
||||||
|
NetworkPredictionSystemToken,
|
||||||
|
NetworkAOISystemToken,
|
||||||
} from '@esengine/network';
|
} from '@esengine/network';
|
||||||
|
|
||||||
const networkService = services.get(NetworkServiceToken);
|
const networkService = services.get(NetworkServiceToken);
|
||||||
|
const predictionSystem = services.get(NetworkPredictionSystemToken);
|
||||||
|
const aoiSystem = services.get(NetworkAOISystemToken);
|
||||||
```
|
```
|
||||||
|
|
||||||
## Blueprint Nodes
|
## Blueprint Nodes
|
||||||
|
|||||||
254
docs/src/content/docs/en/modules/network/prediction.md
Normal file
254
docs/src/content/docs/en/modules/network/prediction.md
Normal file
@@ -0,0 +1,254 @@
|
|||||||
|
---
|
||||||
|
title: "Client Prediction"
|
||||||
|
description: "Local input prediction and server reconciliation"
|
||||||
|
---
|
||||||
|
|
||||||
|
Client prediction is a key technique in networked games to reduce input latency. By immediately applying player inputs locally while waiting for server confirmation, games feel more responsive.
|
||||||
|
|
||||||
|
## NetworkPredictionSystem
|
||||||
|
|
||||||
|
`NetworkPredictionSystem` is an ECS system dedicated to handling local player prediction.
|
||||||
|
|
||||||
|
### Basic Usage
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { NetworkPlugin } from '@esengine/network';
|
||||||
|
|
||||||
|
const networkPlugin = new NetworkPlugin({
|
||||||
|
enablePrediction: true,
|
||||||
|
predictionConfig: {
|
||||||
|
moveSpeed: 200, // Movement speed (units/second)
|
||||||
|
maxUnacknowledgedInputs: 60, // Max unacknowledged inputs
|
||||||
|
reconciliationThreshold: 0.5, // Reconciliation threshold
|
||||||
|
reconciliationSpeed: 10, // Reconciliation speed
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await Core.installPlugin(networkPlugin);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Setting Up Local Player
|
||||||
|
|
||||||
|
After the local player entity spawns, set its network ID:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
networkPlugin.registerPrefab('player', (scene, spawn) => {
|
||||||
|
const entity = scene.createEntity(`player_${spawn.netId}`);
|
||||||
|
|
||||||
|
const identity = entity.addComponent(new NetworkIdentity());
|
||||||
|
identity.netId = spawn.netId;
|
||||||
|
identity.ownerId = spawn.ownerId;
|
||||||
|
identity.bHasAuthority = spawn.ownerId === networkPlugin.localPlayerId;
|
||||||
|
identity.bIsLocalPlayer = identity.bHasAuthority;
|
||||||
|
|
||||||
|
entity.addComponent(new NetworkTransform());
|
||||||
|
|
||||||
|
// Set local player for prediction
|
||||||
|
if (identity.bIsLocalPlayer) {
|
||||||
|
networkPlugin.setLocalPlayerNetId(spawn.netId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return entity;
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Sending Input
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Send movement input in game loop
|
||||||
|
function onUpdate() {
|
||||||
|
const moveX = Input.getAxis('horizontal');
|
||||||
|
const moveY = Input.getAxis('vertical');
|
||||||
|
|
||||||
|
if (moveX !== 0 || moveY !== 0) {
|
||||||
|
networkPlugin.sendMoveInput(moveX, moveY);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send action input
|
||||||
|
if (Input.isPressed('attack')) {
|
||||||
|
networkPlugin.sendActionInput('attack');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Prediction Configuration
|
||||||
|
|
||||||
|
| Property | Type | Default | Description |
|
||||||
|
|----------|------|---------|-------------|
|
||||||
|
| `moveSpeed` | `number` | 200 | Movement speed (units/second) |
|
||||||
|
| `enabled` | `boolean` | true | Whether prediction is enabled |
|
||||||
|
| `maxUnacknowledgedInputs` | `number` | 60 | Max unacknowledged inputs |
|
||||||
|
| `reconciliationThreshold` | `number` | 0.5 | Position difference threshold for reconciliation |
|
||||||
|
| `reconciliationSpeed` | `number` | 10 | Reconciliation smoothing speed |
|
||||||
|
|
||||||
|
## How It Works
|
||||||
|
|
||||||
|
```
|
||||||
|
Client Server
|
||||||
|
│ │
|
||||||
|
├─ 1. Capture input (seq=1) │
|
||||||
|
├─ 2. Predict movement locally │
|
||||||
|
├─ 3. Send input to server ─────────►
|
||||||
|
│ │
|
||||||
|
├─ 4. Continue capturing (seq=2,3...) │
|
||||||
|
├─ 5. Continue predicting │
|
||||||
|
│ │
|
||||||
|
│ ├─ 6. Process input (seq=1)
|
||||||
|
│ │
|
||||||
|
◄──────── 7. Return state (ackSeq=1) ─
|
||||||
|
│ │
|
||||||
|
├─ 8. Compare prediction with server │
|
||||||
|
├─ 9. Replay inputs seq=2,3... │
|
||||||
|
├─ 10. Smooth correction │
|
||||||
|
│ │
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step by Step
|
||||||
|
|
||||||
|
1. **Input Capture**: Capture player input and assign sequence number
|
||||||
|
2. **Local Prediction**: Immediately apply input to local state
|
||||||
|
3. **Send Input**: Send input to server
|
||||||
|
4. **Cache Input**: Save input for later reconciliation
|
||||||
|
5. **Receive Acknowledgment**: Server returns authoritative state with ack sequence
|
||||||
|
6. **State Comparison**: Compare predicted state with server state
|
||||||
|
7. **Input Replay**: Recalculate state using cached unacknowledged inputs
|
||||||
|
8. **Smooth Correction**: Interpolate smoothly to correct position
|
||||||
|
|
||||||
|
## Low-Level API
|
||||||
|
|
||||||
|
For fine-grained control, use the `ClientPrediction` class directly:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { createClientPrediction, type IPredictor } from '@esengine/network';
|
||||||
|
|
||||||
|
// Define state type
|
||||||
|
interface PlayerState {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
rotation: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Define input type
|
||||||
|
interface PlayerInput {
|
||||||
|
dx: number;
|
||||||
|
dy: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Define predictor
|
||||||
|
const predictor: IPredictor<PlayerState, PlayerInput> = {
|
||||||
|
predict(state: PlayerState, input: PlayerInput, dt: number): PlayerState {
|
||||||
|
return {
|
||||||
|
x: state.x + input.dx * MOVE_SPEED * dt,
|
||||||
|
y: state.y + input.dy * MOVE_SPEED * dt,
|
||||||
|
rotation: state.rotation,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create client prediction
|
||||||
|
const prediction = createClientPrediction(predictor, {
|
||||||
|
maxUnacknowledgedInputs: 60,
|
||||||
|
reconciliationThreshold: 0.5,
|
||||||
|
reconciliationSpeed: 10,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Record input and get predicted state
|
||||||
|
const input = { dx: 1, dy: 0 };
|
||||||
|
const predictedState = prediction.recordInput(input, currentState, deltaTime);
|
||||||
|
|
||||||
|
// Get input to send
|
||||||
|
const inputToSend = prediction.getInputToSend();
|
||||||
|
|
||||||
|
// Reconcile with server state
|
||||||
|
prediction.reconcile(
|
||||||
|
serverState,
|
||||||
|
serverAckSeq,
|
||||||
|
(state) => ({ x: state.x, y: state.y }),
|
||||||
|
deltaTime
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get correction offset
|
||||||
|
const offset = prediction.correctionOffset;
|
||||||
|
```
|
||||||
|
|
||||||
|
## Enable/Disable Prediction
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Toggle prediction at runtime
|
||||||
|
networkPlugin.setPredictionEnabled(false);
|
||||||
|
|
||||||
|
// Check prediction status
|
||||||
|
if (networkPlugin.isPredictionEnabled) {
|
||||||
|
console.log('Prediction is active');
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
### 1. Set Appropriate Reconciliation Threshold
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Action games: lower threshold, more precise
|
||||||
|
predictionConfig: {
|
||||||
|
reconciliationThreshold: 0.1,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Casual games: higher threshold, smoother
|
||||||
|
predictionConfig: {
|
||||||
|
reconciliationThreshold: 1.0,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Prediction Only for Local Player
|
||||||
|
|
||||||
|
Remote players should use interpolation, not prediction:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const identity = entity.getComponent(NetworkIdentity);
|
||||||
|
|
||||||
|
if (identity.bIsLocalPlayer) {
|
||||||
|
// Use prediction system
|
||||||
|
} else {
|
||||||
|
// Use NetworkSyncSystem interpolation
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Handle High Latency
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// High latency network: increase buffer
|
||||||
|
predictionConfig: {
|
||||||
|
maxUnacknowledgedInputs: 120, // Increase buffer
|
||||||
|
reconciliationSpeed: 5, // Slower correction
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Deterministic Prediction
|
||||||
|
|
||||||
|
Ensure client and server use the same physics calculations:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Use fixed timestep
|
||||||
|
const FIXED_DT = 1 / 60;
|
||||||
|
|
||||||
|
function applyInput(state: PlayerState, input: PlayerInput): PlayerState {
|
||||||
|
// Use fixed timestep instead of actual deltaTime
|
||||||
|
return {
|
||||||
|
x: state.x + input.dx * MOVE_SPEED * FIXED_DT,
|
||||||
|
y: state.y + input.dy * MOVE_SPEED * FIXED_DT,
|
||||||
|
rotation: state.rotation,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Debugging
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Get prediction system instance
|
||||||
|
const predictionSystem = networkPlugin.predictionSystem;
|
||||||
|
|
||||||
|
if (predictionSystem) {
|
||||||
|
console.log('Pending inputs:', predictionSystem.pendingInputCount);
|
||||||
|
console.log('Current sequence:', predictionSystem.inputSequence);
|
||||||
|
}
|
||||||
|
```
|
||||||
458
docs/src/content/docs/en/modules/network/rate-limit.md
Normal file
458
docs/src/content/docs/en/modules/network/rate-limit.md
Normal file
@@ -0,0 +1,458 @@
|
|||||||
|
---
|
||||||
|
title: "Rate Limiting"
|
||||||
|
description: "Protect your game server from abuse with configurable rate limiting"
|
||||||
|
---
|
||||||
|
|
||||||
|
The `@esengine/server` package includes a pluggable rate limiting system to protect against DDoS attacks, message flooding, and other abuse.
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
Rate limiting is included in the server package:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install @esengine/server
|
||||||
|
```
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { Room, onMessage } from '@esengine/server'
|
||||||
|
import { withRateLimit, rateLimit, noRateLimit } from '@esengine/server/ratelimit'
|
||||||
|
|
||||||
|
class GameRoom extends withRateLimit(Room, {
|
||||||
|
messagesPerSecond: 10,
|
||||||
|
burstSize: 20,
|
||||||
|
onLimited: (player, type, result) => {
|
||||||
|
player.send('Error', {
|
||||||
|
code: 'RATE_LIMITED',
|
||||||
|
retryAfter: result.retryAfter,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}) {
|
||||||
|
@onMessage('Move')
|
||||||
|
handleMove(data: { x: number; y: number }, player: Player) {
|
||||||
|
// Protected by rate limit (10 msg/s default)
|
||||||
|
}
|
||||||
|
|
||||||
|
@rateLimit({ messagesPerSecond: 1 })
|
||||||
|
@onMessage('Trade')
|
||||||
|
handleTrade(data: TradeData, player: Player) {
|
||||||
|
// Stricter limit for trading
|
||||||
|
}
|
||||||
|
|
||||||
|
@noRateLimit()
|
||||||
|
@onMessage('Heartbeat')
|
||||||
|
handleHeartbeat(data: any, player: Player) {
|
||||||
|
// No rate limit for heartbeat
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Rate Limit Strategies
|
||||||
|
|
||||||
|
### Token Bucket (Default)
|
||||||
|
|
||||||
|
The token bucket algorithm allows burst traffic while maintaining long-term rate limits. Tokens are added at a fixed rate, and each request consumes tokens.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { withRateLimit } from '@esengine/server/ratelimit'
|
||||||
|
|
||||||
|
class GameRoom extends withRateLimit(Room, {
|
||||||
|
strategy: 'token-bucket',
|
||||||
|
messagesPerSecond: 10, // Refill rate
|
||||||
|
burstSize: 20, // Bucket capacity
|
||||||
|
}) { }
|
||||||
|
```
|
||||||
|
|
||||||
|
**How it works:**
|
||||||
|
```
|
||||||
|
Config: rate=10/s, burstSize=20
|
||||||
|
|
||||||
|
[0s] Bucket full: 20 tokens
|
||||||
|
[0s] 15 messages → allowed, 5 remaining
|
||||||
|
[0.5s] Refill 5 tokens → 10 tokens
|
||||||
|
[0.5s] 8 messages → allowed, 2 remaining
|
||||||
|
[0.6s] Refill 1 token → 3 tokens
|
||||||
|
[0.6s] 5 messages → 3 allowed, 2 rejected
|
||||||
|
```
|
||||||
|
|
||||||
|
**Best for:** Most general use cases, balances burst tolerance with protection.
|
||||||
|
|
||||||
|
### Sliding Window
|
||||||
|
|
||||||
|
The sliding window algorithm precisely tracks requests within a time window. More accurate than fixed window but uses slightly more memory.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
class GameRoom extends withRateLimit(Room, {
|
||||||
|
strategy: 'sliding-window',
|
||||||
|
messagesPerSecond: 10,
|
||||||
|
burstSize: 10,
|
||||||
|
}) { }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Best for:** When you need precise rate limiting without burst tolerance.
|
||||||
|
|
||||||
|
### Fixed Window
|
||||||
|
|
||||||
|
The fixed window algorithm divides time into fixed intervals and counts requests per interval. Simple and memory-efficient but allows 2x burst at window boundaries.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
class GameRoom extends withRateLimit(Room, {
|
||||||
|
strategy: 'fixed-window',
|
||||||
|
messagesPerSecond: 10,
|
||||||
|
burstSize: 10,
|
||||||
|
}) { }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Best for:** Simple scenarios where boundary burst is acceptable.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
### Room Configuration
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { withRateLimit } from '@esengine/server/ratelimit'
|
||||||
|
|
||||||
|
class GameRoom extends withRateLimit(Room, {
|
||||||
|
// Messages allowed per second (default: 10)
|
||||||
|
messagesPerSecond: 10,
|
||||||
|
|
||||||
|
// Burst capacity / bucket size (default: 20)
|
||||||
|
burstSize: 20,
|
||||||
|
|
||||||
|
// Strategy: 'token-bucket' | 'sliding-window' | 'fixed-window'
|
||||||
|
strategy: 'token-bucket',
|
||||||
|
|
||||||
|
// Callback when rate limited
|
||||||
|
onLimited: (player, messageType, result) => {
|
||||||
|
player.send('RateLimited', {
|
||||||
|
type: messageType,
|
||||||
|
retryAfter: result.retryAfter,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
// Disconnect on rate limit (default: false)
|
||||||
|
disconnectOnLimit: false,
|
||||||
|
|
||||||
|
// Disconnect after N consecutive limits (0 = never)
|
||||||
|
maxConsecutiveLimits: 10,
|
||||||
|
|
||||||
|
// Custom key function (default: player.id)
|
||||||
|
getKey: (player) => player.id,
|
||||||
|
|
||||||
|
// Cleanup interval in ms (default: 60000)
|
||||||
|
cleanupInterval: 60000,
|
||||||
|
}) { }
|
||||||
|
```
|
||||||
|
|
||||||
|
### Per-Message Configuration
|
||||||
|
|
||||||
|
Use decorators to configure rate limits for specific messages:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { rateLimit, noRateLimit, rateLimitMessage } from '@esengine/server/ratelimit'
|
||||||
|
|
||||||
|
class GameRoom extends withRateLimit(Room) {
|
||||||
|
// Custom rate limit for this message
|
||||||
|
@rateLimit({ messagesPerSecond: 1, burstSize: 2 })
|
||||||
|
@onMessage('Trade')
|
||||||
|
handleTrade(data: TradeData, player: Player) { }
|
||||||
|
|
||||||
|
// This message costs 5 tokens
|
||||||
|
@rateLimit({ cost: 5 })
|
||||||
|
@onMessage('ExpensiveAction')
|
||||||
|
handleExpensive(data: any, player: Player) { }
|
||||||
|
|
||||||
|
// Exempt from rate limiting
|
||||||
|
@noRateLimit()
|
||||||
|
@onMessage('Heartbeat')
|
||||||
|
handleHeartbeat(data: any, player: Player) { }
|
||||||
|
|
||||||
|
// Alternative: specify message type explicitly
|
||||||
|
@rateLimitMessage('SpecialAction', { messagesPerSecond: 2 })
|
||||||
|
@onMessage('SpecialAction')
|
||||||
|
handleSpecial(data: any, player: Player) { }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Combining with Authentication
|
||||||
|
|
||||||
|
Rate limiting works seamlessly with the authentication system:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { withRoomAuth } from '@esengine/server/auth'
|
||||||
|
import { withRateLimit } from '@esengine/server/ratelimit'
|
||||||
|
|
||||||
|
// Apply both mixins
|
||||||
|
class GameRoom extends withRateLimit(
|
||||||
|
withRoomAuth(Room, { requireAuth: true }),
|
||||||
|
{ messagesPerSecond: 10 }
|
||||||
|
) {
|
||||||
|
onJoin(player: AuthPlayer) {
|
||||||
|
console.log(`${player.user?.name} joined with rate limit protection`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Rate Limit Result
|
||||||
|
|
||||||
|
When a message is rate limited, the callback receives a result object:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface RateLimitResult {
|
||||||
|
// Whether the request was allowed
|
||||||
|
allowed: boolean
|
||||||
|
|
||||||
|
// Remaining quota
|
||||||
|
remaining: number
|
||||||
|
|
||||||
|
// When the quota resets (timestamp)
|
||||||
|
resetAt: number
|
||||||
|
|
||||||
|
// How long to wait before retrying (ms)
|
||||||
|
retryAfter?: number
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Accessing Rate Limit Context
|
||||||
|
|
||||||
|
You can access the rate limit context for any player:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { getPlayerRateLimitContext } from '@esengine/server/ratelimit'
|
||||||
|
|
||||||
|
class GameRoom extends withRateLimit(Room) {
|
||||||
|
someMethod(player: Player) {
|
||||||
|
const context = this.getRateLimitContext(player)
|
||||||
|
|
||||||
|
// Check without consuming
|
||||||
|
const status = context?.check()
|
||||||
|
console.log(`Remaining: ${status?.remaining}`)
|
||||||
|
|
||||||
|
// Get consecutive limit count
|
||||||
|
console.log(`Consecutive limits: ${context?.consecutiveLimitCount}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Or use the standalone function
|
||||||
|
const context = getPlayerRateLimitContext(player)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Custom Strategies
|
||||||
|
|
||||||
|
You can use the strategies directly for custom implementations:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import {
|
||||||
|
TokenBucketStrategy,
|
||||||
|
SlidingWindowStrategy,
|
||||||
|
FixedWindowStrategy,
|
||||||
|
createTokenBucketStrategy,
|
||||||
|
} from '@esengine/server/ratelimit'
|
||||||
|
|
||||||
|
// Create strategy directly
|
||||||
|
const strategy = createTokenBucketStrategy({
|
||||||
|
rate: 10, // tokens per second
|
||||||
|
capacity: 20, // max tokens
|
||||||
|
})
|
||||||
|
|
||||||
|
// Check and consume
|
||||||
|
const result = strategy.consume('player-123')
|
||||||
|
if (result.allowed) {
|
||||||
|
// Process message
|
||||||
|
} else {
|
||||||
|
// Rate limited, wait result.retryAfter ms
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check without consuming
|
||||||
|
const status = strategy.getStatus('player-123')
|
||||||
|
|
||||||
|
// Reset a key
|
||||||
|
strategy.reset('player-123')
|
||||||
|
|
||||||
|
// Cleanup expired records
|
||||||
|
strategy.cleanup()
|
||||||
|
```
|
||||||
|
|
||||||
|
## Rate Limit Context
|
||||||
|
|
||||||
|
The `RateLimitContext` class manages rate limiting for a single player:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { RateLimitContext, TokenBucketStrategy } from '@esengine/server/ratelimit'
|
||||||
|
|
||||||
|
const strategy = new TokenBucketStrategy({ rate: 10, capacity: 20 })
|
||||||
|
const context = new RateLimitContext('player-123', strategy)
|
||||||
|
|
||||||
|
// Check without consuming
|
||||||
|
context.check()
|
||||||
|
|
||||||
|
// Consume quota
|
||||||
|
context.consume()
|
||||||
|
|
||||||
|
// Consume with cost
|
||||||
|
context.consume(undefined, 5)
|
||||||
|
|
||||||
|
// Consume for specific message type
|
||||||
|
context.consume('Trade')
|
||||||
|
|
||||||
|
// Set per-message strategy
|
||||||
|
context.setMessageStrategy('Trade', new TokenBucketStrategy({ rate: 1, capacity: 2 }))
|
||||||
|
|
||||||
|
// Reset
|
||||||
|
context.reset()
|
||||||
|
|
||||||
|
// Get consecutive limit count
|
||||||
|
console.log(context.consecutiveLimitCount)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Room Lifecycle Hook
|
||||||
|
|
||||||
|
You can override the `onRateLimited` hook for custom handling:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
class GameRoom extends withRateLimit(Room) {
|
||||||
|
onRateLimited(player: Player, messageType: string, result: RateLimitResult) {
|
||||||
|
// Log the event
|
||||||
|
console.log(`Player ${player.id} rate limited on ${messageType}`)
|
||||||
|
|
||||||
|
// Send custom error
|
||||||
|
player.send('SystemMessage', {
|
||||||
|
type: 'warning',
|
||||||
|
message: `Slow down! Try again in ${result.retryAfter}ms`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
1. **Start with token bucket**: It's the most flexible algorithm for games.
|
||||||
|
|
||||||
|
2. **Set appropriate limits**: Consider your game's mechanics:
|
||||||
|
- Movement messages: Higher limits (20-60/s)
|
||||||
|
- Chat messages: Lower limits (1-5/s)
|
||||||
|
- Trade/purchase: Very low limits (0.5-1/s)
|
||||||
|
|
||||||
|
3. **Use burst capacity**: Allow short bursts for responsive gameplay:
|
||||||
|
```typescript
|
||||||
|
messagesPerSecond: 10,
|
||||||
|
burstSize: 30, // Allow 3s worth of burst
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Exempt critical messages**: Don't rate limit heartbeats or system messages:
|
||||||
|
```typescript
|
||||||
|
@noRateLimit()
|
||||||
|
@onMessage('Heartbeat')
|
||||||
|
handleHeartbeat() { }
|
||||||
|
```
|
||||||
|
|
||||||
|
5. **Combine with auth**: Rate limit by user ID for authenticated users:
|
||||||
|
```typescript
|
||||||
|
getKey: (player) => player.auth?.userId ?? player.id
|
||||||
|
```
|
||||||
|
|
||||||
|
6. **Monitor and adjust**: Log rate limit events to tune your limits:
|
||||||
|
```typescript
|
||||||
|
onLimited: (player, type, result) => {
|
||||||
|
metrics.increment('rate_limit', { messageType: type })
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
7. **Graceful degradation**: Send informative errors instead of just disconnecting:
|
||||||
|
```typescript
|
||||||
|
onLimited: (player, type, result) => {
|
||||||
|
player.send('Error', {
|
||||||
|
code: 'RATE_LIMITED',
|
||||||
|
message: 'Too many requests',
|
||||||
|
retryAfter: result.retryAfter,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Complete Example
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { Room, onMessage, type Player } from '@esengine/server'
|
||||||
|
import { withRoomAuth, type AuthPlayer } from '@esengine/server/auth'
|
||||||
|
import {
|
||||||
|
withRateLimit,
|
||||||
|
rateLimit,
|
||||||
|
noRateLimit,
|
||||||
|
type RateLimitResult,
|
||||||
|
} from '@esengine/server/ratelimit'
|
||||||
|
|
||||||
|
interface User {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
premium: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
// Combine auth and rate limit
|
||||||
|
class GameRoom extends withRateLimit(
|
||||||
|
withRoomAuth<User>(Room, { requireAuth: true }),
|
||||||
|
{
|
||||||
|
messagesPerSecond: 10,
|
||||||
|
burstSize: 30,
|
||||||
|
strategy: 'token-bucket',
|
||||||
|
|
||||||
|
// Use user ID for rate limiting
|
||||||
|
getKey: (player) => (player as AuthPlayer<User>).user?.id ?? player.id,
|
||||||
|
|
||||||
|
// Handle rate limits
|
||||||
|
onLimited: (player, type, result) => {
|
||||||
|
player.send('Error', {
|
||||||
|
code: 'RATE_LIMITED',
|
||||||
|
messageType: type,
|
||||||
|
retryAfter: result.retryAfter,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
// Disconnect after 20 consecutive rate limits
|
||||||
|
maxConsecutiveLimits: 20,
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
onCreate() {
|
||||||
|
console.log('Room created with auth + rate limit protection')
|
||||||
|
}
|
||||||
|
|
||||||
|
onJoin(player: AuthPlayer<User>) {
|
||||||
|
this.broadcast('PlayerJoined', { name: player.user?.name })
|
||||||
|
}
|
||||||
|
|
||||||
|
// High-frequency movement (default rate limit)
|
||||||
|
@onMessage('Move')
|
||||||
|
handleMove(data: { x: number; y: number }, player: AuthPlayer<User>) {
|
||||||
|
this.broadcast('PlayerMoved', { id: player.id, ...data })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Low-frequency trading (strict limit)
|
||||||
|
@rateLimit({ messagesPerSecond: 0.5, burstSize: 2 })
|
||||||
|
@onMessage('Trade')
|
||||||
|
handleTrade(data: TradeData, player: AuthPlayer<User>) {
|
||||||
|
// Process trade...
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chat with moderate limit
|
||||||
|
@rateLimit({ messagesPerSecond: 2, burstSize: 5 })
|
||||||
|
@onMessage('Chat')
|
||||||
|
handleChat(data: { text: string }, player: AuthPlayer<User>) {
|
||||||
|
this.broadcast('Chat', {
|
||||||
|
from: player.user?.name,
|
||||||
|
text: data.text,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// System messages - no limit
|
||||||
|
@noRateLimit()
|
||||||
|
@onMessage('Heartbeat')
|
||||||
|
handleHeartbeat(data: any, player: Player) {
|
||||||
|
player.send('Pong', { time: Date.now() })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Custom rate limit handling
|
||||||
|
onRateLimited(player: Player, messageType: string, result: RateLimitResult) {
|
||||||
|
console.warn(`[RateLimit] Player ${player.id} limited on ${messageType}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -1,76 +1,335 @@
|
|||||||
---
|
---
|
||||||
title: "Server Side"
|
title: "Server Side"
|
||||||
description: "GameServer and Room management"
|
description: "Build game servers with @esengine/server"
|
||||||
---
|
---
|
||||||
|
|
||||||
## GameServer
|
## Quick Start
|
||||||
|
|
||||||
GameServer is the core server-side class managing WebSocket connections and rooms.
|
Create a new game server project using the CLI:
|
||||||
|
|
||||||
### Basic Usage
|
```bash
|
||||||
|
# Using npm
|
||||||
|
npm create esengine-server my-game-server
|
||||||
|
|
||||||
|
# Using pnpm
|
||||||
|
pnpm create esengine-server my-game-server
|
||||||
|
|
||||||
|
# Using yarn
|
||||||
|
yarn create esengine-server my-game-server
|
||||||
|
```
|
||||||
|
|
||||||
|
Generated project structure:
|
||||||
|
|
||||||
|
```
|
||||||
|
my-game-server/
|
||||||
|
├── src/
|
||||||
|
│ ├── shared/ # Shared protocol (client & server)
|
||||||
|
│ │ ├── protocol.ts # Type definitions
|
||||||
|
│ │ └── index.ts
|
||||||
|
│ ├── server/ # Server code
|
||||||
|
│ │ ├── main.ts # Entry point
|
||||||
|
│ │ └── rooms/
|
||||||
|
│ │ └── GameRoom.ts # Game room
|
||||||
|
│ └── client/ # Client example
|
||||||
|
│ └── index.ts
|
||||||
|
├── package.json
|
||||||
|
└── tsconfig.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Start the server:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Development mode (hot reload)
|
||||||
|
npm run dev
|
||||||
|
|
||||||
|
# Production mode
|
||||||
|
npm run start
|
||||||
|
```
|
||||||
|
|
||||||
|
## createServer
|
||||||
|
|
||||||
|
Create a game server instance:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { GameServer } from '@esengine/network-server';
|
import { createServer } from '@esengine/server'
|
||||||
|
import { GameRoom } from './rooms/GameRoom.js'
|
||||||
|
|
||||||
const server = new GameServer({
|
const server = await createServer({
|
||||||
port: 3000,
|
port: 3000,
|
||||||
roomConfig: {
|
onConnect(conn) {
|
||||||
maxPlayers: 16,
|
console.log('Client connected:', conn.id)
|
||||||
tickRate: 20
|
},
|
||||||
|
onDisconnect(conn) {
|
||||||
|
console.log('Client disconnected:', conn.id)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Register room type
|
||||||
|
server.define('game', GameRoom)
|
||||||
|
|
||||||
|
// Start server
|
||||||
|
await server.start()
|
||||||
|
```
|
||||||
|
|
||||||
|
### Configuration Options
|
||||||
|
|
||||||
|
| Property | Type | Default | Description |
|
||||||
|
|----------|------|---------|-------------|
|
||||||
|
| `port` | `number` | `3000` | WebSocket port |
|
||||||
|
| `tickRate` | `number` | `20` | Global tick rate (Hz) |
|
||||||
|
| `apiDir` | `string` | `'src/api'` | API handlers directory |
|
||||||
|
| `msgDir` | `string` | `'src/msg'` | Message handlers directory |
|
||||||
|
| `onStart` | `(port) => void` | - | Start callback |
|
||||||
|
| `onConnect` | `(conn) => void` | - | Connection callback |
|
||||||
|
| `onDisconnect` | `(conn) => void` | - | Disconnect callback |
|
||||||
|
|
||||||
|
## Room System
|
||||||
|
|
||||||
|
Room is the base class for game rooms, managing players and game state.
|
||||||
|
|
||||||
|
### Define a Room
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { Room, Player, onMessage } from '@esengine/server'
|
||||||
|
import type { MsgMove, MsgChat } from '../../shared/index.js'
|
||||||
|
|
||||||
|
interface PlayerData {
|
||||||
|
name: string
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export class GameRoom extends Room<{ players: any[] }, PlayerData> {
|
||||||
|
// Configuration
|
||||||
|
maxPlayers = 8
|
||||||
|
tickRate = 20
|
||||||
|
autoDispose = true
|
||||||
|
|
||||||
|
// Room state
|
||||||
|
state = {
|
||||||
|
players: [],
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
|
||||||
await server.start();
|
// Lifecycle
|
||||||
await server.stop();
|
onCreate() {
|
||||||
```
|
console.log(`Room ${this.id} created`)
|
||||||
|
}
|
||||||
|
|
||||||
### Configuration
|
onJoin(player: Player<PlayerData>) {
|
||||||
|
player.data.name = 'Player_' + player.id.slice(-4)
|
||||||
|
player.data.x = Math.random() * 800
|
||||||
|
player.data.y = Math.random() * 600
|
||||||
|
|
||||||
| Property | Type | Description |
|
this.broadcast('Joined', {
|
||||||
|----------|------|-------------|
|
playerId: player.id,
|
||||||
| `port` | `number` | WebSocket port |
|
playerName: player.data.name,
|
||||||
| `roomConfig.maxPlayers` | `number` | Max players per room |
|
})
|
||||||
| `roomConfig.tickRate` | `number` | Sync rate (Hz) |
|
}
|
||||||
|
|
||||||
## Room
|
onLeave(player: Player<PlayerData>) {
|
||||||
|
this.broadcast('Left', { playerId: player.id })
|
||||||
|
}
|
||||||
|
|
||||||
```typescript
|
onTick(dt: number) {
|
||||||
class Room {
|
// State synchronization
|
||||||
readonly id: string;
|
this.broadcast('Sync', { players: this.state.players })
|
||||||
readonly playerCount: number;
|
}
|
||||||
readonly isFull: boolean;
|
|
||||||
|
|
||||||
addPlayer(name: string, connection: Connection): IPlayer | null;
|
onDispose() {
|
||||||
removePlayer(clientId: number): void;
|
console.log(`Room ${this.id} disposed`)
|
||||||
getPlayer(clientId: number): IPlayer | undefined;
|
}
|
||||||
handleInput(clientId: number, input: IPlayerInput): void;
|
|
||||||
destroy(): void;
|
// Message handlers
|
||||||
|
@onMessage('Move')
|
||||||
|
handleMove(data: MsgMove, player: Player<PlayerData>) {
|
||||||
|
player.data.x = data.x
|
||||||
|
player.data.y = data.y
|
||||||
|
}
|
||||||
|
|
||||||
|
@onMessage('Chat')
|
||||||
|
handleChat(data: MsgChat, player: Player<PlayerData>) {
|
||||||
|
this.broadcast('Chat', {
|
||||||
|
from: player.data.name,
|
||||||
|
text: data.text,
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## Protocol Types
|
### Room Configuration
|
||||||
|
|
||||||
|
| Property | Type | Default | Description |
|
||||||
|
|----------|------|---------|-------------|
|
||||||
|
| `maxPlayers` | `number` | `10` | Maximum players |
|
||||||
|
| `tickRate` | `number` | `20` | Tick rate (Hz) |
|
||||||
|
| `autoDispose` | `boolean` | `true` | Auto-dispose empty rooms |
|
||||||
|
|
||||||
|
### Room API
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
interface MsgSync {
|
class Room<TState, TPlayerData> {
|
||||||
time: number;
|
readonly id: string // Room ID
|
||||||
entities: IEntityState[];
|
readonly players: Player[] // All players
|
||||||
|
readonly playerCount: number // Player count
|
||||||
|
readonly isLocked: boolean // Lock status
|
||||||
|
state: TState // Room state
|
||||||
|
|
||||||
|
// Broadcast to all players
|
||||||
|
broadcast<T>(type: string, data: T): void
|
||||||
|
|
||||||
|
// Broadcast to all except one
|
||||||
|
broadcastExcept<T>(type: string, data: T, except: Player): void
|
||||||
|
|
||||||
|
// Get player by ID
|
||||||
|
getPlayer(id: string): Player | undefined
|
||||||
|
|
||||||
|
// Kick a player
|
||||||
|
kick(player: Player, reason?: string): void
|
||||||
|
|
||||||
|
// Lock/unlock room
|
||||||
|
lock(): void
|
||||||
|
unlock(): void
|
||||||
|
|
||||||
|
// Dispose room
|
||||||
|
dispose(): void
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Lifecycle Methods
|
||||||
|
|
||||||
|
| Method | Trigger | Purpose |
|
||||||
|
|--------|---------|---------|
|
||||||
|
| `onCreate()` | Room created | Initialize game state |
|
||||||
|
| `onJoin(player)` | Player joins | Welcome message, assign position |
|
||||||
|
| `onLeave(player)` | Player leaves | Cleanup player data |
|
||||||
|
| `onTick(dt)` | Every frame | Game logic, state sync |
|
||||||
|
| `onDispose()` | Before disposal | Save data, cleanup resources |
|
||||||
|
|
||||||
|
## Player Class
|
||||||
|
|
||||||
|
Player represents a connected player in a room.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
class Player<TData = Record<string, unknown>> {
|
||||||
|
readonly id: string // Player ID
|
||||||
|
readonly roomId: string // Room ID
|
||||||
|
data: TData // Custom data
|
||||||
|
|
||||||
|
// Send message to this player
|
||||||
|
send<T>(type: string, data: T): void
|
||||||
|
|
||||||
|
// Leave room
|
||||||
|
leave(): void
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## @onMessage Decorator
|
||||||
|
|
||||||
|
Use decorators to simplify message handling:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { Room, Player, onMessage } from '@esengine/server'
|
||||||
|
|
||||||
|
class GameRoom extends Room {
|
||||||
|
@onMessage('Move')
|
||||||
|
handleMove(data: { x: number; y: number }, player: Player) {
|
||||||
|
// Handle movement
|
||||||
|
}
|
||||||
|
|
||||||
|
@onMessage('Attack')
|
||||||
|
handleAttack(data: { targetId: string }, player: Player) {
|
||||||
|
// Handle attack
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Protocol Definition
|
||||||
|
|
||||||
|
Define shared types in `src/shared/protocol.ts`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// API request/response
|
||||||
|
export interface JoinRoomReq {
|
||||||
|
roomType: string
|
||||||
|
playerName: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MsgSpawn {
|
export interface JoinRoomRes {
|
||||||
netId: number;
|
roomId: string
|
||||||
ownerId: number;
|
playerId: string
|
||||||
prefab: string;
|
|
||||||
pos: Vec2;
|
|
||||||
rot: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MsgDespawn {
|
// Game messages
|
||||||
netId: number;
|
export interface MsgMove {
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface MsgChat {
|
||||||
|
text: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Server broadcasts
|
||||||
|
export interface BroadcastSync {
|
||||||
|
players: PlayerState[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlayerState {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Client Connection
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { connect } from '@esengine/rpc/client'
|
||||||
|
|
||||||
|
const client = await connect('ws://localhost:3000')
|
||||||
|
|
||||||
|
// Join room
|
||||||
|
const { roomId, playerId } = await client.call('JoinRoom', {
|
||||||
|
roomType: 'game',
|
||||||
|
playerName: 'Alice',
|
||||||
|
})
|
||||||
|
|
||||||
|
// Listen for broadcasts
|
||||||
|
client.onMessage('Sync', (data) => {
|
||||||
|
console.log('State:', data.players)
|
||||||
|
})
|
||||||
|
|
||||||
|
client.onMessage('Joined', (data) => {
|
||||||
|
console.log('Player joined:', data.playerName)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Send message
|
||||||
|
client.send('RoomMessage', {
|
||||||
|
type: 'Move',
|
||||||
|
payload: { x: 100, y: 200 },
|
||||||
|
})
|
||||||
```
|
```
|
||||||
|
|
||||||
## Best Practices
|
## Best Practices
|
||||||
|
|
||||||
1. **Set appropriate tick rate**: Choose based on game type (20-60 Hz for action games)
|
1. **Set Appropriate Tick Rate**
|
||||||
2. **Room size control**: Set reasonable `maxPlayers` based on server capacity
|
- Turn-based games: 5-10 Hz
|
||||||
3. **State validation**: Server should validate client inputs to prevent cheating
|
- Casual games: 10-20 Hz
|
||||||
|
- Action games: 20-60 Hz
|
||||||
|
|
||||||
|
2. **Use Shared Protocol**
|
||||||
|
- Define all types in `shared/` directory
|
||||||
|
- Import from here in both client and server
|
||||||
|
|
||||||
|
3. **State Validation**
|
||||||
|
- Server should validate all client inputs
|
||||||
|
- Never trust client-sent data
|
||||||
|
|
||||||
|
4. **Disconnect Handling**
|
||||||
|
- Implement reconnection logic
|
||||||
|
- Use `onLeave` to save player state
|
||||||
|
|
||||||
|
5. **Room Lifecycle**
|
||||||
|
- Use `autoDispose` to clean up empty rooms
|
||||||
|
- Save important data in `onDispose`
|
||||||
|
|||||||
251
docs/src/content/docs/en/modules/rpc/client.md
Normal file
251
docs/src/content/docs/en/modules/rpc/client.md
Normal file
@@ -0,0 +1,251 @@
|
|||||||
|
---
|
||||||
|
title: "RPC Client API"
|
||||||
|
description: "RpcClient for connecting to RPC servers"
|
||||||
|
---
|
||||||
|
|
||||||
|
The `RpcClient` class provides a type-safe WebSocket client for RPC communication.
|
||||||
|
|
||||||
|
## Basic Usage
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { RpcClient } from '@esengine/rpc/client';
|
||||||
|
import { gameProtocol } from './protocol';
|
||||||
|
|
||||||
|
const client = new RpcClient(gameProtocol, 'ws://localhost:3000', {
|
||||||
|
onConnect: () => console.log('Connected'),
|
||||||
|
onDisconnect: (reason) => console.log('Disconnected:', reason),
|
||||||
|
onError: (error) => console.error('Error:', error),
|
||||||
|
});
|
||||||
|
|
||||||
|
await client.connect();
|
||||||
|
```
|
||||||
|
|
||||||
|
## Constructor Options
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface RpcClientOptions {
|
||||||
|
// Codec for serialization (default: json())
|
||||||
|
codec?: Codec;
|
||||||
|
|
||||||
|
// API call timeout in ms (default: 30000)
|
||||||
|
timeout?: number;
|
||||||
|
|
||||||
|
// Auto reconnect on disconnect (default: true)
|
||||||
|
autoReconnect?: boolean;
|
||||||
|
|
||||||
|
// Reconnect interval in ms (default: 3000)
|
||||||
|
reconnectInterval?: number;
|
||||||
|
|
||||||
|
// Custom WebSocket factory (for WeChat Mini Games, etc.)
|
||||||
|
webSocketFactory?: (url: string) => WebSocketAdapter;
|
||||||
|
|
||||||
|
// Callbacks
|
||||||
|
onConnect?: () => void;
|
||||||
|
onDisconnect?: (reason?: string) => void;
|
||||||
|
onError?: (error: Error) => void;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Connection
|
||||||
|
|
||||||
|
### Connect
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Connect returns a promise
|
||||||
|
await client.connect();
|
||||||
|
|
||||||
|
// Or chain
|
||||||
|
client.connect().then(() => {
|
||||||
|
console.log('Ready');
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Check Status
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Connection status: 'connecting' | 'open' | 'closing' | 'closed'
|
||||||
|
console.log(client.status);
|
||||||
|
|
||||||
|
// Convenience boolean
|
||||||
|
if (client.isConnected) {
|
||||||
|
// Safe to call APIs
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Disconnect
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Manually disconnect (disables auto-reconnect)
|
||||||
|
client.disconnect();
|
||||||
|
```
|
||||||
|
|
||||||
|
## Calling APIs
|
||||||
|
|
||||||
|
APIs use request-response pattern with full type safety:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Define protocol
|
||||||
|
const protocol = rpc.define({
|
||||||
|
api: {
|
||||||
|
login: rpc.api<{ username: string }, { userId: string; token: string }>(),
|
||||||
|
getProfile: rpc.api<{ userId: string }, { name: string; level: number }>(),
|
||||||
|
},
|
||||||
|
msg: {}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Call with type inference
|
||||||
|
const { userId, token } = await client.call('login', { username: 'player1' });
|
||||||
|
const profile = await client.call('getProfile', { userId });
|
||||||
|
```
|
||||||
|
|
||||||
|
### Error Handling
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { RpcError, ErrorCode } from '@esengine/rpc/client';
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.call('login', { username: 'player1' });
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof RpcError) {
|
||||||
|
switch (error.code) {
|
||||||
|
case ErrorCode.TIMEOUT:
|
||||||
|
console.log('Request timed out');
|
||||||
|
break;
|
||||||
|
case ErrorCode.CONNECTION_CLOSED:
|
||||||
|
console.log('Not connected');
|
||||||
|
break;
|
||||||
|
case ErrorCode.NOT_FOUND:
|
||||||
|
console.log('API not found');
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
console.log('Server error:', error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Sending Messages
|
||||||
|
|
||||||
|
Messages are fire-and-forget (no response):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Send message to server
|
||||||
|
client.send('playerMove', { x: 100, y: 200 });
|
||||||
|
client.send('chat', { text: 'Hello!' });
|
||||||
|
```
|
||||||
|
|
||||||
|
## Receiving Messages
|
||||||
|
|
||||||
|
Listen for server-pushed messages:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Subscribe to message
|
||||||
|
client.on('newMessage', (data) => {
|
||||||
|
console.log(`${data.from}: ${data.text}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
client.on('playerJoined', (data) => {
|
||||||
|
console.log(`${data.name} joined the game`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Unsubscribe specific handler
|
||||||
|
const handler = (data) => console.log(data);
|
||||||
|
client.on('event', handler);
|
||||||
|
client.off('event', handler);
|
||||||
|
|
||||||
|
// Unsubscribe all handlers for a message
|
||||||
|
client.off('event');
|
||||||
|
|
||||||
|
// One-time listener
|
||||||
|
client.once('gameStart', (data) => {
|
||||||
|
console.log('Game started!');
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Custom WebSocket (Platform Adapters)
|
||||||
|
|
||||||
|
For platforms like WeChat Mini Games:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// WeChat Mini Games adapter
|
||||||
|
const wxWebSocketFactory = (url: string) => {
|
||||||
|
const ws = wx.connectSocket({ url });
|
||||||
|
|
||||||
|
return {
|
||||||
|
get readyState() { return ws.readyState; },
|
||||||
|
send: (data) => ws.send({ data }),
|
||||||
|
close: (code, reason) => ws.close({ code, reason }),
|
||||||
|
set onopen(fn) { ws.onOpen(fn); },
|
||||||
|
set onclose(fn) { ws.onClose((e) => fn({ code: e.code, reason: e.reason })); },
|
||||||
|
set onerror(fn) { ws.onError(fn); },
|
||||||
|
set onmessage(fn) { ws.onMessage((e) => fn({ data: e.data })); },
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const client = new RpcClient(protocol, 'wss://game.example.com', {
|
||||||
|
webSocketFactory: wxWebSocketFactory,
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Convenience Function
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { connect } from '@esengine/rpc/client';
|
||||||
|
|
||||||
|
// Connect and return client in one call
|
||||||
|
const client = await connect(protocol, 'ws://localhost:3000', {
|
||||||
|
onConnect: () => console.log('Connected'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await client.call('join', { name: 'Alice' });
|
||||||
|
```
|
||||||
|
|
||||||
|
## Full Example
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { RpcClient } from '@esengine/rpc/client';
|
||||||
|
import { gameProtocol } from './protocol';
|
||||||
|
|
||||||
|
class GameClient {
|
||||||
|
private client: RpcClient<typeof gameProtocol>;
|
||||||
|
private userId: string | null = null;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.client = new RpcClient(gameProtocol, 'ws://localhost:3000', {
|
||||||
|
onConnect: () => this.onConnected(),
|
||||||
|
onDisconnect: () => this.onDisconnected(),
|
||||||
|
onError: (e) => console.error('RPC Error:', e),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Setup message handlers
|
||||||
|
this.client.on('gameState', (state) => this.updateState(state));
|
||||||
|
this.client.on('playerJoined', (p) => this.addPlayer(p));
|
||||||
|
this.client.on('playerLeft', (p) => this.removePlayer(p));
|
||||||
|
}
|
||||||
|
|
||||||
|
async connect() {
|
||||||
|
await this.client.connect();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async onConnected() {
|
||||||
|
const { userId, token } = await this.client.call('login', {
|
||||||
|
username: localStorage.getItem('username') || 'Guest',
|
||||||
|
});
|
||||||
|
this.userId = userId;
|
||||||
|
console.log('Logged in as', userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private onDisconnected() {
|
||||||
|
console.log('Disconnected, will auto-reconnect...');
|
||||||
|
}
|
||||||
|
|
||||||
|
async move(x: number, y: number) {
|
||||||
|
if (!this.client.isConnected) return;
|
||||||
|
this.client.send('move', { x, y });
|
||||||
|
}
|
||||||
|
|
||||||
|
async chat(text: string) {
|
||||||
|
await this.client.call('sendChat', { text });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
160
docs/src/content/docs/en/modules/rpc/codec.md
Normal file
160
docs/src/content/docs/en/modules/rpc/codec.md
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
---
|
||||||
|
title: "RPC Codecs"
|
||||||
|
description: "Serialization codecs for RPC communication"
|
||||||
|
---
|
||||||
|
|
||||||
|
Codecs handle serialization and deserialization of RPC messages. Two built-in codecs are available.
|
||||||
|
|
||||||
|
## Built-in Codecs
|
||||||
|
|
||||||
|
### JSON Codec (Default)
|
||||||
|
|
||||||
|
Human-readable, widely compatible:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { json } from '@esengine/rpc/codec';
|
||||||
|
|
||||||
|
const client = new RpcClient(protocol, url, {
|
||||||
|
codec: json(),
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**Pros:**
|
||||||
|
- Human-readable (easy debugging)
|
||||||
|
- No additional dependencies
|
||||||
|
- Universal browser support
|
||||||
|
|
||||||
|
**Cons:**
|
||||||
|
- Larger message size
|
||||||
|
- Slower serialization
|
||||||
|
|
||||||
|
### MessagePack Codec
|
||||||
|
|
||||||
|
Binary format, more efficient:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { msgpack } from '@esengine/rpc/codec';
|
||||||
|
|
||||||
|
const client = new RpcClient(protocol, url, {
|
||||||
|
codec: msgpack(),
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**Pros:**
|
||||||
|
- Smaller message size (~30-50% smaller)
|
||||||
|
- Faster serialization
|
||||||
|
- Supports binary data natively
|
||||||
|
|
||||||
|
**Cons:**
|
||||||
|
- Not human-readable
|
||||||
|
- Requires msgpack library
|
||||||
|
|
||||||
|
## Codec Interface
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface Codec {
|
||||||
|
/**
|
||||||
|
* Encode packet to wire format
|
||||||
|
*/
|
||||||
|
encode(packet: unknown): string | Uint8Array;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decode wire format to packet
|
||||||
|
*/
|
||||||
|
decode(data: string | Uint8Array): unknown;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Custom Codec
|
||||||
|
|
||||||
|
Create your own codec for special needs:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import type { Codec } from '@esengine/rpc/codec';
|
||||||
|
|
||||||
|
// Example: Compressed JSON codec
|
||||||
|
const compressedJson: () => Codec = () => ({
|
||||||
|
encode(packet: unknown): Uint8Array {
|
||||||
|
const json = JSON.stringify(packet);
|
||||||
|
return compress(new TextEncoder().encode(json));
|
||||||
|
},
|
||||||
|
|
||||||
|
decode(data: string | Uint8Array): unknown {
|
||||||
|
const bytes = typeof data === 'string'
|
||||||
|
? new TextEncoder().encode(data)
|
||||||
|
: data;
|
||||||
|
const decompressed = decompress(bytes);
|
||||||
|
return JSON.parse(new TextDecoder().decode(decompressed));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Use custom codec
|
||||||
|
const client = new RpcClient(protocol, url, {
|
||||||
|
codec: compressedJson(),
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Protocol Buffers Codec
|
||||||
|
|
||||||
|
For production games, consider Protocol Buffers:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import type { Codec } from '@esengine/rpc/codec';
|
||||||
|
|
||||||
|
const protobuf = (schema: ProtobufSchema): Codec => ({
|
||||||
|
encode(packet: unknown): Uint8Array {
|
||||||
|
return schema.Packet.encode(packet).finish();
|
||||||
|
},
|
||||||
|
|
||||||
|
decode(data: string | Uint8Array): unknown {
|
||||||
|
const bytes = typeof data === 'string'
|
||||||
|
? new TextEncoder().encode(data)
|
||||||
|
: data;
|
||||||
|
return schema.Packet.decode(bytes);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Matching Client and Server
|
||||||
|
|
||||||
|
Both client and server must use the same codec:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// shared/codec.ts
|
||||||
|
import { msgpack } from '@esengine/rpc/codec';
|
||||||
|
export const gameCodec = msgpack();
|
||||||
|
|
||||||
|
// client.ts
|
||||||
|
import { gameCodec } from './shared/codec';
|
||||||
|
const client = new RpcClient(protocol, url, { codec: gameCodec });
|
||||||
|
|
||||||
|
// server.ts
|
||||||
|
import { gameCodec } from './shared/codec';
|
||||||
|
const server = serve(protocol, {
|
||||||
|
port: 3000,
|
||||||
|
codec: gameCodec,
|
||||||
|
api: { /* ... */ },
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Performance Comparison
|
||||||
|
|
||||||
|
| Codec | Encode Speed | Decode Speed | Size |
|
||||||
|
|-------|-------------|--------------|------|
|
||||||
|
| JSON | Medium | Medium | Large |
|
||||||
|
| MessagePack | Fast | Fast | Small |
|
||||||
|
| Protobuf | Fastest | Fastest | Smallest |
|
||||||
|
|
||||||
|
For most games, MessagePack provides a good balance. Use Protobuf for high-performance requirements.
|
||||||
|
|
||||||
|
## Text Encoding Utilities
|
||||||
|
|
||||||
|
For custom codecs, utilities are provided:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { textEncode, textDecode } from '@esengine/rpc/codec';
|
||||||
|
|
||||||
|
// Works on all platforms (browser, Node.js, WeChat)
|
||||||
|
const bytes = textEncode('Hello'); // Uint8Array
|
||||||
|
const text = textDecode(bytes); // 'Hello'
|
||||||
|
```
|
||||||
350
docs/src/content/docs/en/modules/rpc/server.md
Normal file
350
docs/src/content/docs/en/modules/rpc/server.md
Normal file
@@ -0,0 +1,350 @@
|
|||||||
|
---
|
||||||
|
title: "RPC Server API"
|
||||||
|
description: "RpcServer for handling client connections"
|
||||||
|
---
|
||||||
|
|
||||||
|
The `serve` function creates a type-safe RPC server that handles client connections and API calls.
|
||||||
|
|
||||||
|
## Basic Usage
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { serve } from '@esengine/rpc/server';
|
||||||
|
import { gameProtocol } from './protocol';
|
||||||
|
|
||||||
|
const server = serve(gameProtocol, {
|
||||||
|
port: 3000,
|
||||||
|
api: {
|
||||||
|
login: async (input, conn) => {
|
||||||
|
console.log(`${input.username} connected from ${conn.ip}`);
|
||||||
|
return { userId: conn.id, token: generateToken() };
|
||||||
|
},
|
||||||
|
sendChat: async (input, conn) => {
|
||||||
|
server.broadcast('newMessage', {
|
||||||
|
from: conn.id,
|
||||||
|
text: input.text,
|
||||||
|
time: Date.now(),
|
||||||
|
});
|
||||||
|
return { success: true };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
onStart: (port) => console.log(`Server started on port ${port}`),
|
||||||
|
});
|
||||||
|
|
||||||
|
await server.start();
|
||||||
|
```
|
||||||
|
|
||||||
|
## Server Options
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface ServeOptions<P, TConnData> {
|
||||||
|
// Required
|
||||||
|
port: number;
|
||||||
|
api: ApiHandlers<P, TConnData>;
|
||||||
|
|
||||||
|
// Optional
|
||||||
|
msg?: MsgHandlers<P, TConnData>;
|
||||||
|
codec?: Codec;
|
||||||
|
createConnData?: () => TConnData;
|
||||||
|
|
||||||
|
// Callbacks
|
||||||
|
onConnect?: (conn: Connection<TConnData>) => void | Promise<void>;
|
||||||
|
onDisconnect?: (conn: Connection<TConnData>, reason?: string) => void | Promise<void>;
|
||||||
|
onError?: (error: Error, conn?: Connection<TConnData>) => void;
|
||||||
|
onStart?: (port: number) => void;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Handlers
|
||||||
|
|
||||||
|
Each API handler receives the input and connection context:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const server = serve(protocol, {
|
||||||
|
port: 3000,
|
||||||
|
api: {
|
||||||
|
// Sync handler
|
||||||
|
ping: (input, conn) => {
|
||||||
|
return { pong: true, time: Date.now() };
|
||||||
|
},
|
||||||
|
|
||||||
|
// Async handler
|
||||||
|
getProfile: async (input, conn) => {
|
||||||
|
const user = await database.findUser(input.userId);
|
||||||
|
return { name: user.name, level: user.level };
|
||||||
|
},
|
||||||
|
|
||||||
|
// Access connection context
|
||||||
|
getMyInfo: (input, conn) => {
|
||||||
|
return {
|
||||||
|
connectionId: conn.id,
|
||||||
|
ip: conn.ip,
|
||||||
|
data: conn.data,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Throwing Errors
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { RpcError, ErrorCode } from '@esengine/rpc/server';
|
||||||
|
|
||||||
|
const server = serve(protocol, {
|
||||||
|
port: 3000,
|
||||||
|
api: {
|
||||||
|
login: async (input, conn) => {
|
||||||
|
const user = await database.findUser(input.username);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new RpcError(ErrorCode.NOT_FOUND, 'User not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!await verifyPassword(input.password, user.hash)) {
|
||||||
|
throw new RpcError('AUTH_FAILED', 'Invalid password');
|
||||||
|
}
|
||||||
|
|
||||||
|
return { userId: user.id, token: generateToken() };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Message Handlers
|
||||||
|
|
||||||
|
Handle messages sent by clients:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const server = serve(protocol, {
|
||||||
|
port: 3000,
|
||||||
|
api: { /* ... */ },
|
||||||
|
msg: {
|
||||||
|
playerMove: (data, conn) => {
|
||||||
|
// Update player position
|
||||||
|
const player = players.get(conn.id);
|
||||||
|
if (player) {
|
||||||
|
player.x = data.x;
|
||||||
|
player.y = data.y;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Broadcast to others
|
||||||
|
server.broadcast('playerMoved', {
|
||||||
|
playerId: conn.id,
|
||||||
|
x: data.x,
|
||||||
|
y: data.y,
|
||||||
|
}, { exclude: conn });
|
||||||
|
},
|
||||||
|
|
||||||
|
chat: async (data, conn) => {
|
||||||
|
// Async handlers work too
|
||||||
|
await logChat(conn.id, data.text);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Connection Context
|
||||||
|
|
||||||
|
The `Connection` object provides access to client info:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface Connection<TData> {
|
||||||
|
// Unique connection ID
|
||||||
|
readonly id: string;
|
||||||
|
|
||||||
|
// Client IP address
|
||||||
|
readonly ip: string;
|
||||||
|
|
||||||
|
// Connection status
|
||||||
|
readonly isOpen: boolean;
|
||||||
|
|
||||||
|
// Custom data attached to this connection
|
||||||
|
data: TData;
|
||||||
|
|
||||||
|
// Close the connection
|
||||||
|
close(reason?: string): void;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Custom Connection Data
|
||||||
|
|
||||||
|
Store per-connection state:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface PlayerData {
|
||||||
|
playerId: string;
|
||||||
|
username: string;
|
||||||
|
room: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const server = serve(protocol, {
|
||||||
|
port: 3000,
|
||||||
|
createConnData: () => ({
|
||||||
|
playerId: '',
|
||||||
|
username: '',
|
||||||
|
room: null,
|
||||||
|
} as PlayerData),
|
||||||
|
api: {
|
||||||
|
login: async (input, conn) => {
|
||||||
|
// Store data on connection
|
||||||
|
conn.data.playerId = generateId();
|
||||||
|
conn.data.username = input.username;
|
||||||
|
return { playerId: conn.data.playerId };
|
||||||
|
},
|
||||||
|
joinRoom: async (input, conn) => {
|
||||||
|
conn.data.room = input.roomId;
|
||||||
|
return { success: true };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
onDisconnect: (conn) => {
|
||||||
|
console.log(`${conn.data.username} left room ${conn.data.room}`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Sending Messages
|
||||||
|
|
||||||
|
### To Single Connection
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
server.send(conn, 'notification', { text: 'Hello!' });
|
||||||
|
```
|
||||||
|
|
||||||
|
### Broadcast to All
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// To everyone
|
||||||
|
server.broadcast('announcement', { text: 'Server restart in 5 minutes' });
|
||||||
|
|
||||||
|
// Exclude sender
|
||||||
|
server.broadcast('playerMoved', { id: conn.id, x, y }, { exclude: conn });
|
||||||
|
|
||||||
|
// Exclude multiple
|
||||||
|
server.broadcast('gameEvent', data, { exclude: [conn1, conn2] });
|
||||||
|
```
|
||||||
|
|
||||||
|
### To Specific Group
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Custom broadcasting
|
||||||
|
function broadcastToRoom(roomId: string, name: string, data: any) {
|
||||||
|
for (const conn of server.connections) {
|
||||||
|
if (conn.data.room === roomId) {
|
||||||
|
server.send(conn, name, data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
broadcastToRoom('room1', 'roomMessage', { text: 'Hello room!' });
|
||||||
|
```
|
||||||
|
|
||||||
|
## Server Lifecycle
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const server = serve(protocol, { /* ... */ });
|
||||||
|
|
||||||
|
// Start
|
||||||
|
await server.start();
|
||||||
|
console.log('Server running');
|
||||||
|
|
||||||
|
// Access connections
|
||||||
|
console.log(`${server.connections.length} clients connected`);
|
||||||
|
|
||||||
|
// Stop (closes all connections)
|
||||||
|
await server.stop();
|
||||||
|
console.log('Server stopped');
|
||||||
|
```
|
||||||
|
|
||||||
|
## Full Example
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { serve, RpcError } from '@esengine/rpc/server';
|
||||||
|
import { gameProtocol } from './protocol';
|
||||||
|
|
||||||
|
interface PlayerData {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const players = new Map<string, PlayerData>();
|
||||||
|
|
||||||
|
const server = serve(gameProtocol, {
|
||||||
|
port: 3000,
|
||||||
|
createConnData: () => ({ id: '', name: '', x: 0, y: 0 }),
|
||||||
|
|
||||||
|
api: {
|
||||||
|
join: async (input, conn) => {
|
||||||
|
const player: PlayerData = {
|
||||||
|
id: conn.id,
|
||||||
|
name: input.name,
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
};
|
||||||
|
players.set(conn.id, player);
|
||||||
|
conn.data = player;
|
||||||
|
|
||||||
|
// Notify others
|
||||||
|
server.broadcast('playerJoined', {
|
||||||
|
id: player.id,
|
||||||
|
name: player.name,
|
||||||
|
}, { exclude: conn });
|
||||||
|
|
||||||
|
// Send current state to new player
|
||||||
|
return {
|
||||||
|
playerId: player.id,
|
||||||
|
players: Array.from(players.values()),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
chat: async (input, conn) => {
|
||||||
|
server.broadcast('chatMessage', {
|
||||||
|
from: conn.data.name,
|
||||||
|
text: input.text,
|
||||||
|
time: Date.now(),
|
||||||
|
});
|
||||||
|
return { sent: true };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
msg: {
|
||||||
|
move: (data, conn) => {
|
||||||
|
const player = players.get(conn.id);
|
||||||
|
if (player) {
|
||||||
|
player.x = data.x;
|
||||||
|
player.y = data.y;
|
||||||
|
|
||||||
|
server.broadcast('playerMoved', {
|
||||||
|
id: conn.id,
|
||||||
|
x: data.x,
|
||||||
|
y: data.y,
|
||||||
|
}, { exclude: conn });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
onConnect: (conn) => {
|
||||||
|
console.log(`Client connected: ${conn.id} from ${conn.ip}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
onDisconnect: (conn) => {
|
||||||
|
const player = players.get(conn.id);
|
||||||
|
if (player) {
|
||||||
|
players.delete(conn.id);
|
||||||
|
server.broadcast('playerLeft', { id: conn.id });
|
||||||
|
console.log(`${player.name} disconnected`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
onError: (error, conn) => {
|
||||||
|
console.error(`Error from ${conn?.id}:`, error);
|
||||||
|
},
|
||||||
|
|
||||||
|
onStart: (port) => {
|
||||||
|
console.log(`Game server running on ws://localhost:${port}`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
server.start();
|
||||||
|
```
|
||||||
261
docs/src/content/docs/en/modules/transaction/core.md
Normal file
261
docs/src/content/docs/en/modules/transaction/core.md
Normal file
@@ -0,0 +1,261 @@
|
|||||||
|
---
|
||||||
|
title: "Core Concepts"
|
||||||
|
description: "Transaction system core concepts: context, manager, and Saga pattern"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Transaction State
|
||||||
|
|
||||||
|
A transaction can be in the following states:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
type TransactionState =
|
||||||
|
| 'pending' // Waiting to execute
|
||||||
|
| 'executing' // Executing
|
||||||
|
| 'committed' // Committed
|
||||||
|
| 'rolledback' // Rolled back
|
||||||
|
| 'failed' // Failed
|
||||||
|
```
|
||||||
|
|
||||||
|
## TransactionContext
|
||||||
|
|
||||||
|
The transaction context encapsulates transaction state, operations, and execution logic.
|
||||||
|
|
||||||
|
### Creating Transactions
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { TransactionManager } from '@esengine/transaction';
|
||||||
|
|
||||||
|
const manager = new TransactionManager();
|
||||||
|
|
||||||
|
// Method 1: Manual management with begin()
|
||||||
|
const tx = manager.begin({ timeout: 5000 });
|
||||||
|
tx.addOperation(op1);
|
||||||
|
tx.addOperation(op2);
|
||||||
|
const result = await tx.execute();
|
||||||
|
|
||||||
|
// Method 2: Automatic management with run()
|
||||||
|
const result = await manager.run((tx) => {
|
||||||
|
tx.addOperation(op1);
|
||||||
|
tx.addOperation(op2);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Chaining Operations
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const result = await manager.run((tx) => {
|
||||||
|
tx.addOperation(new CurrencyOperation({ ... }))
|
||||||
|
.addOperation(new InventoryOperation({ ... }))
|
||||||
|
.addOperation(new InventoryOperation({ ... }));
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Context Data
|
||||||
|
|
||||||
|
Operations can share data through the context:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
class CustomOperation extends BaseOperation<MyData, MyResult> {
|
||||||
|
async execute(ctx: ITransactionContext): Promise<OperationResult<MyResult>> {
|
||||||
|
// Read data set by previous operations
|
||||||
|
const previousResult = ctx.get<number>('previousValue');
|
||||||
|
|
||||||
|
// Set data for subsequent operations
|
||||||
|
ctx.set('myResult', { value: 123 });
|
||||||
|
|
||||||
|
return this.success({ ... });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## TransactionManager
|
||||||
|
|
||||||
|
The transaction manager is responsible for creating, executing, and recovering transactions.
|
||||||
|
|
||||||
|
### Configuration Options
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface TransactionManagerConfig {
|
||||||
|
storage?: ITransactionStorage; // Storage instance
|
||||||
|
defaultTimeout?: number; // Default timeout (ms)
|
||||||
|
serverId?: string; // Server ID (for distributed)
|
||||||
|
autoRecover?: boolean; // Auto-recover pending transactions
|
||||||
|
}
|
||||||
|
|
||||||
|
const manager = new TransactionManager({
|
||||||
|
storage: new RedisStorage({ client: redis }),
|
||||||
|
defaultTimeout: 10000,
|
||||||
|
serverId: 'server-1',
|
||||||
|
autoRecover: true,
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Distributed Locking
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Acquire lock
|
||||||
|
const token = await manager.acquireLock('player:123:inventory', 10000);
|
||||||
|
|
||||||
|
if (token) {
|
||||||
|
try {
|
||||||
|
// Perform operations
|
||||||
|
await doSomething();
|
||||||
|
} finally {
|
||||||
|
// Release lock
|
||||||
|
await manager.releaseLock('player:123:inventory', token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Or use withLock for convenience
|
||||||
|
await manager.withLock('player:123:inventory', async () => {
|
||||||
|
await doSomething();
|
||||||
|
}, 10000);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Transaction Recovery
|
||||||
|
|
||||||
|
Recover pending transactions after server restart:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const manager = new TransactionManager({
|
||||||
|
storage: new RedisStorage({ client: redis }),
|
||||||
|
serverId: 'server-1',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Recover pending transactions
|
||||||
|
const recoveredCount = await manager.recover();
|
||||||
|
console.log(`Recovered ${recoveredCount} transactions`);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Saga Pattern
|
||||||
|
|
||||||
|
The transaction system uses the Saga pattern. Each operation must implement `execute` and `compensate` methods:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface ITransactionOperation<TData, TResult> {
|
||||||
|
readonly name: string;
|
||||||
|
readonly data: TData;
|
||||||
|
|
||||||
|
// Validate preconditions
|
||||||
|
validate(ctx: ITransactionContext): Promise<boolean>;
|
||||||
|
|
||||||
|
// Forward execution
|
||||||
|
execute(ctx: ITransactionContext): Promise<OperationResult<TResult>>;
|
||||||
|
|
||||||
|
// Compensate (rollback)
|
||||||
|
compensate(ctx: ITransactionContext): Promise<void>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Execution Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
Begin Transaction
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────────┐
|
||||||
|
│ validate(op1) │──fail──► Return failure
|
||||||
|
└─────────────────────┘
|
||||||
|
│success
|
||||||
|
▼
|
||||||
|
┌─────────────────────┐
|
||||||
|
│ execute(op1) │──fail──┐
|
||||||
|
└─────────────────────┘ │
|
||||||
|
│success │
|
||||||
|
▼ │
|
||||||
|
┌─────────────────────┐ │
|
||||||
|
│ validate(op2) │──fail──┤
|
||||||
|
└─────────────────────┘ │
|
||||||
|
│success │
|
||||||
|
▼ │
|
||||||
|
┌─────────────────────┐ │
|
||||||
|
│ execute(op2) │──fail──┤
|
||||||
|
└─────────────────────┘ │
|
||||||
|
│success ▼
|
||||||
|
▼ ┌─────────────────────┐
|
||||||
|
Commit Transaction │ compensate(op1) │
|
||||||
|
└─────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Return failure (rolled back)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Custom Operations
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { BaseOperation, ITransactionContext, OperationResult } from '@esengine/transaction';
|
||||||
|
|
||||||
|
interface UpgradeData {
|
||||||
|
playerId: string;
|
||||||
|
itemId: string;
|
||||||
|
targetLevel: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UpgradeResult {
|
||||||
|
newLevel: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
class UpgradeOperation extends BaseOperation<UpgradeData, UpgradeResult> {
|
||||||
|
readonly name = 'upgrade';
|
||||||
|
|
||||||
|
private _previousLevel: number = 0;
|
||||||
|
|
||||||
|
async validate(ctx: ITransactionContext): Promise<boolean> {
|
||||||
|
// Validate item exists and can be upgraded
|
||||||
|
const item = await this.getItem(ctx);
|
||||||
|
return item !== null && item.level < this.data.targetLevel;
|
||||||
|
}
|
||||||
|
|
||||||
|
async execute(ctx: ITransactionContext): Promise<OperationResult<UpgradeResult>> {
|
||||||
|
const item = await this.getItem(ctx);
|
||||||
|
if (!item) {
|
||||||
|
return this.failure('Item not found', 'ITEM_NOT_FOUND');
|
||||||
|
}
|
||||||
|
|
||||||
|
this._previousLevel = item.level;
|
||||||
|
item.level = this.data.targetLevel;
|
||||||
|
await this.saveItem(ctx, item);
|
||||||
|
|
||||||
|
return this.success({ newLevel: item.level });
|
||||||
|
}
|
||||||
|
|
||||||
|
async compensate(ctx: ITransactionContext): Promise<void> {
|
||||||
|
const item = await this.getItem(ctx);
|
||||||
|
if (item) {
|
||||||
|
item.level = this._previousLevel;
|
||||||
|
await this.saveItem(ctx, item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getItem(ctx: ITransactionContext) {
|
||||||
|
// Get item from storage
|
||||||
|
}
|
||||||
|
|
||||||
|
private async saveItem(ctx: ITransactionContext, item: any) {
|
||||||
|
// Save item to storage
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Transaction Result
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface TransactionResult<T = unknown> {
|
||||||
|
success: boolean; // Whether succeeded
|
||||||
|
transactionId: string; // Transaction ID
|
||||||
|
results: OperationResult[]; // Operation results
|
||||||
|
data?: T; // Final data
|
||||||
|
error?: string; // Error message
|
||||||
|
duration: number; // Execution time (ms)
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await manager.run((tx) => { ... });
|
||||||
|
|
||||||
|
console.log(`Transaction ${result.transactionId}`);
|
||||||
|
console.log(`Success: ${result.success}`);
|
||||||
|
console.log(`Duration: ${result.duration}ms`);
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
console.log(`Error: ${result.error}`);
|
||||||
|
}
|
||||||
|
```
|
||||||
355
docs/src/content/docs/en/modules/transaction/distributed.md
Normal file
355
docs/src/content/docs/en/modules/transaction/distributed.md
Normal file
@@ -0,0 +1,355 @@
|
|||||||
|
---
|
||||||
|
title: "Distributed Transactions"
|
||||||
|
description: "Saga orchestrator and cross-server transaction support"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Saga Orchestrator
|
||||||
|
|
||||||
|
`SagaOrchestrator` is used to orchestrate distributed transactions across servers.
|
||||||
|
|
||||||
|
### Basic Usage
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { SagaOrchestrator, RedisStorage } from '@esengine/transaction';
|
||||||
|
|
||||||
|
const orchestrator = new SagaOrchestrator({
|
||||||
|
storage: new RedisStorage({ client: redis }),
|
||||||
|
timeout: 30000,
|
||||||
|
serverId: 'orchestrator-1',
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await orchestrator.execute([
|
||||||
|
{
|
||||||
|
name: 'deduct_currency',
|
||||||
|
serverId: 'game-server-1',
|
||||||
|
data: { playerId: 'player1', amount: 100 },
|
||||||
|
execute: async (data) => {
|
||||||
|
// Call game server API to deduct currency
|
||||||
|
const response = await gameServerApi.deductCurrency(data);
|
||||||
|
return { success: response.ok };
|
||||||
|
},
|
||||||
|
compensate: async (data) => {
|
||||||
|
// Call game server API to restore currency
|
||||||
|
await gameServerApi.addCurrency(data);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'add_item',
|
||||||
|
serverId: 'inventory-server-1',
|
||||||
|
data: { playerId: 'player1', itemId: 'sword' },
|
||||||
|
execute: async (data) => {
|
||||||
|
const response = await inventoryServerApi.addItem(data);
|
||||||
|
return { success: response.ok };
|
||||||
|
},
|
||||||
|
compensate: async (data) => {
|
||||||
|
await inventoryServerApi.removeItem(data);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
console.log('Saga completed successfully');
|
||||||
|
} else {
|
||||||
|
console.log('Saga failed:', result.error);
|
||||||
|
console.log('Completed steps:', result.completedSteps);
|
||||||
|
console.log('Failed at:', result.failedStep);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Configuration Options
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface SagaOrchestratorConfig {
|
||||||
|
storage?: ITransactionStorage; // Storage instance
|
||||||
|
timeout?: number; // Timeout in milliseconds
|
||||||
|
serverId?: string; // Orchestrator server ID
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Saga Step
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface SagaStep<T = unknown> {
|
||||||
|
name: string; // Step name
|
||||||
|
serverId?: string; // Target server ID
|
||||||
|
data: T; // Step data
|
||||||
|
execute: (data: T) => Promise<OperationResult>; // Execute function
|
||||||
|
compensate: (data: T) => Promise<void>; // Compensate function
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Saga Result
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface SagaResult {
|
||||||
|
success: boolean; // Whether succeeded
|
||||||
|
sagaId: string; // Saga ID
|
||||||
|
completedSteps: string[]; // Completed steps
|
||||||
|
failedStep?: string; // Failed step
|
||||||
|
error?: string; // Error message
|
||||||
|
duration: number; // Execution time (ms)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Execution Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
Start Saga
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────────┐
|
||||||
|
│ Step 1: execute │──fail──┐
|
||||||
|
└─────────────────────┘ │
|
||||||
|
│success │
|
||||||
|
▼ │
|
||||||
|
┌─────────────────────┐ │
|
||||||
|
│ Step 2: execute │──fail──┤
|
||||||
|
└─────────────────────┘ │
|
||||||
|
│success │
|
||||||
|
▼ │
|
||||||
|
┌─────────────────────┐ │
|
||||||
|
│ Step 3: execute │──fail──┤
|
||||||
|
└─────────────────────┘ │
|
||||||
|
│success ▼
|
||||||
|
▼ ┌─────────────────────┐
|
||||||
|
Saga Complete │ Step 2: compensate │
|
||||||
|
└─────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────────┐
|
||||||
|
│ Step 1: compensate │
|
||||||
|
└─────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Saga Failed (compensated)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Saga Logs
|
||||||
|
|
||||||
|
The orchestrator records detailed execution logs:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface SagaLog {
|
||||||
|
id: string; // Saga ID
|
||||||
|
state: SagaLogState; // State
|
||||||
|
steps: SagaStepLog[]; // Step logs
|
||||||
|
createdAt: number; // Creation time
|
||||||
|
updatedAt: number; // Update time
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
type SagaLogState =
|
||||||
|
| 'pending' // Waiting to execute
|
||||||
|
| 'running' // Executing
|
||||||
|
| 'completed' // Completed
|
||||||
|
| 'compensating' // Compensating
|
||||||
|
| 'compensated' // Compensated
|
||||||
|
| 'failed' // Failed
|
||||||
|
|
||||||
|
interface SagaStepLog {
|
||||||
|
name: string; // Step name
|
||||||
|
serverId?: string; // Server ID
|
||||||
|
state: SagaStepState; // State
|
||||||
|
startedAt?: number; // Start time
|
||||||
|
completedAt?: number; // Completion time
|
||||||
|
error?: string; // Error message
|
||||||
|
}
|
||||||
|
|
||||||
|
type SagaStepState =
|
||||||
|
| 'pending' // Waiting to execute
|
||||||
|
| 'executing' // Executing
|
||||||
|
| 'completed' // Completed
|
||||||
|
| 'compensating' // Compensating
|
||||||
|
| 'compensated' // Compensated
|
||||||
|
| 'failed' // Failed
|
||||||
|
```
|
||||||
|
|
||||||
|
### Query Saga Logs
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const log = await orchestrator.getSagaLog('saga_xxx');
|
||||||
|
|
||||||
|
if (log) {
|
||||||
|
console.log('Saga state:', log.state);
|
||||||
|
for (const step of log.steps) {
|
||||||
|
console.log(` ${step.name}: ${step.state}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cross-Server Transaction Examples
|
||||||
|
|
||||||
|
### Scenario: Cross-Server Purchase
|
||||||
|
|
||||||
|
A player purchases an item on a game server, with currency on an account server and items on an inventory server.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const orchestrator = new SagaOrchestrator({
|
||||||
|
storage: redisStorage,
|
||||||
|
serverId: 'purchase-orchestrator',
|
||||||
|
});
|
||||||
|
|
||||||
|
async function crossServerPurchase(
|
||||||
|
playerId: string,
|
||||||
|
itemId: string,
|
||||||
|
price: number
|
||||||
|
): Promise<SagaResult> {
|
||||||
|
return orchestrator.execute([
|
||||||
|
// Step 1: Deduct balance on account server
|
||||||
|
{
|
||||||
|
name: 'deduct_balance',
|
||||||
|
serverId: 'account-server',
|
||||||
|
data: { playerId, amount: price },
|
||||||
|
execute: async (data) => {
|
||||||
|
const result = await accountService.deduct(data.playerId, data.amount);
|
||||||
|
return { success: result.ok, error: result.error };
|
||||||
|
},
|
||||||
|
compensate: async (data) => {
|
||||||
|
await accountService.refund(data.playerId, data.amount);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// Step 2: Add item on inventory server
|
||||||
|
{
|
||||||
|
name: 'add_item',
|
||||||
|
serverId: 'inventory-server',
|
||||||
|
data: { playerId, itemId },
|
||||||
|
execute: async (data) => {
|
||||||
|
const result = await inventoryService.addItem(data.playerId, data.itemId);
|
||||||
|
return { success: result.ok, error: result.error };
|
||||||
|
},
|
||||||
|
compensate: async (data) => {
|
||||||
|
await inventoryService.removeItem(data.playerId, data.itemId);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// Step 3: Record purchase log
|
||||||
|
{
|
||||||
|
name: 'log_purchase',
|
||||||
|
serverId: 'log-server',
|
||||||
|
data: { playerId, itemId, price, timestamp: Date.now() },
|
||||||
|
execute: async (data) => {
|
||||||
|
await logService.recordPurchase(data);
|
||||||
|
return { success: true };
|
||||||
|
},
|
||||||
|
compensate: async (data) => {
|
||||||
|
await logService.cancelPurchase(data);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Scenario: Cross-Server Trade
|
||||||
|
|
||||||
|
Two players on different servers trade with each other.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
async function crossServerTrade(
|
||||||
|
playerA: { id: string; server: string; items: string[] },
|
||||||
|
playerB: { id: string; server: string; items: string[] }
|
||||||
|
): Promise<SagaResult> {
|
||||||
|
const steps: SagaStep[] = [];
|
||||||
|
|
||||||
|
// Remove items from player A
|
||||||
|
for (const itemId of playerA.items) {
|
||||||
|
steps.push({
|
||||||
|
name: `remove_${playerA.id}_${itemId}`,
|
||||||
|
serverId: playerA.server,
|
||||||
|
data: { playerId: playerA.id, itemId },
|
||||||
|
execute: async (data) => {
|
||||||
|
return await inventoryService.removeItem(data.playerId, data.itemId);
|
||||||
|
},
|
||||||
|
compensate: async (data) => {
|
||||||
|
await inventoryService.addItem(data.playerId, data.itemId);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add items to player B (from A)
|
||||||
|
for (const itemId of playerA.items) {
|
||||||
|
steps.push({
|
||||||
|
name: `add_${playerB.id}_${itemId}`,
|
||||||
|
serverId: playerB.server,
|
||||||
|
data: { playerId: playerB.id, itemId },
|
||||||
|
execute: async (data) => {
|
||||||
|
return await inventoryService.addItem(data.playerId, data.itemId);
|
||||||
|
},
|
||||||
|
compensate: async (data) => {
|
||||||
|
await inventoryService.removeItem(data.playerId, data.itemId);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Similarly handle player B's items...
|
||||||
|
|
||||||
|
return orchestrator.execute(steps);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Recovering Incomplete Sagas
|
||||||
|
|
||||||
|
Recover incomplete Sagas after server restart:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const orchestrator = new SagaOrchestrator({
|
||||||
|
storage: redisStorage,
|
||||||
|
serverId: 'my-orchestrator',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Recover incomplete Sagas (will execute compensation)
|
||||||
|
const recoveredCount = await orchestrator.recover();
|
||||||
|
console.log(`Recovered ${recoveredCount} sagas`);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
### 1. Idempotency
|
||||||
|
|
||||||
|
Ensure all operations are idempotent:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
execute: async (data) => {
|
||||||
|
// Use unique ID to ensure idempotency
|
||||||
|
const result = await service.process(data.requestId, data);
|
||||||
|
return { success: result.ok };
|
||||||
|
},
|
||||||
|
compensate: async (data) => {
|
||||||
|
// Compensation must also be idempotent
|
||||||
|
await service.rollback(data.requestId);
|
||||||
|
},
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Timeout Handling
|
||||||
|
|
||||||
|
Set appropriate timeout values:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const orchestrator = new SagaOrchestrator({
|
||||||
|
timeout: 60000, // Cross-server operations need longer timeout
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Monitoring and Alerts
|
||||||
|
|
||||||
|
Log Saga execution results:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const result = await orchestrator.execute(steps);
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
// Send alert
|
||||||
|
alertService.send({
|
||||||
|
type: 'saga_failed',
|
||||||
|
sagaId: result.sagaId,
|
||||||
|
failedStep: result.failedStep,
|
||||||
|
error: result.error,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Log details
|
||||||
|
const log = await orchestrator.getSagaLog(result.sagaId);
|
||||||
|
logger.error('Saga failed', { log });
|
||||||
|
}
|
||||||
|
```
|
||||||
238
docs/src/content/docs/en/modules/transaction/index.md
Normal file
238
docs/src/content/docs/en/modules/transaction/index.md
Normal file
@@ -0,0 +1,238 @@
|
|||||||
|
---
|
||||||
|
title: "Transaction System"
|
||||||
|
description: "Game transaction system with distributed support for shop purchases, player trading, and more"
|
||||||
|
---
|
||||||
|
|
||||||
|
`@esengine/transaction` provides comprehensive game transaction capabilities based on the Saga pattern, supporting shop purchases, player trading, multi-step tasks, and distributed transactions with Redis/MongoDB.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The transaction system solves common data consistency problems in games:
|
||||||
|
|
||||||
|
| Scenario | Problem | Solution |
|
||||||
|
|----------|---------|----------|
|
||||||
|
| Shop Purchase | Payment succeeded but item not delivered | Atomic transaction with auto-rollback |
|
||||||
|
| Player Trade | One party transferred items but other didn't receive | Saga compensation mechanism |
|
||||||
|
| Cross-Server | Data inconsistency across servers | Distributed lock + transaction log |
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install @esengine/transaction
|
||||||
|
```
|
||||||
|
|
||||||
|
Optional dependencies (install based on storage needs):
|
||||||
|
```bash
|
||||||
|
npm install ioredis # Redis storage
|
||||||
|
npm install mongodb # MongoDB storage
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ Transaction Layer │
|
||||||
|
├─────────────────────────────────────────────────────────────────┤
|
||||||
|
│ TransactionManager - Manages transaction lifecycle │
|
||||||
|
│ TransactionContext - Encapsulates operations and state │
|
||||||
|
│ SagaOrchestrator - Distributed Saga orchestrator │
|
||||||
|
├─────────────────────────────────────────────────────────────────┤
|
||||||
|
│ Storage Layer │
|
||||||
|
├─────────────────────────────────────────────────────────────────┤
|
||||||
|
│ MemoryStorage - In-memory (dev/test) │
|
||||||
|
│ RedisStorage - Redis (distributed lock + cache) │
|
||||||
|
│ MongoStorage - MongoDB (persistent log) │
|
||||||
|
├─────────────────────────────────────────────────────────────────┤
|
||||||
|
│ Operation Layer │
|
||||||
|
├─────────────────────────────────────────────────────────────────┤
|
||||||
|
│ CurrencyOperation - Currency operations │
|
||||||
|
│ InventoryOperation - Inventory operations │
|
||||||
|
│ TradeOperation - Trade operations │
|
||||||
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### Basic Usage
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import {
|
||||||
|
TransactionManager,
|
||||||
|
MemoryStorage,
|
||||||
|
CurrencyOperation,
|
||||||
|
InventoryOperation,
|
||||||
|
} from '@esengine/transaction';
|
||||||
|
|
||||||
|
// Create transaction manager
|
||||||
|
const manager = new TransactionManager({
|
||||||
|
storage: new MemoryStorage(),
|
||||||
|
defaultTimeout: 10000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Execute transaction
|
||||||
|
const result = await manager.run((tx) => {
|
||||||
|
// Deduct gold
|
||||||
|
tx.addOperation(new CurrencyOperation({
|
||||||
|
type: 'deduct',
|
||||||
|
playerId: 'player1',
|
||||||
|
currency: 'gold',
|
||||||
|
amount: 100,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Add item
|
||||||
|
tx.addOperation(new InventoryOperation({
|
||||||
|
type: 'add',
|
||||||
|
playerId: 'player1',
|
||||||
|
itemId: 'sword_001',
|
||||||
|
quantity: 1,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
console.log('Purchase successful!');
|
||||||
|
} else {
|
||||||
|
console.log('Purchase failed:', result.error);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Player Trading
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { TradeOperation } from '@esengine/transaction';
|
||||||
|
|
||||||
|
const result = await manager.run((tx) => {
|
||||||
|
tx.addOperation(new TradeOperation({
|
||||||
|
tradeId: 'trade_001',
|
||||||
|
partyA: {
|
||||||
|
playerId: 'player1',
|
||||||
|
items: [{ itemId: 'sword', quantity: 1 }],
|
||||||
|
},
|
||||||
|
partyB: {
|
||||||
|
playerId: 'player2',
|
||||||
|
currencies: [{ currency: 'gold', amount: 1000 }],
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}, { timeout: 30000 });
|
||||||
|
```
|
||||||
|
|
||||||
|
### Using Redis Storage
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import Redis from 'ioredis';
|
||||||
|
import { TransactionManager, RedisStorage } from '@esengine/transaction';
|
||||||
|
|
||||||
|
const redis = new Redis('redis://localhost:6379');
|
||||||
|
const storage = new RedisStorage({ client: redis });
|
||||||
|
|
||||||
|
const manager = new TransactionManager({ storage });
|
||||||
|
```
|
||||||
|
|
||||||
|
### Using MongoDB Storage
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { MongoClient } from 'mongodb';
|
||||||
|
import { TransactionManager, MongoStorage } from '@esengine/transaction';
|
||||||
|
|
||||||
|
const client = new MongoClient('mongodb://localhost:27017');
|
||||||
|
await client.connect();
|
||||||
|
const db = client.db('game');
|
||||||
|
|
||||||
|
const storage = new MongoStorage({ db });
|
||||||
|
await storage.ensureIndexes();
|
||||||
|
|
||||||
|
const manager = new TransactionManager({ storage });
|
||||||
|
```
|
||||||
|
|
||||||
|
## Room Integration
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { Room } from '@esengine/server';
|
||||||
|
import { withTransactions, CurrencyOperation, RedisStorage } from '@esengine/transaction';
|
||||||
|
|
||||||
|
class GameRoom extends withTransactions(Room, {
|
||||||
|
storage: new RedisStorage({ client: redisClient }),
|
||||||
|
}) {
|
||||||
|
@onMessage('Buy')
|
||||||
|
async handleBuy(data: { itemId: string }, player: Player) {
|
||||||
|
const result = await this.runTransaction((tx) => {
|
||||||
|
tx.addOperation(new CurrencyOperation({
|
||||||
|
type: 'deduct',
|
||||||
|
playerId: player.id,
|
||||||
|
currency: 'gold',
|
||||||
|
amount: getItemPrice(data.itemId),
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
player.send('buy_success', { itemId: data.itemId });
|
||||||
|
} else {
|
||||||
|
player.send('buy_failed', { error: result.error });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
- [Core Concepts](/en/modules/transaction/core/) - Transaction context, manager, Saga pattern
|
||||||
|
- [Storage Layer](/en/modules/transaction/storage/) - MemoryStorage, RedisStorage, MongoStorage
|
||||||
|
- [Operations](/en/modules/transaction/operations/) - Currency, inventory, trade operations
|
||||||
|
- [Distributed Transactions](/en/modules/transaction/distributed/) - Saga orchestrator, cross-server transactions
|
||||||
|
- [API Reference](/en/modules/transaction/api/) - Complete API documentation
|
||||||
|
|
||||||
|
## Service Tokens
|
||||||
|
|
||||||
|
For dependency injection:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import {
|
||||||
|
TransactionManagerToken,
|
||||||
|
TransactionStorageToken,
|
||||||
|
} from '@esengine/transaction';
|
||||||
|
|
||||||
|
const manager = services.get(TransactionManagerToken);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
### 1. Operation Granularity
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ✅ Good: Fine-grained operations, easy to rollback
|
||||||
|
tx.addOperation(new CurrencyOperation({ type: 'deduct', ... }));
|
||||||
|
tx.addOperation(new InventoryOperation({ type: 'add', ... }));
|
||||||
|
|
||||||
|
// ❌ Bad: Coarse-grained operation, hard to partially rollback
|
||||||
|
tx.addOperation(new ComplexPurchaseOperation({ ... }));
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Timeout Settings
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Simple operations: short timeout
|
||||||
|
await manager.run(tx => { ... }, { timeout: 5000 });
|
||||||
|
|
||||||
|
// Complex trades: longer timeout
|
||||||
|
await manager.run(tx => { ... }, { timeout: 30000 });
|
||||||
|
|
||||||
|
// Cross-server: even longer timeout
|
||||||
|
await manager.run(tx => { ... }, { timeout: 60000, distributed: true });
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Error Handling
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const result = await manager.run((tx) => { ... });
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
// Log the error
|
||||||
|
logger.error('Transaction failed', {
|
||||||
|
transactionId: result.transactionId,
|
||||||
|
error: result.error,
|
||||||
|
duration: result.duration,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Notify user
|
||||||
|
player.send('error', { message: getErrorMessage(result.error) });
|
||||||
|
}
|
||||||
|
```
|
||||||
313
docs/src/content/docs/en/modules/transaction/operations.md
Normal file
313
docs/src/content/docs/en/modules/transaction/operations.md
Normal file
@@ -0,0 +1,313 @@
|
|||||||
|
---
|
||||||
|
title: "Operations"
|
||||||
|
description: "Built-in transaction operations: currency, inventory, trade"
|
||||||
|
---
|
||||||
|
|
||||||
|
## BaseOperation
|
||||||
|
|
||||||
|
Base class for all operations, providing a common implementation template.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { BaseOperation, ITransactionContext, OperationResult } from '@esengine/transaction';
|
||||||
|
|
||||||
|
class MyOperation extends BaseOperation<MyData, MyResult> {
|
||||||
|
readonly name = 'myOperation';
|
||||||
|
|
||||||
|
async validate(ctx: ITransactionContext): Promise<boolean> {
|
||||||
|
// Validate preconditions
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async execute(ctx: ITransactionContext): Promise<OperationResult<MyResult>> {
|
||||||
|
// Execute operation
|
||||||
|
return this.success({ result: 'ok' });
|
||||||
|
// or
|
||||||
|
return this.failure('Something went wrong', 'ERROR_CODE');
|
||||||
|
}
|
||||||
|
|
||||||
|
async compensate(ctx: ITransactionContext): Promise<void> {
|
||||||
|
// Rollback operation
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## CurrencyOperation
|
||||||
|
|
||||||
|
Handles currency addition and deduction.
|
||||||
|
|
||||||
|
### Deduct Currency
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { CurrencyOperation } from '@esengine/transaction';
|
||||||
|
|
||||||
|
tx.addOperation(new CurrencyOperation({
|
||||||
|
type: 'deduct',
|
||||||
|
playerId: 'player1',
|
||||||
|
currency: 'gold',
|
||||||
|
amount: 100,
|
||||||
|
reason: 'purchase_item',
|
||||||
|
}));
|
||||||
|
```
|
||||||
|
|
||||||
|
### Add Currency
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
tx.addOperation(new CurrencyOperation({
|
||||||
|
type: 'add',
|
||||||
|
playerId: 'player1',
|
||||||
|
currency: 'diamond',
|
||||||
|
amount: 50,
|
||||||
|
reason: 'daily_reward',
|
||||||
|
}));
|
||||||
|
```
|
||||||
|
|
||||||
|
### Operation Data
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface CurrencyOperationData {
|
||||||
|
type: 'add' | 'deduct'; // Operation type
|
||||||
|
playerId: string; // Player ID
|
||||||
|
currency: string; // Currency type
|
||||||
|
amount: number; // Amount
|
||||||
|
reason?: string; // Reason/source
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Operation Result
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface CurrencyOperationResult {
|
||||||
|
beforeBalance: number; // Balance before operation
|
||||||
|
afterBalance: number; // Balance after operation
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Custom Data Provider
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface ICurrencyProvider {
|
||||||
|
getBalance(playerId: string, currency: string): Promise<number>;
|
||||||
|
setBalance(playerId: string, currency: string, amount: number): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
class MyCurrencyProvider implements ICurrencyProvider {
|
||||||
|
async getBalance(playerId: string, currency: string): Promise<number> {
|
||||||
|
// Get balance from database
|
||||||
|
return await db.getCurrency(playerId, currency);
|
||||||
|
}
|
||||||
|
|
||||||
|
async setBalance(playerId: string, currency: string, amount: number): Promise<void> {
|
||||||
|
// Save to database
|
||||||
|
await db.setCurrency(playerId, currency, amount);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use custom provider
|
||||||
|
const op = new CurrencyOperation({ ... });
|
||||||
|
op.setProvider(new MyCurrencyProvider());
|
||||||
|
tx.addOperation(op);
|
||||||
|
```
|
||||||
|
|
||||||
|
## InventoryOperation
|
||||||
|
|
||||||
|
Handles item addition, removal, and updates.
|
||||||
|
|
||||||
|
### Add Item
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { InventoryOperation } from '@esengine/transaction';
|
||||||
|
|
||||||
|
tx.addOperation(new InventoryOperation({
|
||||||
|
type: 'add',
|
||||||
|
playerId: 'player1',
|
||||||
|
itemId: 'sword_001',
|
||||||
|
quantity: 1,
|
||||||
|
properties: { enchant: 'fire' },
|
||||||
|
}));
|
||||||
|
```
|
||||||
|
|
||||||
|
### Remove Item
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
tx.addOperation(new InventoryOperation({
|
||||||
|
type: 'remove',
|
||||||
|
playerId: 'player1',
|
||||||
|
itemId: 'potion_hp',
|
||||||
|
quantity: 5,
|
||||||
|
}));
|
||||||
|
```
|
||||||
|
|
||||||
|
### Update Item
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
tx.addOperation(new InventoryOperation({
|
||||||
|
type: 'update',
|
||||||
|
playerId: 'player1',
|
||||||
|
itemId: 'sword_001',
|
||||||
|
quantity: 1, // Optional, keeps original if not provided
|
||||||
|
properties: { enchant: 'lightning', level: 5 },
|
||||||
|
}));
|
||||||
|
```
|
||||||
|
|
||||||
|
### Operation Data
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface InventoryOperationData {
|
||||||
|
type: 'add' | 'remove' | 'update'; // Operation type
|
||||||
|
playerId: string; // Player ID
|
||||||
|
itemId: string; // Item ID
|
||||||
|
quantity: number; // Quantity
|
||||||
|
properties?: Record<string, unknown>; // Item properties
|
||||||
|
reason?: string; // Reason/source
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Operation Result
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface InventoryOperationResult {
|
||||||
|
beforeItem?: ItemData; // Item before operation
|
||||||
|
afterItem?: ItemData; // Item after operation
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ItemData {
|
||||||
|
itemId: string;
|
||||||
|
quantity: number;
|
||||||
|
properties?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Custom Data Provider
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface IInventoryProvider {
|
||||||
|
getItem(playerId: string, itemId: string): Promise<ItemData | null>;
|
||||||
|
setItem(playerId: string, itemId: string, item: ItemData | null): Promise<void>;
|
||||||
|
hasCapacity?(playerId: string, count: number): Promise<boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
class MyInventoryProvider implements IInventoryProvider {
|
||||||
|
async getItem(playerId: string, itemId: string): Promise<ItemData | null> {
|
||||||
|
return await db.getItem(playerId, itemId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async setItem(playerId: string, itemId: string, item: ItemData | null): Promise<void> {
|
||||||
|
if (item) {
|
||||||
|
await db.saveItem(playerId, itemId, item);
|
||||||
|
} else {
|
||||||
|
await db.deleteItem(playerId, itemId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async hasCapacity(playerId: string, count: number): Promise<boolean> {
|
||||||
|
const current = await db.getItemCount(playerId);
|
||||||
|
const max = await db.getMaxCapacity(playerId);
|
||||||
|
return current + count <= max;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## TradeOperation
|
||||||
|
|
||||||
|
Handles item and currency exchange between players.
|
||||||
|
|
||||||
|
### Basic Usage
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { TradeOperation } from '@esengine/transaction';
|
||||||
|
|
||||||
|
tx.addOperation(new TradeOperation({
|
||||||
|
tradeId: 'trade_001',
|
||||||
|
partyA: {
|
||||||
|
playerId: 'player1',
|
||||||
|
items: [{ itemId: 'sword', quantity: 1 }],
|
||||||
|
currencies: [{ currency: 'diamond', amount: 10 }],
|
||||||
|
},
|
||||||
|
partyB: {
|
||||||
|
playerId: 'player2',
|
||||||
|
currencies: [{ currency: 'gold', amount: 1000 }],
|
||||||
|
},
|
||||||
|
reason: 'player_trade',
|
||||||
|
}));
|
||||||
|
```
|
||||||
|
|
||||||
|
### Operation Data
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface TradeOperationData {
|
||||||
|
tradeId: string; // Trade ID
|
||||||
|
partyA: TradeParty; // Trade initiator
|
||||||
|
partyB: TradeParty; // Trade receiver
|
||||||
|
reason?: string; // Reason/note
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TradeParty {
|
||||||
|
playerId: string; // Player ID
|
||||||
|
items?: TradeItem[]; // Items to give
|
||||||
|
currencies?: TradeCurrency[]; // Currencies to give
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TradeItem {
|
||||||
|
itemId: string;
|
||||||
|
quantity: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TradeCurrency {
|
||||||
|
currency: string;
|
||||||
|
amount: number;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Execution Flow
|
||||||
|
|
||||||
|
TradeOperation internally generates the following sub-operation sequence:
|
||||||
|
|
||||||
|
```
|
||||||
|
1. Remove partyA's items
|
||||||
|
2. Add items to partyB (from partyA)
|
||||||
|
3. Deduct partyA's currencies
|
||||||
|
4. Add currencies to partyB (from partyA)
|
||||||
|
5. Remove partyB's items
|
||||||
|
6. Add items to partyA (from partyB)
|
||||||
|
7. Deduct partyB's currencies
|
||||||
|
8. Add currencies to partyA (from partyB)
|
||||||
|
```
|
||||||
|
|
||||||
|
If any step fails, all previous operations are rolled back.
|
||||||
|
|
||||||
|
### Using Custom Providers
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const op = new TradeOperation({ ... });
|
||||||
|
op.setProvider({
|
||||||
|
currencyProvider: new MyCurrencyProvider(),
|
||||||
|
inventoryProvider: new MyInventoryProvider(),
|
||||||
|
});
|
||||||
|
tx.addOperation(op);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Factory Functions
|
||||||
|
|
||||||
|
Each operation class provides a factory function:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import {
|
||||||
|
createCurrencyOperation,
|
||||||
|
createInventoryOperation,
|
||||||
|
createTradeOperation,
|
||||||
|
} from '@esengine/transaction';
|
||||||
|
|
||||||
|
tx.addOperation(createCurrencyOperation({
|
||||||
|
type: 'deduct',
|
||||||
|
playerId: 'player1',
|
||||||
|
currency: 'gold',
|
||||||
|
amount: 100,
|
||||||
|
}));
|
||||||
|
|
||||||
|
tx.addOperation(createInventoryOperation({
|
||||||
|
type: 'add',
|
||||||
|
playerId: 'player1',
|
||||||
|
itemId: 'sword',
|
||||||
|
quantity: 1,
|
||||||
|
}));
|
||||||
|
```
|
||||||
234
docs/src/content/docs/en/modules/transaction/storage.md
Normal file
234
docs/src/content/docs/en/modules/transaction/storage.md
Normal file
@@ -0,0 +1,234 @@
|
|||||||
|
---
|
||||||
|
title: "Storage Layer"
|
||||||
|
description: "Transaction storage interface and implementations: MemoryStorage, RedisStorage, MongoStorage"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Storage Interface
|
||||||
|
|
||||||
|
All storage implementations must implement the `ITransactionStorage` interface:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface ITransactionStorage {
|
||||||
|
// Lifecycle
|
||||||
|
close?(): Promise<void>;
|
||||||
|
|
||||||
|
// Distributed lock
|
||||||
|
acquireLock(key: string, ttl: number): Promise<string | null>;
|
||||||
|
releaseLock(key: string, token: string): Promise<boolean>;
|
||||||
|
|
||||||
|
// Transaction log
|
||||||
|
saveTransaction(tx: TransactionLog): Promise<void>;
|
||||||
|
getTransaction(id: string): Promise<TransactionLog | null>;
|
||||||
|
updateTransactionState(id: string, state: TransactionState): Promise<void>;
|
||||||
|
updateOperationState(txId: string, opIndex: number, state: string, error?: string): Promise<void>;
|
||||||
|
getPendingTransactions(serverId?: string): Promise<TransactionLog[]>;
|
||||||
|
deleteTransaction(id: string): Promise<void>;
|
||||||
|
|
||||||
|
// Data operations
|
||||||
|
get<T>(key: string): Promise<T | null>;
|
||||||
|
set<T>(key: string, value: T, ttl?: number): Promise<void>;
|
||||||
|
delete(key: string): Promise<boolean>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## MemoryStorage
|
||||||
|
|
||||||
|
In-memory storage, suitable for development and testing.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { MemoryStorage } from '@esengine/transaction';
|
||||||
|
|
||||||
|
const storage = new MemoryStorage({
|
||||||
|
maxTransactions: 1000, // Maximum transaction log count
|
||||||
|
});
|
||||||
|
|
||||||
|
const manager = new TransactionManager({ storage });
|
||||||
|
```
|
||||||
|
|
||||||
|
### Characteristics
|
||||||
|
|
||||||
|
- ✅ No external dependencies
|
||||||
|
- ✅ Fast, good for debugging
|
||||||
|
- ❌ Data only stored in memory
|
||||||
|
- ❌ No true distributed locking
|
||||||
|
- ❌ Data lost on restart
|
||||||
|
|
||||||
|
### Test Helpers
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Clear all data
|
||||||
|
storage.clear();
|
||||||
|
|
||||||
|
// Get transaction count
|
||||||
|
console.log(storage.transactionCount);
|
||||||
|
```
|
||||||
|
|
||||||
|
## RedisStorage
|
||||||
|
|
||||||
|
Redis storage, suitable for production distributed systems. Uses factory pattern with lazy connection.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import Redis from 'ioredis';
|
||||||
|
import { RedisStorage } from '@esengine/transaction';
|
||||||
|
|
||||||
|
// Factory pattern: lazy connection, connects on first operation
|
||||||
|
const storage = new RedisStorage({
|
||||||
|
factory: () => new Redis('redis://localhost:6379'),
|
||||||
|
prefix: 'tx:', // Key prefix
|
||||||
|
transactionTTL: 86400, // Transaction log TTL (seconds)
|
||||||
|
});
|
||||||
|
|
||||||
|
const manager = new TransactionManager({ storage });
|
||||||
|
|
||||||
|
// Close connection when done
|
||||||
|
await storage.close();
|
||||||
|
|
||||||
|
// Or use await using for automatic cleanup (TypeScript 5.2+)
|
||||||
|
await using storage = new RedisStorage({
|
||||||
|
factory: () => new Redis('redis://localhost:6379')
|
||||||
|
});
|
||||||
|
// Automatically closed when scope ends
|
||||||
|
```
|
||||||
|
|
||||||
|
### Characteristics
|
||||||
|
|
||||||
|
- ✅ High-performance distributed locking
|
||||||
|
- ✅ Fast read/write
|
||||||
|
- ✅ Supports TTL auto-expiration
|
||||||
|
- ✅ Suitable for high concurrency
|
||||||
|
- ❌ Requires Redis server
|
||||||
|
|
||||||
|
### Distributed Lock Implementation
|
||||||
|
|
||||||
|
Uses Redis `SET NX EX` for distributed locking:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Acquire lock (atomic operation)
|
||||||
|
SET tx:lock:player:123 <token> NX EX 10
|
||||||
|
|
||||||
|
// Release lock (Lua script for atomicity)
|
||||||
|
if redis.call("get", KEYS[1]) == ARGV[1] then
|
||||||
|
return redis.call("del", KEYS[1])
|
||||||
|
else
|
||||||
|
return 0
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
### Key Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
tx:lock:{key} - Distributed locks
|
||||||
|
tx:tx:{id} - Transaction logs
|
||||||
|
tx:server:{id}:txs - Server transaction index
|
||||||
|
tx:data:{key} - Business data
|
||||||
|
```
|
||||||
|
|
||||||
|
## MongoStorage
|
||||||
|
|
||||||
|
MongoDB storage, suitable for scenarios requiring persistence and complex queries. Uses factory pattern with lazy connection.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { MongoClient } from 'mongodb';
|
||||||
|
import { MongoStorage } from '@esengine/transaction';
|
||||||
|
|
||||||
|
// Factory pattern: lazy connection, connects on first operation
|
||||||
|
const storage = new MongoStorage({
|
||||||
|
factory: async () => {
|
||||||
|
const client = new MongoClient('mongodb://localhost:27017');
|
||||||
|
await client.connect();
|
||||||
|
return client;
|
||||||
|
},
|
||||||
|
database: 'game',
|
||||||
|
transactionCollection: 'transactions', // Transaction log collection
|
||||||
|
dataCollection: 'transaction_data', // Business data collection
|
||||||
|
lockCollection: 'transaction_locks', // Lock collection
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create indexes (run on first startup)
|
||||||
|
await storage.ensureIndexes();
|
||||||
|
|
||||||
|
const manager = new TransactionManager({ storage });
|
||||||
|
|
||||||
|
// Close connection when done
|
||||||
|
await storage.close();
|
||||||
|
|
||||||
|
// Or use await using for automatic cleanup (TypeScript 5.2+)
|
||||||
|
await using storage = new MongoStorage({ ... });
|
||||||
|
```
|
||||||
|
|
||||||
|
### Characteristics
|
||||||
|
|
||||||
|
- ✅ Persistent storage
|
||||||
|
- ✅ Supports complex queries
|
||||||
|
- ✅ Transaction logs are traceable
|
||||||
|
- ✅ Suitable for audit requirements
|
||||||
|
- ❌ Slightly lower performance than Redis
|
||||||
|
- ❌ Requires MongoDB server
|
||||||
|
|
||||||
|
### Index Structure
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// transactions collection
|
||||||
|
{ state: 1 }
|
||||||
|
{ 'metadata.serverId': 1 }
|
||||||
|
{ createdAt: 1 }
|
||||||
|
|
||||||
|
// transaction_locks collection
|
||||||
|
{ expireAt: 1 } // TTL index
|
||||||
|
|
||||||
|
// transaction_data collection
|
||||||
|
{ expireAt: 1 } // TTL index
|
||||||
|
```
|
||||||
|
|
||||||
|
### Distributed Lock Implementation
|
||||||
|
|
||||||
|
Uses MongoDB unique index for distributed locking:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Acquire lock
|
||||||
|
db.transaction_locks.insertOne({
|
||||||
|
_id: 'player:123',
|
||||||
|
token: '<token>',
|
||||||
|
expireAt: new Date(Date.now() + 10000)
|
||||||
|
});
|
||||||
|
|
||||||
|
// If key exists, check if expired
|
||||||
|
db.transaction_locks.updateOne(
|
||||||
|
{ _id: 'player:123', expireAt: { $lt: new Date() } },
|
||||||
|
{ $set: { token: '<token>', expireAt: new Date(Date.now() + 10000) } }
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Storage Selection Guide
|
||||||
|
|
||||||
|
| Scenario | Recommended Storage | Reason |
|
||||||
|
|----------|---------------------|--------|
|
||||||
|
| Development/Testing | MemoryStorage | No dependencies, fast startup |
|
||||||
|
| Single-machine Production | RedisStorage | High performance, simple |
|
||||||
|
| Distributed System | RedisStorage | True distributed locking |
|
||||||
|
| Audit Required | MongoStorage | Persistent logs |
|
||||||
|
| Mixed Requirements | Redis + Mongo | Redis for locks, Mongo for logs |
|
||||||
|
|
||||||
|
## Custom Storage
|
||||||
|
|
||||||
|
Implement `ITransactionStorage` interface to create custom storage:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { ITransactionStorage, TransactionLog, TransactionState } from '@esengine/transaction';
|
||||||
|
|
||||||
|
class MyCustomStorage implements ITransactionStorage {
|
||||||
|
async acquireLock(key: string, ttl: number): Promise<string | null> {
|
||||||
|
// Implement distributed lock acquisition
|
||||||
|
}
|
||||||
|
|
||||||
|
async releaseLock(key: string, token: string): Promise<boolean> {
|
||||||
|
// Implement distributed lock release
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveTransaction(tx: TransactionLog): Promise<void> {
|
||||||
|
// Save transaction log
|
||||||
|
}
|
||||||
|
|
||||||
|
// ... implement other methods
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
---
|
||||||
|
title: "Chunk Manager API"
|
||||||
|
description: "ChunkManager handles chunk lifecycle, loading queue, and spatial queries"
|
||||||
|
---
|
||||||
|
|
||||||
|
The `ChunkManager` is the core service responsible for managing chunk lifecycle, including loading, unloading, and spatial queries.
|
||||||
|
|
||||||
|
## Basic Usage
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { ChunkManager } from '@esengine/world-streaming';
|
||||||
|
|
||||||
|
// Create manager with 512-unit chunks
|
||||||
|
const chunkManager = new ChunkManager(512);
|
||||||
|
chunkManager.setScene(scene);
|
||||||
|
|
||||||
|
// Set data provider for loading chunks
|
||||||
|
chunkManager.setDataProvider(myProvider);
|
||||||
|
|
||||||
|
// Set event callbacks
|
||||||
|
chunkManager.setEvents({
|
||||||
|
onChunkLoaded: (coord, entities) => {
|
||||||
|
console.log(`Chunk (${coord.x}, ${coord.y}) loaded with ${entities.length} entities`);
|
||||||
|
},
|
||||||
|
onChunkUnloaded: (coord) => {
|
||||||
|
console.log(`Chunk (${coord.x}, ${coord.y}) unloaded`);
|
||||||
|
},
|
||||||
|
onChunkLoadFailed: (coord, error) => {
|
||||||
|
console.error(`Failed to load chunk (${coord.x}, ${coord.y}):`, error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Loading and Unloading
|
||||||
|
|
||||||
|
### Request Loading
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { EChunkPriority } from '@esengine/world-streaming';
|
||||||
|
|
||||||
|
// Request with priority
|
||||||
|
chunkManager.requestLoad({ x: 0, y: 0 }, EChunkPriority.Immediate);
|
||||||
|
chunkManager.requestLoad({ x: 1, y: 0 }, EChunkPriority.High);
|
||||||
|
chunkManager.requestLoad({ x: 2, y: 0 }, EChunkPriority.Normal);
|
||||||
|
chunkManager.requestLoad({ x: 3, y: 0 }, EChunkPriority.Low);
|
||||||
|
chunkManager.requestLoad({ x: 4, y: 0 }, EChunkPriority.Prefetch);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Priority Levels
|
||||||
|
|
||||||
|
| Priority | Value | Description |
|
||||||
|
|----------|-------|-------------|
|
||||||
|
| `Immediate` | 0 | Current chunk (player standing on) |
|
||||||
|
| `High` | 1 | Adjacent chunks |
|
||||||
|
| `Normal` | 2 | Nearby chunks |
|
||||||
|
| `Low` | 3 | Distant visible chunks |
|
||||||
|
| `Prefetch` | 4 | Movement direction prefetch |
|
||||||
|
|
||||||
|
### Request Unloading
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Request unload with 3 second delay
|
||||||
|
chunkManager.requestUnload({ x: 5, y: 5 }, 3000);
|
||||||
|
|
||||||
|
// Cancel pending unload (player moved back)
|
||||||
|
chunkManager.cancelUnload({ x: 5, y: 5 });
|
||||||
|
```
|
||||||
|
|
||||||
|
### Process Queues
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// In your update loop or system
|
||||||
|
await chunkManager.processLoads(2); // Load up to 2 chunks per frame
|
||||||
|
chunkManager.processUnloads(1); // Unload up to 1 chunk per frame
|
||||||
|
```
|
||||||
|
|
||||||
|
## Spatial Queries
|
||||||
|
|
||||||
|
### Coordinate Conversion
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// World position to chunk coordinates
|
||||||
|
const coord = chunkManager.worldToChunk(1500, 2300);
|
||||||
|
// Result: { x: 2, y: 4 } for 512-unit chunks
|
||||||
|
|
||||||
|
// Get chunk bounds in world space
|
||||||
|
const bounds = chunkManager.getChunkBounds({ x: 2, y: 4 });
|
||||||
|
// Result: { minX: 1024, minY: 2048, maxX: 1536, maxY: 2560 }
|
||||||
|
```
|
||||||
|
|
||||||
|
### Chunk Queries
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Check if chunk is loaded
|
||||||
|
if (chunkManager.isChunkLoaded({ x: 0, y: 0 })) {
|
||||||
|
const chunk = chunkManager.getChunk({ x: 0, y: 0 });
|
||||||
|
console.log('Entities:', chunk.entities.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get missing chunks in radius
|
||||||
|
const missing = chunkManager.getMissingChunks({ x: 0, y: 0 }, 2);
|
||||||
|
for (const coord of missing) {
|
||||||
|
chunkManager.requestLoad(coord);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get chunks outside radius (for unloading)
|
||||||
|
const outside = chunkManager.getChunksOutsideRadius({ x: 0, y: 0 }, 4);
|
||||||
|
for (const coord of outside) {
|
||||||
|
chunkManager.requestUnload(coord, 3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Iterate all loaded chunks
|
||||||
|
chunkManager.forEachChunk((info, coord) => {
|
||||||
|
console.log(`Chunk (${coord.x}, ${coord.y}): ${info.state}`);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Statistics
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
console.log('Loaded chunks:', chunkManager.loadedChunkCount);
|
||||||
|
console.log('Pending loads:', chunkManager.pendingLoadCount);
|
||||||
|
console.log('Pending unloads:', chunkManager.pendingUnloadCount);
|
||||||
|
console.log('Chunk size:', chunkManager.chunkSize);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Chunk States
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { EChunkState } from '@esengine/world-streaming';
|
||||||
|
|
||||||
|
// Chunk lifecycle states
|
||||||
|
EChunkState.Unloaded // Not in memory
|
||||||
|
EChunkState.Loading // Being loaded
|
||||||
|
EChunkState.Loaded // Ready for use
|
||||||
|
EChunkState.Unloading // Being removed
|
||||||
|
EChunkState.Failed // Load failed
|
||||||
|
```
|
||||||
|
|
||||||
|
## Data Provider Interface
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import type { IChunkDataProvider, IChunkCoord, IChunkData } from '@esengine/world-streaming';
|
||||||
|
|
||||||
|
class MyChunkProvider implements IChunkDataProvider {
|
||||||
|
async loadChunkData(coord: IChunkCoord): Promise<IChunkData | null> {
|
||||||
|
// Load from database, file, or procedural generation
|
||||||
|
const data = await fetchChunkFromServer(coord);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveChunkData(data: IChunkData): Promise<void> {
|
||||||
|
// Save modified chunks
|
||||||
|
await saveChunkToServer(data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cleanup
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Unload all chunks
|
||||||
|
chunkManager.clear();
|
||||||
|
|
||||||
|
// Full disposal (implements IService)
|
||||||
|
chunkManager.dispose();
|
||||||
|
```
|
||||||
330
docs/src/content/docs/en/modules/world-streaming/examples.md
Normal file
330
docs/src/content/docs/en/modules/world-streaming/examples.md
Normal file
@@ -0,0 +1,330 @@
|
|||||||
|
---
|
||||||
|
title: "Examples"
|
||||||
|
description: "Practical examples of world streaming"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Infinite Procedural World
|
||||||
|
|
||||||
|
An infinite world with procedural resource generation.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import {
|
||||||
|
ChunkManager,
|
||||||
|
ChunkStreamingSystem,
|
||||||
|
ChunkLoaderComponent,
|
||||||
|
StreamingAnchorComponent
|
||||||
|
} from '@esengine/world-streaming';
|
||||||
|
import type { IChunkDataProvider, IChunkCoord, IChunkData } from '@esengine/world-streaming';
|
||||||
|
|
||||||
|
// Procedural world generator
|
||||||
|
class WorldGenerator implements IChunkDataProvider {
|
||||||
|
private seed: number;
|
||||||
|
private nextEntityId = 1;
|
||||||
|
|
||||||
|
constructor(seed: number = 12345) {
|
||||||
|
this.seed = seed;
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadChunkData(coord: IChunkCoord): Promise<IChunkData | null> {
|
||||||
|
const rng = this.createChunkRNG(coord);
|
||||||
|
const entities = [];
|
||||||
|
|
||||||
|
// Generate 5-15 resources per chunk
|
||||||
|
const resourceCount = 5 + Math.floor(rng() * 10);
|
||||||
|
|
||||||
|
for (let i = 0; i < resourceCount; i++) {
|
||||||
|
const type = this.randomResourceType(rng);
|
||||||
|
|
||||||
|
entities.push({
|
||||||
|
name: `Resource_${this.nextEntityId++}`,
|
||||||
|
localPosition: {
|
||||||
|
x: rng() * 512,
|
||||||
|
y: rng() * 512
|
||||||
|
},
|
||||||
|
components: {
|
||||||
|
ResourceNode: {
|
||||||
|
type,
|
||||||
|
amount: this.getResourceAmount(type, rng),
|
||||||
|
regenRate: this.getRegenRate(type)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { coord, entities, version: 1 };
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveChunkData(_data: IChunkData): Promise<void> {
|
||||||
|
// Procedural - no persistence needed
|
||||||
|
}
|
||||||
|
|
||||||
|
private createChunkRNG(coord: IChunkCoord) {
|
||||||
|
let seed = this.seed ^ (coord.x * 73856093) ^ (coord.y * 19349663);
|
||||||
|
return () => {
|
||||||
|
seed = (seed * 1103515245 + 12345) & 0x7fffffff;
|
||||||
|
return seed / 0x7fffffff;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private randomResourceType(rng: () => number) {
|
||||||
|
const types = ['energyWell', 'oreVein', 'crystalDeposit'];
|
||||||
|
const weights = [0.5, 0.35, 0.15];
|
||||||
|
|
||||||
|
let random = rng();
|
||||||
|
for (let i = 0; i < types.length; i++) {
|
||||||
|
random -= weights[i];
|
||||||
|
if (random <= 0) return types[i];
|
||||||
|
}
|
||||||
|
return types[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
private getResourceAmount(type: string, rng: () => number) {
|
||||||
|
switch (type) {
|
||||||
|
case 'energyWell': return 300 + Math.floor(rng() * 200);
|
||||||
|
case 'oreVein': return 500 + Math.floor(rng() * 300);
|
||||||
|
case 'crystalDeposit': return 100 + Math.floor(rng() * 100);
|
||||||
|
default: return 100;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private getRegenRate(type: string) {
|
||||||
|
switch (type) {
|
||||||
|
case 'energyWell': return 2;
|
||||||
|
case 'oreVein': return 1;
|
||||||
|
case 'crystalDeposit': return 0.2;
|
||||||
|
default: return 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Setup
|
||||||
|
const chunkManager = new ChunkManager(512);
|
||||||
|
chunkManager.setScene(scene);
|
||||||
|
chunkManager.setDataProvider(new WorldGenerator(12345));
|
||||||
|
|
||||||
|
const streamingSystem = new ChunkStreamingSystem();
|
||||||
|
streamingSystem.setChunkManager(chunkManager);
|
||||||
|
scene.addSystem(streamingSystem);
|
||||||
|
```
|
||||||
|
|
||||||
|
## MMO Server Chunks
|
||||||
|
|
||||||
|
Server-side chunk management for MMO with database persistence.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
class ServerChunkProvider implements IChunkDataProvider {
|
||||||
|
private db: Database;
|
||||||
|
private cache = new Map<string, IChunkData>();
|
||||||
|
|
||||||
|
constructor(db: Database) {
|
||||||
|
this.db = db;
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadChunkData(coord: IChunkCoord): Promise<IChunkData | null> {
|
||||||
|
const key = `${coord.x},${coord.y}`;
|
||||||
|
|
||||||
|
// Check cache
|
||||||
|
if (this.cache.has(key)) {
|
||||||
|
return this.cache.get(key)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load from database
|
||||||
|
const row = await this.db.query(
|
||||||
|
'SELECT data FROM chunks WHERE x = ? AND y = ?',
|
||||||
|
[coord.x, coord.y]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (row) {
|
||||||
|
const data = JSON.parse(row.data);
|
||||||
|
this.cache.set(key, data);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate new chunk
|
||||||
|
const data = this.generateChunk(coord);
|
||||||
|
await this.saveChunkData(data);
|
||||||
|
this.cache.set(key, data);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveChunkData(data: IChunkData): Promise<void> {
|
||||||
|
const key = `${data.coord.x},${data.coord.y}`;
|
||||||
|
this.cache.set(key, data);
|
||||||
|
|
||||||
|
await this.db.query(
|
||||||
|
`INSERT INTO chunks (x, y, data) VALUES (?, ?, ?)
|
||||||
|
ON DUPLICATE KEY UPDATE data = VALUES(data)`,
|
||||||
|
[data.coord.x, data.coord.y, JSON.stringify(data)]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private generateChunk(coord: IChunkCoord): IChunkData {
|
||||||
|
// Procedural generation for new chunks
|
||||||
|
return { coord, entities: [], version: 1 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per-player chunk loading on server
|
||||||
|
class PlayerChunkManager {
|
||||||
|
private chunkManager: ChunkManager;
|
||||||
|
private playerChunks = new Map<string, Set<string>>();
|
||||||
|
|
||||||
|
async updatePlayerPosition(playerId: string, x: number, y: number) {
|
||||||
|
const centerCoord = this.chunkManager.worldToChunk(x, y);
|
||||||
|
const loadRadius = 2;
|
||||||
|
|
||||||
|
const newChunks = new Set<string>();
|
||||||
|
|
||||||
|
// Load chunks around player
|
||||||
|
for (let dx = -loadRadius; dx <= loadRadius; dx++) {
|
||||||
|
for (let dy = -loadRadius; dy <= loadRadius; dy++) {
|
||||||
|
const coord = { x: centerCoord.x + dx, y: centerCoord.y + dy };
|
||||||
|
const key = `${coord.x},${coord.y}`;
|
||||||
|
newChunks.add(key);
|
||||||
|
|
||||||
|
if (!this.chunkManager.isChunkLoaded(coord)) {
|
||||||
|
await this.chunkManager.requestLoad(coord);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track player's loaded chunks
|
||||||
|
this.playerChunks.set(playerId, newChunks);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tile-Based World
|
||||||
|
|
||||||
|
Tilemap integration with chunk streaming.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { TilemapComponent } from '@esengine/tilemap';
|
||||||
|
|
||||||
|
class TilemapChunkProvider implements IChunkDataProvider {
|
||||||
|
private tilemapData: number[][]; // Full tilemap
|
||||||
|
private tileSize = 32;
|
||||||
|
private chunkTiles = 16; // 16x16 tiles per chunk
|
||||||
|
|
||||||
|
async loadChunkData(coord: IChunkCoord): Promise<IChunkData | null> {
|
||||||
|
const startTileX = coord.x * this.chunkTiles;
|
||||||
|
const startTileY = coord.y * this.chunkTiles;
|
||||||
|
|
||||||
|
// Extract tiles for this chunk
|
||||||
|
const tiles: number[][] = [];
|
||||||
|
for (let y = 0; y < this.chunkTiles; y++) {
|
||||||
|
const row: number[] = [];
|
||||||
|
for (let x = 0; x < this.chunkTiles; x++) {
|
||||||
|
const tileX = startTileX + x;
|
||||||
|
const tileY = startTileY + y;
|
||||||
|
row.push(this.getTile(tileX, tileY));
|
||||||
|
}
|
||||||
|
tiles.push(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
coord,
|
||||||
|
entities: [{
|
||||||
|
name: `TileChunk_${coord.x}_${coord.y}`,
|
||||||
|
localPosition: { x: 0, y: 0 },
|
||||||
|
components: {
|
||||||
|
TilemapChunk: { tiles }
|
||||||
|
}
|
||||||
|
}],
|
||||||
|
version: 1
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private getTile(x: number, y: number): number {
|
||||||
|
if (x < 0 || y < 0 || y >= this.tilemapData.length) {
|
||||||
|
return 0; // Out of bounds = empty
|
||||||
|
}
|
||||||
|
return this.tilemapData[y]?.[x] ?? 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Custom serializer for tilemap
|
||||||
|
class TilemapSerializer extends ChunkSerializer {
|
||||||
|
protected deserializeComponents(entity: Entity, components: Record<string, unknown>): void {
|
||||||
|
if (components.TilemapChunk) {
|
||||||
|
const data = components.TilemapChunk as { tiles: number[][] };
|
||||||
|
const tilemap = entity.addComponent(new TilemapComponent());
|
||||||
|
tilemap.loadTiles(data.tiles);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Dynamic Loading Events
|
||||||
|
|
||||||
|
React to chunk loading for gameplay.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
chunkManager.setEvents({
|
||||||
|
onChunkLoaded: (coord, entities) => {
|
||||||
|
// Enable physics
|
||||||
|
for (const entity of entities) {
|
||||||
|
const collider = entity.getComponent(ColliderComponent);
|
||||||
|
collider?.enable();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Spawn NPCs for loaded chunks
|
||||||
|
npcManager.spawnForChunk(coord);
|
||||||
|
|
||||||
|
// Update fog of war
|
||||||
|
fogOfWar.revealChunk(coord);
|
||||||
|
|
||||||
|
// Notify clients (server)
|
||||||
|
broadcast('ChunkLoaded', { coord, entityCount: entities.length });
|
||||||
|
},
|
||||||
|
|
||||||
|
onChunkUnloaded: (coord) => {
|
||||||
|
// Save NPC states
|
||||||
|
npcManager.saveAndRemoveForChunk(coord);
|
||||||
|
|
||||||
|
// Update fog
|
||||||
|
fogOfWar.hideChunk(coord);
|
||||||
|
|
||||||
|
// Notify clients
|
||||||
|
broadcast('ChunkUnloaded', { coord });
|
||||||
|
},
|
||||||
|
|
||||||
|
onChunkLoadFailed: (coord, error) => {
|
||||||
|
console.error(`Failed to load chunk ${coord.x},${coord.y}:`, error);
|
||||||
|
|
||||||
|
// Retry after delay
|
||||||
|
setTimeout(() => {
|
||||||
|
chunkManager.requestLoad(coord);
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Performance Optimization
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Adjust based on device performance
|
||||||
|
function configureForDevice(loader: ChunkLoaderComponent) {
|
||||||
|
const memory = navigator.deviceMemory ?? 4;
|
||||||
|
const cores = navigator.hardwareConcurrency ?? 4;
|
||||||
|
|
||||||
|
if (memory <= 2 || cores <= 2) {
|
||||||
|
// Low-end device
|
||||||
|
loader.loadRadius = 1;
|
||||||
|
loader.unloadRadius = 2;
|
||||||
|
loader.maxLoadsPerFrame = 1;
|
||||||
|
loader.bEnablePrefetch = false;
|
||||||
|
} else if (memory <= 4) {
|
||||||
|
// Mid-range
|
||||||
|
loader.loadRadius = 2;
|
||||||
|
loader.unloadRadius = 3;
|
||||||
|
loader.maxLoadsPerFrame = 2;
|
||||||
|
} else {
|
||||||
|
// High-end
|
||||||
|
loader.loadRadius = 3;
|
||||||
|
loader.unloadRadius = 5;
|
||||||
|
loader.maxLoadsPerFrame = 4;
|
||||||
|
loader.prefetchRadius = 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
158
docs/src/content/docs/en/modules/world-streaming/index.md
Normal file
158
docs/src/content/docs/en/modules/world-streaming/index.md
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
---
|
||||||
|
title: "World Streaming"
|
||||||
|
description: "Chunk-based world streaming for open world games"
|
||||||
|
---
|
||||||
|
|
||||||
|
`@esengine/world-streaming` provides chunk-based world streaming and management for open world games. It handles dynamic loading/unloading of world chunks based on player position.
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install @esengine/world-streaming
|
||||||
|
```
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### Basic Setup
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import {
|
||||||
|
ChunkManager,
|
||||||
|
ChunkStreamingSystem,
|
||||||
|
StreamingAnchorComponent,
|
||||||
|
ChunkLoaderComponent
|
||||||
|
} from '@esengine/world-streaming';
|
||||||
|
|
||||||
|
// Create chunk manager (512 unit chunks)
|
||||||
|
const chunkManager = new ChunkManager(512);
|
||||||
|
chunkManager.setScene(scene);
|
||||||
|
|
||||||
|
// Add streaming system
|
||||||
|
const streamingSystem = new ChunkStreamingSystem();
|
||||||
|
streamingSystem.setChunkManager(chunkManager);
|
||||||
|
scene.addSystem(streamingSystem);
|
||||||
|
|
||||||
|
// Create loader entity with config
|
||||||
|
const loaderEntity = scene.createEntity('ChunkLoader');
|
||||||
|
const loader = loaderEntity.addComponent(new ChunkLoaderComponent());
|
||||||
|
loader.chunkSize = 512;
|
||||||
|
loader.loadRadius = 2;
|
||||||
|
loader.unloadRadius = 4;
|
||||||
|
|
||||||
|
// Create player as streaming anchor
|
||||||
|
const playerEntity = scene.createEntity('Player');
|
||||||
|
const anchor = playerEntity.addComponent(new StreamingAnchorComponent());
|
||||||
|
|
||||||
|
// Update anchor position each frame
|
||||||
|
function update() {
|
||||||
|
anchor.x = player.position.x;
|
||||||
|
anchor.y = player.position.y;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Procedural Generation
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import type { IChunkDataProvider, IChunkCoord, IChunkData } from '@esengine/world-streaming';
|
||||||
|
|
||||||
|
class ProceduralChunkProvider implements IChunkDataProvider {
|
||||||
|
private seed: number;
|
||||||
|
|
||||||
|
constructor(seed: number) {
|
||||||
|
this.seed = seed;
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadChunkData(coord: IChunkCoord): Promise<IChunkData | null> {
|
||||||
|
// Use deterministic random based on seed + coord
|
||||||
|
const chunkSeed = this.hashCoord(coord);
|
||||||
|
const rng = this.createRNG(chunkSeed);
|
||||||
|
|
||||||
|
// Generate chunk content
|
||||||
|
const entities = this.generateEntities(coord, rng);
|
||||||
|
|
||||||
|
return {
|
||||||
|
coord,
|
||||||
|
entities,
|
||||||
|
version: 1
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveChunkData(data: IChunkData): Promise<void> {
|
||||||
|
// Optional: persist modified chunks
|
||||||
|
}
|
||||||
|
|
||||||
|
private hashCoord(coord: IChunkCoord): number {
|
||||||
|
return this.seed ^ (coord.x * 73856093) ^ (coord.y * 19349663);
|
||||||
|
}
|
||||||
|
|
||||||
|
private createRNG(seed: number) {
|
||||||
|
// Simple seeded random
|
||||||
|
return () => {
|
||||||
|
seed = (seed * 1103515245 + 12345) & 0x7fffffff;
|
||||||
|
return seed / 0x7fffffff;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private generateEntities(coord: IChunkCoord, rng: () => number) {
|
||||||
|
// Generate resources, trees, etc.
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use provider
|
||||||
|
chunkManager.setDataProvider(new ProceduralChunkProvider(12345));
|
||||||
|
```
|
||||||
|
|
||||||
|
## Core Concepts
|
||||||
|
|
||||||
|
### Chunk Lifecycle
|
||||||
|
|
||||||
|
```
|
||||||
|
Unloaded → Loading → Loaded → Unloading → Unloaded
|
||||||
|
↓ ↓
|
||||||
|
Failed (on error)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Streaming Anchor
|
||||||
|
|
||||||
|
`StreamingAnchorComponent` marks entities as chunk loading anchors. The system loads chunks around all anchors and unloads chunks outside the combined range.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// StreamingAnchorComponent implements IPositionable
|
||||||
|
interface IPositionable {
|
||||||
|
readonly position: { x: number; y: number };
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
|
||||||
|
| Property | Default | Description |
|
||||||
|
|----------|---------|-------------|
|
||||||
|
| `chunkSize` | 512 | Chunk size in world units |
|
||||||
|
| `loadRadius` | 2 | Chunks to load around anchor |
|
||||||
|
| `unloadRadius` | 4 | Chunks to unload beyond this |
|
||||||
|
| `maxLoadsPerFrame` | 2 | Max async loads per frame |
|
||||||
|
| `unloadDelay` | 3000 | MS before unloading |
|
||||||
|
| `bEnablePrefetch` | true | Prefetch in movement direction |
|
||||||
|
|
||||||
|
## Module Setup (Optional)
|
||||||
|
|
||||||
|
For quick setup, use the module helper:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { worldStreamingModule } from '@esengine/world-streaming';
|
||||||
|
|
||||||
|
const chunkManager = worldStreamingModule.setup(
|
||||||
|
scene,
|
||||||
|
services,
|
||||||
|
componentRegistry,
|
||||||
|
{ chunkSize: 256, bEnableCulling: true }
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
- [Chunk Manager API](./chunk-manager) - Loading queue, chunk lifecycle
|
||||||
|
- [Streaming System](./streaming-system) - Anchor-based loading
|
||||||
|
- [Serialization](./serialization) - Custom chunk serialization
|
||||||
|
- [Examples](./examples) - Procedural worlds, MMO chunks
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
---
|
||||||
|
title: "Chunk Serialization"
|
||||||
|
description: "Custom serialization for chunk data"
|
||||||
|
---
|
||||||
|
|
||||||
|
The `ChunkSerializer` handles converting between entity data and chunk storage format.
|
||||||
|
|
||||||
|
## Default Serializer
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { ChunkSerializer, ChunkManager } from '@esengine/world-streaming';
|
||||||
|
|
||||||
|
const serializer = new ChunkSerializer();
|
||||||
|
const chunkManager = new ChunkManager(512, serializer);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Custom Serializer
|
||||||
|
|
||||||
|
Override `ChunkSerializer` for custom serialization logic:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { ChunkSerializer } from '@esengine/world-streaming';
|
||||||
|
import type { Entity, IScene } from '@esengine/ecs-framework';
|
||||||
|
import type { IChunkCoord, IChunkData, IChunkBounds } from '@esengine/world-streaming';
|
||||||
|
|
||||||
|
class GameChunkSerializer extends ChunkSerializer {
|
||||||
|
/**
|
||||||
|
* Get position from entity
|
||||||
|
* Override to use your position component
|
||||||
|
*/
|
||||||
|
protected getPositionable(entity: Entity) {
|
||||||
|
const transform = entity.getComponent(TransformComponent);
|
||||||
|
if (transform) {
|
||||||
|
return { position: { x: transform.x, y: transform.y } };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set position on entity after deserialization
|
||||||
|
*/
|
||||||
|
protected setEntityPosition(entity: Entity, x: number, y: number): void {
|
||||||
|
const transform = entity.addComponent(new TransformComponent());
|
||||||
|
transform.x = x;
|
||||||
|
transform.y = y;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serialize components
|
||||||
|
*/
|
||||||
|
protected serializeComponents(entity: Entity): Record<string, unknown> {
|
||||||
|
const data: Record<string, unknown> = {};
|
||||||
|
|
||||||
|
const resource = entity.getComponent(ResourceComponent);
|
||||||
|
if (resource) {
|
||||||
|
data.ResourceComponent = {
|
||||||
|
type: resource.type,
|
||||||
|
amount: resource.amount,
|
||||||
|
maxAmount: resource.maxAmount
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const npc = entity.getComponent(NPCComponent);
|
||||||
|
if (npc) {
|
||||||
|
data.NPCComponent = {
|
||||||
|
id: npc.id,
|
||||||
|
state: npc.state
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deserialize components
|
||||||
|
*/
|
||||||
|
protected deserializeComponents(entity: Entity, components: Record<string, unknown>): void {
|
||||||
|
if (components.ResourceComponent) {
|
||||||
|
const data = components.ResourceComponent as any;
|
||||||
|
const resource = entity.addComponent(new ResourceComponent());
|
||||||
|
resource.type = data.type;
|
||||||
|
resource.amount = data.amount;
|
||||||
|
resource.maxAmount = data.maxAmount;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (components.NPCComponent) {
|
||||||
|
const data = components.NPCComponent as any;
|
||||||
|
const npc = entity.addComponent(new NPCComponent());
|
||||||
|
npc.id = data.id;
|
||||||
|
npc.state = data.state;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter which components to serialize
|
||||||
|
*/
|
||||||
|
protected shouldSerializeComponent(componentName: string): boolean {
|
||||||
|
const include = ['ResourceComponent', 'NPCComponent', 'BuildingComponent'];
|
||||||
|
return include.includes(componentName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Chunk Data Format
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface IChunkData {
|
||||||
|
coord: IChunkCoord; // Chunk coordinates
|
||||||
|
entities: ISerializedEntity[]; // Entity data
|
||||||
|
version: number; // Data version
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ISerializedEntity {
|
||||||
|
name: string; // Entity name
|
||||||
|
localPosition: { x: number; y: number }; // Position within chunk
|
||||||
|
components: Record<string, unknown>; // Component data
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IChunkCoord {
|
||||||
|
x: number; // Chunk X coordinate
|
||||||
|
y: number; // Chunk Y coordinate
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Data Provider with Serialization
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
class DatabaseChunkProvider implements IChunkDataProvider {
|
||||||
|
async loadChunkData(coord: IChunkCoord): Promise<IChunkData | null> {
|
||||||
|
const key = `chunk_${coord.x}_${coord.y}`;
|
||||||
|
const json = await database.get(key);
|
||||||
|
|
||||||
|
if (!json) return null;
|
||||||
|
return JSON.parse(json) as IChunkData;
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveChunkData(data: IChunkData): Promise<void> {
|
||||||
|
const key = `chunk_${data.coord.x}_${data.coord.y}`;
|
||||||
|
await database.set(key, JSON.stringify(data));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Procedural Generation with Serializer
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
class ProceduralProvider implements IChunkDataProvider {
|
||||||
|
private serializer: GameChunkSerializer;
|
||||||
|
|
||||||
|
async loadChunkData(coord: IChunkCoord): Promise<IChunkData | null> {
|
||||||
|
const entities = this.generateEntities(coord);
|
||||||
|
|
||||||
|
return {
|
||||||
|
coord,
|
||||||
|
entities,
|
||||||
|
version: 1
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private generateEntities(coord: IChunkCoord): ISerializedEntity[] {
|
||||||
|
const entities: ISerializedEntity[] = [];
|
||||||
|
const rng = this.createRNG(coord);
|
||||||
|
|
||||||
|
// Generate trees
|
||||||
|
const treeCount = Math.floor(rng() * 10);
|
||||||
|
for (let i = 0; i < treeCount; i++) {
|
||||||
|
entities.push({
|
||||||
|
name: `Tree_${coord.x}_${coord.y}_${i}`,
|
||||||
|
localPosition: {
|
||||||
|
x: rng() * 512,
|
||||||
|
y: rng() * 512
|
||||||
|
},
|
||||||
|
components: {
|
||||||
|
TreeComponent: { type: 'oak', health: 100 }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate resources
|
||||||
|
if (rng() > 0.7) {
|
||||||
|
entities.push({
|
||||||
|
name: `Resource_${coord.x}_${coord.y}`,
|
||||||
|
localPosition: { x: 256, y: 256 },
|
||||||
|
components: {
|
||||||
|
ResourceComponent: {
|
||||||
|
type: 'iron',
|
||||||
|
amount: 500,
|
||||||
|
maxAmount: 500
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return entities;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Version Migration
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
class VersionedSerializer extends ChunkSerializer {
|
||||||
|
private static readonly CURRENT_VERSION = 2;
|
||||||
|
|
||||||
|
deserialize(data: IChunkData, scene: IScene): Entity[] {
|
||||||
|
// Migrate old data
|
||||||
|
if (data.version < 2) {
|
||||||
|
data = this.migrateV1toV2(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
return super.deserialize(data, scene);
|
||||||
|
}
|
||||||
|
|
||||||
|
private migrateV1toV2(data: IChunkData): IChunkData {
|
||||||
|
// Convert old component format
|
||||||
|
for (const entity of data.entities) {
|
||||||
|
if (entity.components.OldResource) {
|
||||||
|
entity.components.ResourceComponent = entity.components.OldResource;
|
||||||
|
delete entity.components.OldResource;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data.version = 2;
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
---
|
||||||
|
title: "Streaming System"
|
||||||
|
description: "ChunkStreamingSystem manages automatic chunk loading based on anchor positions"
|
||||||
|
---
|
||||||
|
|
||||||
|
The `ChunkStreamingSystem` automatically manages chunk loading and unloading based on `StreamingAnchorComponent` positions.
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import {
|
||||||
|
ChunkManager,
|
||||||
|
ChunkStreamingSystem,
|
||||||
|
ChunkLoaderComponent,
|
||||||
|
StreamingAnchorComponent
|
||||||
|
} from '@esengine/world-streaming';
|
||||||
|
|
||||||
|
// Create and configure chunk manager
|
||||||
|
const chunkManager = new ChunkManager(512);
|
||||||
|
chunkManager.setScene(scene);
|
||||||
|
chunkManager.setDataProvider(myProvider);
|
||||||
|
|
||||||
|
// Create streaming system
|
||||||
|
const streamingSystem = new ChunkStreamingSystem();
|
||||||
|
streamingSystem.setChunkManager(chunkManager);
|
||||||
|
scene.addSystem(streamingSystem);
|
||||||
|
|
||||||
|
// Create loader entity with configuration
|
||||||
|
const loaderEntity = scene.createEntity('ChunkLoader');
|
||||||
|
const loader = loaderEntity.addComponent(new ChunkLoaderComponent());
|
||||||
|
loader.chunkSize = 512;
|
||||||
|
loader.loadRadius = 2;
|
||||||
|
loader.unloadRadius = 4;
|
||||||
|
```
|
||||||
|
|
||||||
|
## Streaming Anchor
|
||||||
|
|
||||||
|
The `StreamingAnchorComponent` marks entities as chunk loading anchors. Chunks are loaded around all anchors.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Create player as streaming anchor
|
||||||
|
const playerEntity = scene.createEntity('Player');
|
||||||
|
const anchor = playerEntity.addComponent(new StreamingAnchorComponent());
|
||||||
|
|
||||||
|
// Update position each frame
|
||||||
|
function update() {
|
||||||
|
anchor.x = player.worldX;
|
||||||
|
anchor.y = player.worldY;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Anchor Properties
|
||||||
|
|
||||||
|
| Property | Type | Default | Description |
|
||||||
|
|----------|------|---------|-------------|
|
||||||
|
| `x` | number | 0 | World X position |
|
||||||
|
| `y` | number | 0 | World Y position |
|
||||||
|
| `weight` | number | 1.0 | Load radius multiplier |
|
||||||
|
| `bEnablePrefetch` | boolean | true | Enable prefetch for this anchor |
|
||||||
|
|
||||||
|
### Multiple Anchors
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Main player - full load radius
|
||||||
|
const playerAnchor = player.addComponent(new StreamingAnchorComponent());
|
||||||
|
playerAnchor.weight = 1.0;
|
||||||
|
|
||||||
|
// Camera preview - smaller radius
|
||||||
|
const cameraAnchor = camera.addComponent(new StreamingAnchorComponent());
|
||||||
|
cameraAnchor.weight = 0.5; // Half the load radius
|
||||||
|
cameraAnchor.bEnablePrefetch = false;
|
||||||
|
```
|
||||||
|
|
||||||
|
## Loader Configuration
|
||||||
|
|
||||||
|
The `ChunkLoaderComponent` configures streaming behavior.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const loader = entity.addComponent(new ChunkLoaderComponent());
|
||||||
|
|
||||||
|
// Chunk dimensions
|
||||||
|
loader.chunkSize = 512; // World units per chunk
|
||||||
|
|
||||||
|
// Loading radius
|
||||||
|
loader.loadRadius = 2; // Load chunks within 2 chunks of anchor
|
||||||
|
loader.unloadRadius = 4; // Unload beyond 4 chunks
|
||||||
|
|
||||||
|
// Performance tuning
|
||||||
|
loader.maxLoadsPerFrame = 2; // Max async loads per frame
|
||||||
|
loader.maxUnloadsPerFrame = 1; // Max unloads per frame
|
||||||
|
loader.unloadDelay = 3000; // MS before unloading
|
||||||
|
|
||||||
|
// Prefetch
|
||||||
|
loader.bEnablePrefetch = true; // Enable movement-based prefetch
|
||||||
|
loader.prefetchRadius = 1; // Extra chunks to prefetch
|
||||||
|
```
|
||||||
|
|
||||||
|
### Coordinate Helpers
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Convert world position to chunk coordinates
|
||||||
|
const coord = loader.worldToChunk(1500, 2300);
|
||||||
|
|
||||||
|
// Get chunk bounds
|
||||||
|
const bounds = loader.getChunkBounds(coord);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Prefetch System
|
||||||
|
|
||||||
|
When enabled, the system prefetches chunks in the movement direction:
|
||||||
|
|
||||||
|
```
|
||||||
|
Movement Direction →
|
||||||
|
|
||||||
|
[ ][ ][ ] [ ][P][P] P = Prefetch
|
||||||
|
[L][L][L] → [L][L][L] L = Loaded
|
||||||
|
[ ][ ][ ] [ ][ ][ ]
|
||||||
|
```
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Enable prefetch
|
||||||
|
loader.bEnablePrefetch = true;
|
||||||
|
loader.prefetchRadius = 2; // Prefetch 2 chunks ahead
|
||||||
|
|
||||||
|
// Per-anchor prefetch control
|
||||||
|
anchor.bEnablePrefetch = true; // Enable for main player
|
||||||
|
cameraAnchor.bEnablePrefetch = false; // Disable for camera
|
||||||
|
```
|
||||||
|
|
||||||
|
## System Processing
|
||||||
|
|
||||||
|
The system runs each frame and:
|
||||||
|
|
||||||
|
1. Updates anchor velocities
|
||||||
|
2. Requests loads for chunks in range
|
||||||
|
3. Cancels unloads for chunks back in range
|
||||||
|
4. Requests unloads for chunks outside range
|
||||||
|
5. Processes load/unload queues
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Access the chunk manager from system
|
||||||
|
const system = scene.getSystem(ChunkStreamingSystem);
|
||||||
|
const manager = system?.chunkManager;
|
||||||
|
|
||||||
|
if (manager) {
|
||||||
|
console.log('Loaded:', manager.loadedChunkCount);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Priority-Based Loading
|
||||||
|
|
||||||
|
Chunks are loaded with priority based on distance:
|
||||||
|
|
||||||
|
| Distance | Priority | Description |
|
||||||
|
|----------|----------|-------------|
|
||||||
|
| 0 | Immediate | Player's current chunk |
|
||||||
|
| 1 | High | Adjacent chunks |
|
||||||
|
| 2-4 | Normal | Nearby chunks |
|
||||||
|
| 5+ | Low | Distant chunks |
|
||||||
|
| Prefetch | Prefetch | Movement direction |
|
||||||
|
|
||||||
|
## Events
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
chunkManager.setEvents({
|
||||||
|
onChunkLoaded: (coord, entities) => {
|
||||||
|
// Chunk ready - spawn NPCs, enable collision
|
||||||
|
for (const entity of entities) {
|
||||||
|
entity.getComponent(ColliderComponent)?.enable();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onChunkUnloaded: (coord) => {
|
||||||
|
// Cleanup - save state, release resources
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
@@ -28,12 +28,14 @@ ESEngine 提供了丰富的功能模块,可以按需引入到你的项目中
|
|||||||
|------|------|------|
|
|------|------|------|
|
||||||
| [可视化脚本](/modules/blueprint/) | `@esengine/blueprint` | 蓝图可视化脚本系统 |
|
| [可视化脚本](/modules/blueprint/) | `@esengine/blueprint` | 蓝图可视化脚本系统 |
|
||||||
| [程序化生成](/modules/procgen/) | `@esengine/procgen` | 噪声函数、随机工具 |
|
| [程序化生成](/modules/procgen/) | `@esengine/procgen` | 噪声函数、随机工具 |
|
||||||
|
| [世界流式加载](/modules/world-streaming/) | `@esengine/world-streaming` | 开放世界区块流式加载 |
|
||||||
|
|
||||||
### 网络模块
|
### 网络模块
|
||||||
|
|
||||||
| 模块 | 包名 | 描述 |
|
| 模块 | 包名 | 描述 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
| [网络同步](/modules/network/) | `@esengine/network` | 多人游戏网络同步 |
|
| [网络同步](/modules/network/) | `@esengine/network` | 多人游戏网络同步 |
|
||||||
|
| [事务系统](/modules/transaction/) | `@esengine/transaction` | 游戏事务处理,支持分布式事务 |
|
||||||
|
|
||||||
## 安装
|
## 安装
|
||||||
|
|
||||||
|
|||||||
283
docs/src/content/docs/modules/network/aoi.md
Normal file
283
docs/src/content/docs/modules/network/aoi.md
Normal file
@@ -0,0 +1,283 @@
|
|||||||
|
---
|
||||||
|
title: "兴趣区域管理 (AOI)"
|
||||||
|
description: "基于视野范围的网络实体过滤"
|
||||||
|
---
|
||||||
|
|
||||||
|
AOI(Area of Interest,兴趣区域)是大规模多人游戏中用于优化网络带宽的关键技术。通过只同步玩家视野范围内的实体,可以大幅减少网络流量。
|
||||||
|
|
||||||
|
## NetworkAOISystem
|
||||||
|
|
||||||
|
`NetworkAOISystem` 提供基于网格的兴趣区域管理。
|
||||||
|
|
||||||
|
### 启用 AOI
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { NetworkPlugin } from '@esengine/network';
|
||||||
|
|
||||||
|
const networkPlugin = new NetworkPlugin({
|
||||||
|
enableAOI: true,
|
||||||
|
aoiConfig: {
|
||||||
|
cellSize: 100, // 网格单元大小
|
||||||
|
defaultViewRange: 500, // 默认视野范围
|
||||||
|
enabled: true,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await Core.installPlugin(networkPlugin);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 添加观察者
|
||||||
|
|
||||||
|
每个需要接收同步数据的玩家都需要作为观察者添加:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 玩家加入时添加观察者
|
||||||
|
networkPlugin.registerPrefab('player', (scene, spawn) => {
|
||||||
|
const entity = scene.createEntity(`player_${spawn.netId}`);
|
||||||
|
|
||||||
|
// ... 设置组件
|
||||||
|
|
||||||
|
// 将玩家添加为 AOI 观察者
|
||||||
|
networkPlugin.addAOIObserver(
|
||||||
|
spawn.netId, // 网络 ID
|
||||||
|
spawn.pos.x, // 初始 X 位置
|
||||||
|
spawn.pos.y, // 初始 Y 位置
|
||||||
|
600 // 视野范围(可选)
|
||||||
|
);
|
||||||
|
|
||||||
|
return entity;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 玩家离开时移除观察者
|
||||||
|
networkPlugin.removeAOIObserver(playerNetId);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 更新观察者位置
|
||||||
|
|
||||||
|
当玩家移动时,需要更新其 AOI 位置:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 在游戏循环或同步回调中更新
|
||||||
|
networkPlugin.updateAOIObserverPosition(playerNetId, newX, newY);
|
||||||
|
```
|
||||||
|
|
||||||
|
## AOI 配置
|
||||||
|
|
||||||
|
| 属性 | 类型 | 默认值 | 描述 |
|
||||||
|
|------|------|--------|------|
|
||||||
|
| `cellSize` | `number` | 100 | 网格单元大小 |
|
||||||
|
| `defaultViewRange` | `number` | 500 | 默认视野范围 |
|
||||||
|
| `enabled` | `boolean` | true | 是否启用 AOI |
|
||||||
|
|
||||||
|
### 网格大小建议
|
||||||
|
|
||||||
|
网格大小应根据游戏视野范围设置:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 建议:cellSize = defaultViewRange / 3 到 / 5
|
||||||
|
aoiConfig: {
|
||||||
|
cellSize: 100,
|
||||||
|
defaultViewRange: 500, // 网格大约是视野的 1/5
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 查询接口
|
||||||
|
|
||||||
|
### 获取可见实体
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 获取玩家能看到的所有实体
|
||||||
|
const visibleEntities = networkPlugin.getVisibleEntities(playerNetId);
|
||||||
|
console.log('Visible entities:', visibleEntities);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 检查可见性
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 检查玩家是否能看到某个实体
|
||||||
|
if (networkPlugin.canSee(playerNetId, targetEntityNetId)) {
|
||||||
|
// 目标在视野内
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 事件监听
|
||||||
|
|
||||||
|
AOI 系统会在实体进入/离开视野时触发事件:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const aoiSystem = networkPlugin.aoiSystem;
|
||||||
|
|
||||||
|
if (aoiSystem) {
|
||||||
|
aoiSystem.addListener((event) => {
|
||||||
|
if (event.type === 'enter') {
|
||||||
|
console.log(`Entity ${event.targetNetId} entered view of ${event.observerNetId}`);
|
||||||
|
// 可以在这里发送实体的初始状态
|
||||||
|
} else if (event.type === 'exit') {
|
||||||
|
console.log(`Entity ${event.targetNetId} left view of ${event.observerNetId}`);
|
||||||
|
// 可以在这里清理资源
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 服务器端过滤
|
||||||
|
|
||||||
|
AOI 最常用于服务器端,过滤发送给每个客户端的同步数据:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 服务器端示例
|
||||||
|
import { NetworkAOISystem, createNetworkAOISystem } from '@esengine/network';
|
||||||
|
|
||||||
|
class GameServer {
|
||||||
|
private aoiSystem = createNetworkAOISystem({
|
||||||
|
cellSize: 100,
|
||||||
|
defaultViewRange: 500,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 玩家加入
|
||||||
|
onPlayerJoin(playerId: number, x: number, y: number) {
|
||||||
|
this.aoiSystem.addObserver(playerId, x, y);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 玩家移动
|
||||||
|
onPlayerMove(playerId: number, x: number, y: number) {
|
||||||
|
this.aoiSystem.updateObserverPosition(playerId, x, y);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 发送同步数据
|
||||||
|
broadcastSync(allEntities: EntitySyncState[]) {
|
||||||
|
for (const playerId of this.players) {
|
||||||
|
// 使用 AOI 过滤
|
||||||
|
const filteredEntities = this.aoiSystem.filterSyncData(
|
||||||
|
playerId,
|
||||||
|
allEntities
|
||||||
|
);
|
||||||
|
|
||||||
|
// 只发送可见实体
|
||||||
|
this.sendToPlayer(playerId, { entities: filteredEntities });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 工作原理
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ 游戏世界 │
|
||||||
|
│ ┌─────┬─────┬─────┬─────┬─────┐ │
|
||||||
|
│ │ │ │ E │ │ │ │
|
||||||
|
│ ├─────┼─────┼─────┼─────┼─────┤ E = 敌人实体 │
|
||||||
|
│ │ │ P │ ● │ │ │ P = 玩家 │
|
||||||
|
│ ├─────┼─────┼─────┼─────┼─────┤ ● = 玩家视野中心 │
|
||||||
|
│ │ │ │ E │ E │ │ ○ = 视野范围 │
|
||||||
|
│ ├─────┼─────┼─────┼─────┼─────┤ │
|
||||||
|
│ │ │ │ │ │ E │ 玩家只能看到视野内的 E │
|
||||||
|
│ └─────┴─────┴─────┴─────┴─────┘ │
|
||||||
|
│ │
|
||||||
|
│ 视野范围(圆形):包含 3 个敌人 │
|
||||||
|
│ 网格优化:只检查视野覆盖的网格单元 │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 网格优化
|
||||||
|
|
||||||
|
AOI 使用空间网格加速查询:
|
||||||
|
|
||||||
|
1. **添加实体**:根据位置计算所在网格
|
||||||
|
2. **视野检测**:只检查视野范围覆盖的网格
|
||||||
|
3. **移动更新**:跨网格时更新网格归属
|
||||||
|
4. **事件触发**:检测进入/离开视野
|
||||||
|
|
||||||
|
## 动态视野范围
|
||||||
|
|
||||||
|
可以为不同类型的玩家设置不同的视野:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 普通玩家
|
||||||
|
networkPlugin.addAOIObserver(playerId, x, y, 500);
|
||||||
|
|
||||||
|
// VIP 玩家(更大视野)
|
||||||
|
networkPlugin.addAOIObserver(vipPlayerId, x, y, 800);
|
||||||
|
|
||||||
|
// 运行时调整视野
|
||||||
|
const aoiSystem = networkPlugin.aoiSystem;
|
||||||
|
if (aoiSystem) {
|
||||||
|
aoiSystem.updateObserverViewRange(playerId, 600);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 最佳实践
|
||||||
|
|
||||||
|
### 1. 服务器端使用
|
||||||
|
|
||||||
|
AOI 过滤应在服务器端进行,客户端不应信任自己的 AOI 判断:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 服务器端过滤后再发送
|
||||||
|
const filtered = aoiSystem.filterSyncData(playerId, entities);
|
||||||
|
sendToClient(playerId, filtered);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 边界处理
|
||||||
|
|
||||||
|
在视野边缘添加缓冲区防止闪烁:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 进入视野时立即添加
|
||||||
|
// 离开视野时延迟移除(保持额外 1-2 秒)
|
||||||
|
aoiSystem.addListener((event) => {
|
||||||
|
if (event.type === 'exit') {
|
||||||
|
setTimeout(() => {
|
||||||
|
// 再次检查是否真的离开
|
||||||
|
if (!aoiSystem.canSee(event.observerNetId, event.targetNetId)) {
|
||||||
|
removeFromClient(event.observerNetId, event.targetNetId);
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 大型实体
|
||||||
|
|
||||||
|
对于大型实体(如 Boss),可能需要特殊处理:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Boss 总是对所有人可见
|
||||||
|
function filterWithBoss(playerId: number, entities: EntitySyncState[]) {
|
||||||
|
const filtered = aoiSystem.filterSyncData(playerId, entities);
|
||||||
|
|
||||||
|
// 添加 Boss 实体
|
||||||
|
const bossState = entities.find(e => e.netId === bossNetId);
|
||||||
|
if (bossState && !filtered.includes(bossState)) {
|
||||||
|
filtered.push(bossState);
|
||||||
|
}
|
||||||
|
|
||||||
|
return filtered;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. 性能考虑
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 大规模游戏建议配置
|
||||||
|
aoiConfig: {
|
||||||
|
cellSize: 200, // 较大的网格减少网格数量
|
||||||
|
defaultViewRange: 800, // 根据实际视野设置
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 调试
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const aoiSystem = networkPlugin.aoiSystem;
|
||||||
|
|
||||||
|
if (aoiSystem) {
|
||||||
|
console.log('AOI enabled:', aoiSystem.enabled);
|
||||||
|
console.log('Observer count:', aoiSystem.observerCount);
|
||||||
|
|
||||||
|
// 获取特定玩家的可见实体
|
||||||
|
const visible = aoiSystem.getVisibleEntities(playerId);
|
||||||
|
console.log('Visible entities:', visible.length);
|
||||||
|
}
|
||||||
|
```
|
||||||
506
docs/src/content/docs/modules/network/auth.md
Normal file
506
docs/src/content/docs/modules/network/auth.md
Normal file
@@ -0,0 +1,506 @@
|
|||||||
|
---
|
||||||
|
title: "认证系统"
|
||||||
|
description: "使用 JWT 和 Session 提供者为游戏服务器添加认证功能"
|
||||||
|
---
|
||||||
|
|
||||||
|
`@esengine/server` 包内置了可插拔的认证系统,支持 JWT、会话认证和自定义提供者。
|
||||||
|
|
||||||
|
## 安装
|
||||||
|
|
||||||
|
认证功能已包含在 server 包中:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install @esengine/server jsonwebtoken
|
||||||
|
```
|
||||||
|
|
||||||
|
> 注意:`jsonwebtoken` 是可选的 peer dependency,仅在使用 JWT 认证时需要。
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
|
### JWT 认证
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { createServer } from '@esengine/server'
|
||||||
|
import { withAuth, createJwtAuthProvider, withRoomAuth, requireAuth } from '@esengine/server/auth'
|
||||||
|
|
||||||
|
// 创建 JWT 提供者
|
||||||
|
const jwtProvider = createJwtAuthProvider({
|
||||||
|
secret: process.env.JWT_SECRET!,
|
||||||
|
expiresIn: 3600, // 1 小时
|
||||||
|
})
|
||||||
|
|
||||||
|
// 用认证包装服务器
|
||||||
|
const server = withAuth(await createServer({ port: 3000 }), {
|
||||||
|
provider: jwtProvider,
|
||||||
|
extractCredentials: (req) => {
|
||||||
|
const url = new URL(req.url ?? '', 'http://localhost')
|
||||||
|
return url.searchParams.get('token')
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// 定义需要认证的房间
|
||||||
|
class GameRoom extends withRoomAuth(Room, { requireAuth: true }) {
|
||||||
|
onJoin(player) {
|
||||||
|
console.log(`${player.user?.name} 加入了游戏!`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
server.define('game', GameRoom)
|
||||||
|
await server.start()
|
||||||
|
```
|
||||||
|
|
||||||
|
## 认证提供者
|
||||||
|
|
||||||
|
### JWT 提供者
|
||||||
|
|
||||||
|
使用 JSON Web Tokens 实现无状态认证:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { createJwtAuthProvider } from '@esengine/server/auth'
|
||||||
|
|
||||||
|
const jwtProvider = createJwtAuthProvider({
|
||||||
|
// 必填:密钥
|
||||||
|
secret: 'your-secret-key',
|
||||||
|
|
||||||
|
// 可选:算法(默认:HS256)
|
||||||
|
algorithm: 'HS256',
|
||||||
|
|
||||||
|
// 可选:过期时间(秒,默认:3600)
|
||||||
|
expiresIn: 3600,
|
||||||
|
|
||||||
|
// 可选:签发者(用于验证)
|
||||||
|
issuer: 'my-game-server',
|
||||||
|
|
||||||
|
// 可选:受众(用于验证)
|
||||||
|
audience: 'my-game-client',
|
||||||
|
|
||||||
|
// 可选:自定义用户提取
|
||||||
|
getUser: async (payload) => {
|
||||||
|
// 从数据库获取用户
|
||||||
|
return await db.users.findById(payload.sub)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// 签发令牌(用于登录接口)
|
||||||
|
const token = jwtProvider.sign({
|
||||||
|
sub: user.id,
|
||||||
|
name: user.name,
|
||||||
|
roles: ['player'],
|
||||||
|
})
|
||||||
|
|
||||||
|
// 解码但不验证(用于调试)
|
||||||
|
const payload = jwtProvider.decode(token)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Session 提供者
|
||||||
|
|
||||||
|
使用服务端会话实现有状态认证:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { createSessionAuthProvider, type ISessionStorage } from '@esengine/server/auth'
|
||||||
|
|
||||||
|
// 自定义存储实现
|
||||||
|
const storage: ISessionStorage = {
|
||||||
|
async get<T>(key: string): Promise<T | null> {
|
||||||
|
return await redis.get(key)
|
||||||
|
},
|
||||||
|
async set<T>(key: string, value: T): Promise<void> {
|
||||||
|
await redis.set(key, value)
|
||||||
|
},
|
||||||
|
async delete(key: string): Promise<boolean> {
|
||||||
|
return await redis.del(key) > 0
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionProvider = createSessionAuthProvider({
|
||||||
|
storage,
|
||||||
|
sessionTTL: 86400000, // 24 小时(毫秒)
|
||||||
|
|
||||||
|
// 可选:每次请求时验证用户
|
||||||
|
validateUser: (user) => !user.banned,
|
||||||
|
})
|
||||||
|
|
||||||
|
// 创建会话(用于登录接口)
|
||||||
|
const sessionId = await sessionProvider.createSession(user, {
|
||||||
|
ipAddress: req.ip,
|
||||||
|
userAgent: req.headers['user-agent'],
|
||||||
|
})
|
||||||
|
|
||||||
|
// 撤销会话(用于登出)
|
||||||
|
await sessionProvider.revoke(sessionId)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 服务器认证 Mixin
|
||||||
|
|
||||||
|
`withAuth` 函数用于包装服务器添加认证功能:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { withAuth } from '@esengine/server/auth'
|
||||||
|
|
||||||
|
const server = withAuth(baseServer, {
|
||||||
|
// 必填:认证提供者
|
||||||
|
provider: jwtProvider,
|
||||||
|
|
||||||
|
// 必填:从请求中提取凭证
|
||||||
|
extractCredentials: (req) => {
|
||||||
|
// 从查询字符串获取
|
||||||
|
return new URL(req.url, 'http://localhost').searchParams.get('token')
|
||||||
|
|
||||||
|
// 或从请求头获取
|
||||||
|
// return req.headers['authorization']?.replace('Bearer ', '')
|
||||||
|
},
|
||||||
|
|
||||||
|
// 可选:处理认证失败
|
||||||
|
onAuthFailed: (conn, error) => {
|
||||||
|
console.log(`认证失败: ${error}`)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### 访问认证上下文
|
||||||
|
|
||||||
|
认证后,可以从连接获取认证上下文:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { getAuthContext } from '@esengine/server/auth'
|
||||||
|
|
||||||
|
server.onConnect = (conn) => {
|
||||||
|
const auth = getAuthContext(conn)
|
||||||
|
|
||||||
|
if (auth.isAuthenticated) {
|
||||||
|
console.log(`用户 ${auth.userId} 已连接`)
|
||||||
|
console.log(`角色: ${auth.roles}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 房间认证 Mixin
|
||||||
|
|
||||||
|
`withRoomAuth` 函数为房间添加认证检查:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { withRoomAuth, type AuthPlayer } from '@esengine/server/auth'
|
||||||
|
|
||||||
|
interface User {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
roles: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
class GameRoom extends withRoomAuth<User>(Room, {
|
||||||
|
// 要求认证才能加入
|
||||||
|
requireAuth: true,
|
||||||
|
|
||||||
|
// 可选:要求特定角色
|
||||||
|
allowedRoles: ['player', 'premium'],
|
||||||
|
|
||||||
|
// 可选:角色检查模式('any' 或 'all')
|
||||||
|
roleCheckMode: 'any',
|
||||||
|
}) {
|
||||||
|
// player 拥有 .auth 和 .user 属性
|
||||||
|
onJoin(player: AuthPlayer<User>) {
|
||||||
|
console.log(`${player.user?.name} 加入了`)
|
||||||
|
console.log(`是否高级会员: ${player.auth.hasRole('premium')}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 可选:自定义认证验证
|
||||||
|
async onAuth(player: AuthPlayer<User>): Promise<boolean> {
|
||||||
|
// 额外的验证逻辑
|
||||||
|
if (player.auth.hasRole('banned')) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
@onMessage('Chat')
|
||||||
|
handleChat(data: { text: string }, player: AuthPlayer<User>) {
|
||||||
|
this.broadcast('Chat', {
|
||||||
|
from: player.user?.name ?? '访客',
|
||||||
|
text: data.text,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### AuthPlayer 接口
|
||||||
|
|
||||||
|
认证房间中的玩家拥有额外属性:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface AuthPlayer<TUser> extends Player {
|
||||||
|
// 完整认证上下文
|
||||||
|
readonly auth: IAuthContext<TUser>
|
||||||
|
|
||||||
|
// 用户信息(auth.user 的快捷方式)
|
||||||
|
readonly user: TUser | null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 房间认证辅助方法
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
class GameRoom extends withRoomAuth<User>(Room) {
|
||||||
|
someMethod() {
|
||||||
|
// 通过用户 ID 获取玩家
|
||||||
|
const player = this.getPlayerByUserId('user-123')
|
||||||
|
|
||||||
|
// 获取拥有特定角色的所有玩家
|
||||||
|
const admins = this.getPlayersByRole('admin')
|
||||||
|
|
||||||
|
// 获取带认证信息的玩家
|
||||||
|
const authPlayer = this.getAuthPlayer(playerId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 认证装饰器
|
||||||
|
|
||||||
|
### @requireAuth
|
||||||
|
|
||||||
|
标记消息处理器需要认证:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { requireAuth, requireRole, onMessage } from '@esengine/server/auth'
|
||||||
|
|
||||||
|
class GameRoom extends withRoomAuth(Room) {
|
||||||
|
@requireAuth()
|
||||||
|
@onMessage('Trade')
|
||||||
|
handleTrade(data: TradeData, player: AuthPlayer) {
|
||||||
|
// 只有已认证玩家才能交易
|
||||||
|
}
|
||||||
|
|
||||||
|
@requireAuth({ allowGuest: true })
|
||||||
|
@onMessage('Chat')
|
||||||
|
handleChat(data: ChatData, player: AuthPlayer) {
|
||||||
|
// 访客也可以聊天
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### @requireRole
|
||||||
|
|
||||||
|
要求特定角色才能访问消息处理器:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
class AdminRoom extends withRoomAuth(Room) {
|
||||||
|
@requireRole('admin')
|
||||||
|
@onMessage('Ban')
|
||||||
|
handleBan(data: BanData, player: AuthPlayer) {
|
||||||
|
// 只有管理员才能封禁
|
||||||
|
}
|
||||||
|
|
||||||
|
@requireRole(['moderator', 'admin'])
|
||||||
|
@onMessage('Mute')
|
||||||
|
handleMute(data: MuteData, player: AuthPlayer) {
|
||||||
|
// 版主或管理员可以禁言
|
||||||
|
}
|
||||||
|
|
||||||
|
@requireRole(['verified', 'premium'], { mode: 'all' })
|
||||||
|
@onMessage('SpecialFeature')
|
||||||
|
handleSpecial(data: any, player: AuthPlayer) {
|
||||||
|
// 需要同时拥有 verified 和 premium 角色
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 认证上下文 API
|
||||||
|
|
||||||
|
认证上下文提供多种检查认证状态的方法:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface IAuthContext<TUser> {
|
||||||
|
// 认证状态
|
||||||
|
readonly isAuthenticated: boolean
|
||||||
|
readonly user: TUser | null
|
||||||
|
readonly userId: string | null
|
||||||
|
readonly roles: ReadonlyArray<string>
|
||||||
|
readonly authenticatedAt: number | null
|
||||||
|
readonly expiresAt: number | null
|
||||||
|
|
||||||
|
// 角色检查
|
||||||
|
hasRole(role: string): boolean
|
||||||
|
hasAnyRole(roles: string[]): boolean
|
||||||
|
hasAllRoles(roles: string[]): boolean
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`AuthContext` 类(实现类)还提供:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
class AuthContext<TUser> implements IAuthContext<TUser> {
|
||||||
|
// 从认证结果设置认证状态
|
||||||
|
setAuthenticated(result: AuthResult<TUser>): void
|
||||||
|
|
||||||
|
// 清除认证状态
|
||||||
|
clear(): void
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 测试
|
||||||
|
|
||||||
|
使用模拟认证提供者进行单元测试:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { createMockAuthProvider } from '@esengine/server/auth/testing'
|
||||||
|
|
||||||
|
// 创建带预设用户的模拟提供者
|
||||||
|
const mockProvider = createMockAuthProvider({
|
||||||
|
users: [
|
||||||
|
{ id: '1', name: 'Alice', roles: ['player'] },
|
||||||
|
{ id: '2', name: 'Bob', roles: ['admin', 'player'] },
|
||||||
|
],
|
||||||
|
autoCreate: true, // 为未知令牌创建用户
|
||||||
|
})
|
||||||
|
|
||||||
|
// 在测试中使用
|
||||||
|
const server = withAuth(testServer, {
|
||||||
|
provider: mockProvider,
|
||||||
|
extractCredentials: (req) => req.headers['x-token'],
|
||||||
|
})
|
||||||
|
|
||||||
|
// 使用用户 ID 作为令牌进行验证
|
||||||
|
const result = await mockProvider.verify('1')
|
||||||
|
// result.user = { id: '1', name: 'Alice', roles: ['player'] }
|
||||||
|
|
||||||
|
// 动态添加/移除用户
|
||||||
|
mockProvider.addUser({ id: '3', name: 'Charlie', roles: ['guest'] })
|
||||||
|
mockProvider.removeUser('3')
|
||||||
|
|
||||||
|
// 撤销令牌
|
||||||
|
await mockProvider.revoke('1')
|
||||||
|
|
||||||
|
// 重置到初始状态
|
||||||
|
mockProvider.clear()
|
||||||
|
```
|
||||||
|
|
||||||
|
## 错误处理
|
||||||
|
|
||||||
|
认证错误包含错误码用于程序化处理:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
type AuthErrorCode =
|
||||||
|
| 'INVALID_CREDENTIALS' // 用户名/密码无效
|
||||||
|
| 'INVALID_TOKEN' // 令牌格式错误或无效
|
||||||
|
| 'EXPIRED_TOKEN' // 令牌已过期
|
||||||
|
| 'USER_NOT_FOUND' // 用户查找失败
|
||||||
|
| 'ACCOUNT_DISABLED' // 用户账号已禁用
|
||||||
|
| 'RATE_LIMITED' // 请求过于频繁
|
||||||
|
| 'INSUFFICIENT_PERMISSIONS' // 权限不足
|
||||||
|
|
||||||
|
// 在认证失败处理器中
|
||||||
|
const server = withAuth(baseServer, {
|
||||||
|
provider: jwtProvider,
|
||||||
|
extractCredentials,
|
||||||
|
onAuthFailed: (conn, error) => {
|
||||||
|
switch (error.errorCode) {
|
||||||
|
case 'EXPIRED_TOKEN':
|
||||||
|
conn.send('AuthError', { code: 'TOKEN_EXPIRED' })
|
||||||
|
break
|
||||||
|
case 'INVALID_TOKEN':
|
||||||
|
conn.send('AuthError', { code: 'INVALID_TOKEN' })
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
conn.close()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## 完整示例
|
||||||
|
|
||||||
|
以下是使用 JWT 认证的完整示例:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// server.ts
|
||||||
|
import { createServer } from '@esengine/server'
|
||||||
|
import {
|
||||||
|
withAuth,
|
||||||
|
withRoomAuth,
|
||||||
|
createJwtAuthProvider,
|
||||||
|
requireAuth,
|
||||||
|
requireRole,
|
||||||
|
type AuthPlayer,
|
||||||
|
} from '@esengine/server/auth'
|
||||||
|
|
||||||
|
// 类型定义
|
||||||
|
interface User {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
roles: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
// JWT 提供者
|
||||||
|
const jwtProvider = createJwtAuthProvider<User>({
|
||||||
|
secret: process.env.JWT_SECRET!,
|
||||||
|
expiresIn: 3600,
|
||||||
|
getUser: async (payload) => ({
|
||||||
|
id: payload.sub as string,
|
||||||
|
name: payload.name as string,
|
||||||
|
roles: (payload.roles as string[]) ?? [],
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
// 创建带认证的服务器
|
||||||
|
const server = withAuth(
|
||||||
|
await createServer({ port: 3000 }),
|
||||||
|
{
|
||||||
|
provider: jwtProvider,
|
||||||
|
extractCredentials: (req) => {
|
||||||
|
return new URL(req.url ?? '', 'http://localhost')
|
||||||
|
.searchParams.get('token')
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// 带认证的游戏房间
|
||||||
|
class GameRoom extends withRoomAuth<User>(Room, {
|
||||||
|
requireAuth: true,
|
||||||
|
allowedRoles: ['player'],
|
||||||
|
}) {
|
||||||
|
onCreate() {
|
||||||
|
console.log('游戏房间已创建')
|
||||||
|
}
|
||||||
|
|
||||||
|
onJoin(player: AuthPlayer<User>) {
|
||||||
|
console.log(`${player.user?.name} 加入了!`)
|
||||||
|
this.broadcast('PlayerJoined', {
|
||||||
|
id: player.id,
|
||||||
|
name: player.user?.name,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
@requireAuth()
|
||||||
|
@onMessage('Move')
|
||||||
|
handleMove(data: { x: number; y: number }, player: AuthPlayer<User>) {
|
||||||
|
// 处理移动
|
||||||
|
}
|
||||||
|
|
||||||
|
@requireRole('admin')
|
||||||
|
@onMessage('Kick')
|
||||||
|
handleKick(data: { playerId: string }, player: AuthPlayer<User>) {
|
||||||
|
const target = this.getPlayer(data.playerId)
|
||||||
|
if (target) {
|
||||||
|
this.kick(target, '被管理员踢出')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
server.define('game', GameRoom)
|
||||||
|
await server.start()
|
||||||
|
```
|
||||||
|
|
||||||
|
## 最佳实践
|
||||||
|
|
||||||
|
1. **保护密钥安全**:永远不要硬编码 JWT 密钥,使用环境变量。
|
||||||
|
|
||||||
|
2. **设置合理的过期时间**:在安全性和用户体验之间平衡令牌 TTL。
|
||||||
|
|
||||||
|
3. **在关键操作上验证**:在敏感消息处理器上使用 `@requireAuth`。
|
||||||
|
|
||||||
|
4. **使用基于角色的访问控制**:为管理功能实现适当的角色层级。
|
||||||
|
|
||||||
|
5. **处理令牌刷新**:为长会话实现令牌刷新逻辑。
|
||||||
|
|
||||||
|
6. **记录认证事件**:跟踪登录尝试和失败以进行安全监控。
|
||||||
|
|
||||||
|
7. **测试认证流程**:使用 `MockAuthProvider` 测试认证场景。
|
||||||
316
docs/src/content/docs/modules/network/delta.md
Normal file
316
docs/src/content/docs/modules/network/delta.md
Normal file
@@ -0,0 +1,316 @@
|
|||||||
|
---
|
||||||
|
title: "状态增量压缩"
|
||||||
|
description: "减少网络带宽的增量同步"
|
||||||
|
---
|
||||||
|
|
||||||
|
状态增量压缩通过只发送变化的字段来减少网络带宽。对于频繁同步的游戏状态,这可以显著降低数据传输量。
|
||||||
|
|
||||||
|
## StateDeltaCompressor
|
||||||
|
|
||||||
|
`StateDeltaCompressor` 类用于压缩和解压状态增量。
|
||||||
|
|
||||||
|
### 基本用法
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { createStateDeltaCompressor, type SyncData } from '@esengine/network';
|
||||||
|
|
||||||
|
// 创建压缩器
|
||||||
|
const compressor = createStateDeltaCompressor({
|
||||||
|
positionThreshold: 0.01, // 位置变化阈值
|
||||||
|
rotationThreshold: 0.001, // 旋转变化阈值(弧度)
|
||||||
|
velocityThreshold: 0.1, // 速度变化阈值
|
||||||
|
fullSnapshotInterval: 60, // 完整快照间隔(帧数)
|
||||||
|
});
|
||||||
|
|
||||||
|
// 压缩同步数据
|
||||||
|
const syncData: SyncData = {
|
||||||
|
frame: 100,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
entities: [
|
||||||
|
{ netId: 1, pos: { x: 100, y: 200 }, rot: 0 },
|
||||||
|
{ netId: 2, pos: { x: 300, y: 400 }, rot: 1.5 },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const deltaData = compressor.compress(syncData);
|
||||||
|
// deltaData 只包含变化的字段
|
||||||
|
|
||||||
|
// 解压增量数据
|
||||||
|
const fullData = compressor.decompress(deltaData);
|
||||||
|
```
|
||||||
|
|
||||||
|
## 配置选项
|
||||||
|
|
||||||
|
| 属性 | 类型 | 默认值 | 描述 |
|
||||||
|
|------|------|--------|------|
|
||||||
|
| `positionThreshold` | `number` | 0.01 | 位置变化阈值 |
|
||||||
|
| `rotationThreshold` | `number` | 0.001 | 旋转变化阈值(弧度) |
|
||||||
|
| `velocityThreshold` | `number` | 0.1 | 速度变化阈值 |
|
||||||
|
| `fullSnapshotInterval` | `number` | 60 | 完整快照间隔(帧数) |
|
||||||
|
|
||||||
|
## 增量标志
|
||||||
|
|
||||||
|
使用位标志表示哪些字段发生了变化:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { DeltaFlags } from '@esengine/network';
|
||||||
|
|
||||||
|
// 位标志定义
|
||||||
|
DeltaFlags.NONE // 0 - 无变化
|
||||||
|
DeltaFlags.POSITION // 1 - 位置变化
|
||||||
|
DeltaFlags.ROTATION // 2 - 旋转变化
|
||||||
|
DeltaFlags.VELOCITY // 4 - 速度变化
|
||||||
|
DeltaFlags.ANGULAR_VELOCITY // 8 - 角速度变化
|
||||||
|
DeltaFlags.CUSTOM // 16 - 自定义数据变化
|
||||||
|
```
|
||||||
|
|
||||||
|
## 数据格式
|
||||||
|
|
||||||
|
### 完整状态
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface EntitySyncState {
|
||||||
|
netId: number;
|
||||||
|
pos?: { x: number; y: number };
|
||||||
|
rot?: number;
|
||||||
|
vel?: { x: number; y: number };
|
||||||
|
angVel?: number;
|
||||||
|
custom?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 增量状态
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface EntityDeltaState {
|
||||||
|
netId: number;
|
||||||
|
flags: number; // 变化标志位
|
||||||
|
pos?: { x: number; y: number }; // 仅在 POSITION 标志时存在
|
||||||
|
rot?: number; // 仅在 ROTATION 标志时存在
|
||||||
|
vel?: { x: number; y: number }; // 仅在 VELOCITY 标志时存在
|
||||||
|
angVel?: number; // 仅在 ANGULAR_VELOCITY 标志时存在
|
||||||
|
custom?: Record<string, unknown>; // 仅在 CUSTOM 标志时存在
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 工作原理
|
||||||
|
|
||||||
|
```
|
||||||
|
帧 1 (完整快照):
|
||||||
|
Entity 1: pos=(100, 200), rot=0
|
||||||
|
|
||||||
|
帧 2 (增量):
|
||||||
|
Entity 1: flags=POSITION, pos=(101, 200) // 只有 X 变化
|
||||||
|
|
||||||
|
帧 3 (增量):
|
||||||
|
Entity 1: flags=0 // 无变化,不发送
|
||||||
|
|
||||||
|
帧 4 (增量):
|
||||||
|
Entity 1: flags=POSITION|ROTATION, pos=(105, 200), rot=0.5
|
||||||
|
|
||||||
|
帧 60 (强制完整快照):
|
||||||
|
Entity 1: pos=(200, 300), rot=1.0, vel=(5, 0)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 服务器端使用
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { createStateDeltaCompressor } from '@esengine/network';
|
||||||
|
|
||||||
|
class GameServer {
|
||||||
|
private compressor = createStateDeltaCompressor();
|
||||||
|
|
||||||
|
// 广播状态更新
|
||||||
|
broadcastState(entities: EntitySyncState[]) {
|
||||||
|
const syncData: SyncData = {
|
||||||
|
frame: this.currentFrame,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
entities,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 压缩数据
|
||||||
|
const deltaData = this.compressor.compress(syncData);
|
||||||
|
|
||||||
|
// 发送增量数据
|
||||||
|
this.broadcast('sync', deltaData);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 玩家离开时清理
|
||||||
|
onPlayerLeave(netId: number) {
|
||||||
|
this.compressor.removeEntity(netId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 客户端使用
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
class GameClient {
|
||||||
|
private compressor = createStateDeltaCompressor();
|
||||||
|
|
||||||
|
// 接收增量数据
|
||||||
|
onSyncReceived(deltaData: DeltaSyncData) {
|
||||||
|
// 解压为完整状态
|
||||||
|
const fullData = this.compressor.decompress(deltaData);
|
||||||
|
|
||||||
|
// 应用状态
|
||||||
|
for (const entity of fullData.entities) {
|
||||||
|
this.applyEntityState(entity);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 带宽节省示例
|
||||||
|
|
||||||
|
假设每个实体有以下数据:
|
||||||
|
|
||||||
|
| 字段 | 大小(字节) |
|
||||||
|
|------|------------|
|
||||||
|
| netId | 4 |
|
||||||
|
| pos.x | 8 |
|
||||||
|
| pos.y | 8 |
|
||||||
|
| rot | 8 |
|
||||||
|
| vel.x | 8 |
|
||||||
|
| vel.y | 8 |
|
||||||
|
| angVel | 8 |
|
||||||
|
| **总计** | **52** |
|
||||||
|
|
||||||
|
使用增量压缩:
|
||||||
|
|
||||||
|
| 场景 | 原始 | 压缩后 | 节省 |
|
||||||
|
|------|------|--------|------|
|
||||||
|
| 只有位置变化 | 52 | 4+1+16 = 21 | 60% |
|
||||||
|
| 只有旋转变化 | 52 | 4+1+8 = 13 | 75% |
|
||||||
|
| 静止不动 | 52 | 0 | 100% |
|
||||||
|
| 位置+旋转变化 | 52 | 4+1+24 = 29 | 44% |
|
||||||
|
|
||||||
|
## 强制完整快照
|
||||||
|
|
||||||
|
某些情况下需要发送完整快照:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 新玩家加入时
|
||||||
|
compressor.forceFullSnapshot();
|
||||||
|
const data = compressor.compress(syncData);
|
||||||
|
// 这次会发送完整状态
|
||||||
|
|
||||||
|
// 重连时
|
||||||
|
compressor.clear(); // 清除历史状态
|
||||||
|
compressor.forceFullSnapshot();
|
||||||
|
```
|
||||||
|
|
||||||
|
## 自定义数据
|
||||||
|
|
||||||
|
支持同步自定义游戏数据:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const syncData: SyncData = {
|
||||||
|
frame: 100,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
entities: [
|
||||||
|
{
|
||||||
|
netId: 1,
|
||||||
|
pos: { x: 100, y: 200 },
|
||||||
|
custom: {
|
||||||
|
health: 80,
|
||||||
|
mana: 50,
|
||||||
|
buffs: ['speed', 'shield'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
// 自定义数据也会进行增量压缩
|
||||||
|
const deltaData = compressor.compress(syncData);
|
||||||
|
```
|
||||||
|
|
||||||
|
## 最佳实践
|
||||||
|
|
||||||
|
### 1. 合理设置阈值
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 高精度游戏(如竞技游戏)
|
||||||
|
const compressor = createStateDeltaCompressor({
|
||||||
|
positionThreshold: 0.001,
|
||||||
|
rotationThreshold: 0.0001,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 普通游戏
|
||||||
|
const compressor = createStateDeltaCompressor({
|
||||||
|
positionThreshold: 0.1,
|
||||||
|
rotationThreshold: 0.01,
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 调整完整快照间隔
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 高可靠性(网络不稳定)
|
||||||
|
fullSnapshotInterval: 30, // 每 30 帧发送完整快照
|
||||||
|
|
||||||
|
// 低带宽优先
|
||||||
|
fullSnapshotInterval: 120, // 每 120 帧发送完整快照
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 配合 AOI 使用
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 先用 AOI 过滤,再用增量压缩
|
||||||
|
const filteredEntities = aoiSystem.filterSyncData(playerId, allEntities);
|
||||||
|
const syncData = { frame, timestamp, entities: filteredEntities };
|
||||||
|
const deltaData = compressor.compress(syncData);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. 处理实体移除
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 实体销毁时清理压缩器状态
|
||||||
|
function onEntityDespawn(netId: number) {
|
||||||
|
compressor.removeEntity(netId);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 与其他功能配合
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────┐
|
||||||
|
│ 游戏状态 │
|
||||||
|
└────────┬────────┘
|
||||||
|
│
|
||||||
|
┌────────▼────────┐
|
||||||
|
│ AOI 过滤 │ ← 只处理视野内实体
|
||||||
|
└────────┬────────┘
|
||||||
|
│
|
||||||
|
┌────────▼────────┐
|
||||||
|
│ 增量压缩 │ ← 只发送变化的字段
|
||||||
|
└────────┬────────┘
|
||||||
|
│
|
||||||
|
┌────────▼────────┐
|
||||||
|
│ 网络传输 │
|
||||||
|
└─────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## 调试
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const compressor = createStateDeltaCompressor();
|
||||||
|
|
||||||
|
// 检查压缩效果
|
||||||
|
const original = syncData;
|
||||||
|
const compressed = compressor.compress(original);
|
||||||
|
|
||||||
|
console.log('Original entities:', original.entities.length);
|
||||||
|
console.log('Compressed entities:', compressed.entities.length);
|
||||||
|
console.log('Is full snapshot:', compressed.isFullSnapshot);
|
||||||
|
|
||||||
|
// 查看每个实体的变化
|
||||||
|
for (const delta of compressed.entities) {
|
||||||
|
console.log(`Entity ${delta.netId}:`, {
|
||||||
|
hasPosition: !!(delta.flags & DeltaFlags.POSITION),
|
||||||
|
hasRotation: !!(delta.flags & DeltaFlags.ROTATION),
|
||||||
|
hasVelocity: !!(delta.flags & DeltaFlags.VELOCITY),
|
||||||
|
hasCustom: !!(delta.flags & DeltaFlags.CUSTOM),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -147,7 +147,10 @@ service.on('chat', (data) => {
|
|||||||
|
|
||||||
- [客户端使用](/modules/network/client/) - NetworkPlugin、组件和系统
|
- [客户端使用](/modules/network/client/) - NetworkPlugin、组件和系统
|
||||||
- [服务器端](/modules/network/server/) - GameServer 和 Room 管理
|
- [服务器端](/modules/network/server/) - GameServer 和 Room 管理
|
||||||
- [状态同步](/modules/network/sync/) - 插值、预测和快照
|
- [状态同步](/modules/network/sync/) - 插值和快照缓冲
|
||||||
|
- [客户端预测](/modules/network/prediction/) - 输入预测和服务器校正
|
||||||
|
- [兴趣区域 (AOI)](/modules/network/aoi/) - 视野过滤和带宽优化
|
||||||
|
- [增量压缩](/modules/network/delta/) - 状态增量同步
|
||||||
- [API 参考](/modules/network/api/) - 完整 API 文档
|
- [API 参考](/modules/network/api/) - 完整 API 文档
|
||||||
|
|
||||||
## 服务令牌
|
## 服务令牌
|
||||||
@@ -159,10 +162,14 @@ import {
|
|||||||
NetworkServiceToken,
|
NetworkServiceToken,
|
||||||
NetworkSyncSystemToken,
|
NetworkSyncSystemToken,
|
||||||
NetworkSpawnSystemToken,
|
NetworkSpawnSystemToken,
|
||||||
NetworkInputSystemToken
|
NetworkInputSystemToken,
|
||||||
|
NetworkPredictionSystemToken,
|
||||||
|
NetworkAOISystemToken,
|
||||||
} from '@esengine/network';
|
} from '@esengine/network';
|
||||||
|
|
||||||
const networkService = services.get(NetworkServiceToken);
|
const networkService = services.get(NetworkServiceToken);
|
||||||
|
const predictionSystem = services.get(NetworkPredictionSystemToken);
|
||||||
|
const aoiSystem = services.get(NetworkAOISystemToken);
|
||||||
```
|
```
|
||||||
|
|
||||||
## 蓝图节点
|
## 蓝图节点
|
||||||
|
|||||||
254
docs/src/content/docs/modules/network/prediction.md
Normal file
254
docs/src/content/docs/modules/network/prediction.md
Normal file
@@ -0,0 +1,254 @@
|
|||||||
|
---
|
||||||
|
title: "客户端预测"
|
||||||
|
description: "本地输入预测和服务器校正"
|
||||||
|
---
|
||||||
|
|
||||||
|
客户端预测是网络游戏中用于减少输入延迟的关键技术。通过在本地立即应用玩家输入,同时等待服务器确认,可以让游戏感觉更加流畅响应。
|
||||||
|
|
||||||
|
## NetworkPredictionSystem
|
||||||
|
|
||||||
|
`NetworkPredictionSystem` 是专门处理本地玩家预测的 ECS 系统。
|
||||||
|
|
||||||
|
### 基本用法
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { NetworkPlugin } from '@esengine/network';
|
||||||
|
|
||||||
|
const networkPlugin = new NetworkPlugin({
|
||||||
|
enablePrediction: true,
|
||||||
|
predictionConfig: {
|
||||||
|
moveSpeed: 200, // 移动速度(单位/秒)
|
||||||
|
maxUnacknowledgedInputs: 60, // 最大未确认输入数
|
||||||
|
reconciliationThreshold: 0.5, // 校正阈值
|
||||||
|
reconciliationSpeed: 10, // 校正速度
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await Core.installPlugin(networkPlugin);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 设置本地玩家
|
||||||
|
|
||||||
|
当本地玩家实体生成后,需要设置其网络 ID:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
networkPlugin.registerPrefab('player', (scene, spawn) => {
|
||||||
|
const entity = scene.createEntity(`player_${spawn.netId}`);
|
||||||
|
|
||||||
|
const identity = entity.addComponent(new NetworkIdentity());
|
||||||
|
identity.netId = spawn.netId;
|
||||||
|
identity.ownerId = spawn.ownerId;
|
||||||
|
identity.bHasAuthority = spawn.ownerId === networkPlugin.localPlayerId;
|
||||||
|
identity.bIsLocalPlayer = identity.bHasAuthority;
|
||||||
|
|
||||||
|
entity.addComponent(new NetworkTransform());
|
||||||
|
|
||||||
|
// 设置本地玩家用于预测
|
||||||
|
if (identity.bIsLocalPlayer) {
|
||||||
|
networkPlugin.setLocalPlayerNetId(spawn.netId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return entity;
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 发送输入
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 在游戏循环中发送移动输入
|
||||||
|
function onUpdate() {
|
||||||
|
const moveX = Input.getAxis('horizontal');
|
||||||
|
const moveY = Input.getAxis('vertical');
|
||||||
|
|
||||||
|
if (moveX !== 0 || moveY !== 0) {
|
||||||
|
networkPlugin.sendMoveInput(moveX, moveY);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 发送动作输入
|
||||||
|
if (Input.isPressed('attack')) {
|
||||||
|
networkPlugin.sendActionInput('attack');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 预测配置
|
||||||
|
|
||||||
|
| 属性 | 类型 | 默认值 | 描述 |
|
||||||
|
|------|------|--------|------|
|
||||||
|
| `moveSpeed` | `number` | 200 | 移动速度(单位/秒) |
|
||||||
|
| `enabled` | `boolean` | true | 是否启用预测 |
|
||||||
|
| `maxUnacknowledgedInputs` | `number` | 60 | 最大未确认输入数 |
|
||||||
|
| `reconciliationThreshold` | `number` | 0.5 | 触发校正的位置差异阈值 |
|
||||||
|
| `reconciliationSpeed` | `number` | 10 | 校正平滑速度 |
|
||||||
|
|
||||||
|
## 工作原理
|
||||||
|
|
||||||
|
```
|
||||||
|
客户端 服务器
|
||||||
|
│ │
|
||||||
|
├─ 1. 捕获输入 (seq=1) │
|
||||||
|
├─ 2. 本地预测移动 │
|
||||||
|
├─ 3. 发送输入到服务器 ──────────────►
|
||||||
|
│ │
|
||||||
|
├─ 4. 继续捕获输入 (seq=2,3...) │
|
||||||
|
├─ 5. 继续本地预测 │
|
||||||
|
│ │
|
||||||
|
│ ├─ 6. 处理输入 (seq=1)
|
||||||
|
│ │
|
||||||
|
◄──────── 7. 返回状态 (ackSeq=1) ────
|
||||||
|
│ │
|
||||||
|
├─ 8. 比较预测和服务器状态 │
|
||||||
|
├─ 9. 重放 seq=2,3... 的输入 │
|
||||||
|
├─ 10. 平滑校正到正确位置 │
|
||||||
|
│ │
|
||||||
|
```
|
||||||
|
|
||||||
|
### 步骤详解
|
||||||
|
|
||||||
|
1. **输入捕获**:捕获玩家输入并分配序列号
|
||||||
|
2. **本地预测**:立即应用输入到本地状态
|
||||||
|
3. **发送输入**:将输入发送到服务器
|
||||||
|
4. **缓存输入**:保存输入用于后续校正
|
||||||
|
5. **接收确认**:服务器返回权威状态和已确认序列号
|
||||||
|
6. **状态比较**:比较预测状态和服务器状态
|
||||||
|
7. **输入重放**:使用缓存的未确认输入重新计算状态
|
||||||
|
8. **平滑校正**:平滑插值到正确位置
|
||||||
|
|
||||||
|
## 底层 API
|
||||||
|
|
||||||
|
如果需要更细粒度的控制,可以直接使用 `ClientPrediction` 类:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { createClientPrediction, type IPredictor } from '@esengine/network';
|
||||||
|
|
||||||
|
// 定义状态类型
|
||||||
|
interface PlayerState {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
rotation: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 定义输入类型
|
||||||
|
interface PlayerInput {
|
||||||
|
dx: number;
|
||||||
|
dy: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 定义预测器
|
||||||
|
const predictor: IPredictor<PlayerState, PlayerInput> = {
|
||||||
|
predict(state: PlayerState, input: PlayerInput, dt: number): PlayerState {
|
||||||
|
return {
|
||||||
|
x: state.x + input.dx * MOVE_SPEED * dt,
|
||||||
|
y: state.y + input.dy * MOVE_SPEED * dt,
|
||||||
|
rotation: state.rotation,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 创建客户端预测
|
||||||
|
const prediction = createClientPrediction(predictor, {
|
||||||
|
maxUnacknowledgedInputs: 60,
|
||||||
|
reconciliationThreshold: 0.5,
|
||||||
|
reconciliationSpeed: 10,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 记录输入并获取预测状态
|
||||||
|
const input = { dx: 1, dy: 0 };
|
||||||
|
const predictedState = prediction.recordInput(input, currentState, deltaTime);
|
||||||
|
|
||||||
|
// 获取要发送的输入
|
||||||
|
const inputToSend = prediction.getInputToSend();
|
||||||
|
|
||||||
|
// 与服务器状态校正
|
||||||
|
prediction.reconcile(
|
||||||
|
serverState,
|
||||||
|
serverAckSeq,
|
||||||
|
(state) => ({ x: state.x, y: state.y }),
|
||||||
|
deltaTime
|
||||||
|
);
|
||||||
|
|
||||||
|
// 获取校正偏移
|
||||||
|
const offset = prediction.correctionOffset;
|
||||||
|
```
|
||||||
|
|
||||||
|
## 启用/禁用预测
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 运行时切换预测
|
||||||
|
networkPlugin.setPredictionEnabled(false);
|
||||||
|
|
||||||
|
// 检查预测状态
|
||||||
|
if (networkPlugin.isPredictionEnabled) {
|
||||||
|
console.log('Prediction is active');
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 最佳实践
|
||||||
|
|
||||||
|
### 1. 合理设置校正阈值
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 动作游戏:较低阈值,更精确
|
||||||
|
predictionConfig: {
|
||||||
|
reconciliationThreshold: 0.1,
|
||||||
|
}
|
||||||
|
|
||||||
|
// 休闲游戏:较高阈值,更平滑
|
||||||
|
predictionConfig: {
|
||||||
|
reconciliationThreshold: 1.0,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 预测仅用于本地玩家
|
||||||
|
|
||||||
|
远程玩家应使用插值而非预测:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const identity = entity.getComponent(NetworkIdentity);
|
||||||
|
|
||||||
|
if (identity.bIsLocalPlayer) {
|
||||||
|
// 使用预测系统
|
||||||
|
} else {
|
||||||
|
// 使用 NetworkSyncSystem 的插值
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 处理高延迟
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 高延迟网络增加缓冲
|
||||||
|
predictionConfig: {
|
||||||
|
maxUnacknowledgedInputs: 120, // 增加缓冲
|
||||||
|
reconciliationSpeed: 5, // 减慢校正速度
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. 确定性预测
|
||||||
|
|
||||||
|
确保客户端和服务器使用相同的物理计算:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 使用固定时间步长
|
||||||
|
const FIXED_DT = 1 / 60;
|
||||||
|
|
||||||
|
function applyInput(state: PlayerState, input: PlayerInput): PlayerState {
|
||||||
|
// 使用固定时间步长而非实际 deltaTime
|
||||||
|
return {
|
||||||
|
x: state.x + input.dx * MOVE_SPEED * FIXED_DT,
|
||||||
|
y: state.y + input.dy * MOVE_SPEED * FIXED_DT,
|
||||||
|
rotation: state.rotation,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 调试
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 获取预测系统实例
|
||||||
|
const predictionSystem = networkPlugin.predictionSystem;
|
||||||
|
|
||||||
|
if (predictionSystem) {
|
||||||
|
console.log('Pending inputs:', predictionSystem.pendingInputCount);
|
||||||
|
console.log('Current sequence:', predictionSystem.inputSequence);
|
||||||
|
}
|
||||||
|
```
|
||||||
458
docs/src/content/docs/modules/network/rate-limit.md
Normal file
458
docs/src/content/docs/modules/network/rate-limit.md
Normal file
@@ -0,0 +1,458 @@
|
|||||||
|
---
|
||||||
|
title: "速率限制"
|
||||||
|
description: "使用可配置的速率限制保护你的游戏服务器免受滥用"
|
||||||
|
---
|
||||||
|
|
||||||
|
`@esengine/server` 包含可插拔的速率限制系统,用于防止 DDoS 攻击、消息洪水和其他滥用行为。
|
||||||
|
|
||||||
|
## 安装
|
||||||
|
|
||||||
|
速率限制包含在 server 包中:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install @esengine/server
|
||||||
|
```
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { Room, onMessage } from '@esengine/server'
|
||||||
|
import { withRateLimit, rateLimit, noRateLimit } from '@esengine/server/ratelimit'
|
||||||
|
|
||||||
|
class GameRoom extends withRateLimit(Room, {
|
||||||
|
messagesPerSecond: 10,
|
||||||
|
burstSize: 20,
|
||||||
|
onLimited: (player, type, result) => {
|
||||||
|
player.send('Error', {
|
||||||
|
code: 'RATE_LIMITED',
|
||||||
|
retryAfter: result.retryAfter,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}) {
|
||||||
|
@onMessage('Move')
|
||||||
|
handleMove(data: { x: number; y: number }, player: Player) {
|
||||||
|
// 受速率限制保护(默认 10 msg/s)
|
||||||
|
}
|
||||||
|
|
||||||
|
@rateLimit({ messagesPerSecond: 1 })
|
||||||
|
@onMessage('Trade')
|
||||||
|
handleTrade(data: TradeData, player: Player) {
|
||||||
|
// 交易使用更严格的限制
|
||||||
|
}
|
||||||
|
|
||||||
|
@noRateLimit()
|
||||||
|
@onMessage('Heartbeat')
|
||||||
|
handleHeartbeat(data: any, player: Player) {
|
||||||
|
// 心跳不限制
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 速率限制策略
|
||||||
|
|
||||||
|
### 令牌桶(默认)
|
||||||
|
|
||||||
|
令牌桶算法允许突发流量,同时保持长期速率限制。令牌以固定速率添加,每个请求消耗令牌。
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { withRateLimit } from '@esengine/server/ratelimit'
|
||||||
|
|
||||||
|
class GameRoom extends withRateLimit(Room, {
|
||||||
|
strategy: 'token-bucket',
|
||||||
|
messagesPerSecond: 10, // 补充速率
|
||||||
|
burstSize: 20, // 桶容量
|
||||||
|
}) { }
|
||||||
|
```
|
||||||
|
|
||||||
|
**工作原理:**
|
||||||
|
```
|
||||||
|
配置: rate=10/s, burstSize=20
|
||||||
|
|
||||||
|
[0s] 桶满: 20 令牌
|
||||||
|
[0s] 收到 15 条消息 → 允许,剩余 5
|
||||||
|
[0.5s] 补充 5 令牌 → 10 令牌
|
||||||
|
[0.5s] 收到 8 条消息 → 允许,剩余 2
|
||||||
|
[0.6s] 补充 1 令牌 → 3 令牌
|
||||||
|
[0.6s] 收到 5 条消息 → 允许 3,拒绝 2
|
||||||
|
```
|
||||||
|
|
||||||
|
**最适合:** 大多数通用场景,平衡突发容忍度与保护。
|
||||||
|
|
||||||
|
### 滑动窗口
|
||||||
|
|
||||||
|
滑动窗口算法精确跟踪时间窗口内的请求。比固定窗口更准确,但内存使用稍多。
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
class GameRoom extends withRateLimit(Room, {
|
||||||
|
strategy: 'sliding-window',
|
||||||
|
messagesPerSecond: 10,
|
||||||
|
burstSize: 10,
|
||||||
|
}) { }
|
||||||
|
```
|
||||||
|
|
||||||
|
**最适合:** 需要精确限流且不需要突发容忍的场景。
|
||||||
|
|
||||||
|
### 固定窗口
|
||||||
|
|
||||||
|
固定窗口算法将时间划分为固定间隔,并计算每个间隔内的请求数。简单且内存高效,但在窗口边界允许 2 倍突发。
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
class GameRoom extends withRateLimit(Room, {
|
||||||
|
strategy: 'fixed-window',
|
||||||
|
messagesPerSecond: 10,
|
||||||
|
burstSize: 10,
|
||||||
|
}) { }
|
||||||
|
```
|
||||||
|
|
||||||
|
**最适合:** 简单场景,可接受边界突发。
|
||||||
|
|
||||||
|
## 配置
|
||||||
|
|
||||||
|
### 房间配置
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { withRateLimit } from '@esengine/server/ratelimit'
|
||||||
|
|
||||||
|
class GameRoom extends withRateLimit(Room, {
|
||||||
|
// 每秒允许的消息数(默认: 10)
|
||||||
|
messagesPerSecond: 10,
|
||||||
|
|
||||||
|
// 突发容量 / 桶大小(默认: 20)
|
||||||
|
burstSize: 20,
|
||||||
|
|
||||||
|
// 策略: 'token-bucket' | 'sliding-window' | 'fixed-window'
|
||||||
|
strategy: 'token-bucket',
|
||||||
|
|
||||||
|
// 被限流时的回调
|
||||||
|
onLimited: (player, messageType, result) => {
|
||||||
|
player.send('RateLimited', {
|
||||||
|
type: messageType,
|
||||||
|
retryAfter: result.retryAfter,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
// 限流时断开连接(默认: false)
|
||||||
|
disconnectOnLimit: false,
|
||||||
|
|
||||||
|
// 连续 N 次限流后断开(0 = 永不)
|
||||||
|
maxConsecutiveLimits: 10,
|
||||||
|
|
||||||
|
// 自定义键函数(默认: player.id)
|
||||||
|
getKey: (player) => player.id,
|
||||||
|
|
||||||
|
// 清理间隔(毫秒,默认: 60000)
|
||||||
|
cleanupInterval: 60000,
|
||||||
|
}) { }
|
||||||
|
```
|
||||||
|
|
||||||
|
### 单消息配置
|
||||||
|
|
||||||
|
使用装饰器为特定消息配置速率限制:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { rateLimit, noRateLimit, rateLimitMessage } from '@esengine/server/ratelimit'
|
||||||
|
|
||||||
|
class GameRoom extends withRateLimit(Room) {
|
||||||
|
// 此消息使用自定义速率限制
|
||||||
|
@rateLimit({ messagesPerSecond: 1, burstSize: 2 })
|
||||||
|
@onMessage('Trade')
|
||||||
|
handleTrade(data: TradeData, player: Player) { }
|
||||||
|
|
||||||
|
// 此消息消耗 5 个令牌
|
||||||
|
@rateLimit({ cost: 5 })
|
||||||
|
@onMessage('ExpensiveAction')
|
||||||
|
handleExpensive(data: any, player: Player) { }
|
||||||
|
|
||||||
|
// 豁免速率限制
|
||||||
|
@noRateLimit()
|
||||||
|
@onMessage('Heartbeat')
|
||||||
|
handleHeartbeat(data: any, player: Player) { }
|
||||||
|
|
||||||
|
// 替代方案:显式指定消息类型
|
||||||
|
@rateLimitMessage('SpecialAction', { messagesPerSecond: 2 })
|
||||||
|
@onMessage('SpecialAction')
|
||||||
|
handleSpecial(data: any, player: Player) { }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 与认证系统组合
|
||||||
|
|
||||||
|
速率限制可与认证系统无缝配合:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { withRoomAuth } from '@esengine/server/auth'
|
||||||
|
import { withRateLimit } from '@esengine/server/ratelimit'
|
||||||
|
|
||||||
|
// 同时应用两个 mixin
|
||||||
|
class GameRoom extends withRateLimit(
|
||||||
|
withRoomAuth(Room, { requireAuth: true }),
|
||||||
|
{ messagesPerSecond: 10 }
|
||||||
|
) {
|
||||||
|
onJoin(player: AuthPlayer) {
|
||||||
|
console.log(`${player.user?.name} 已加入,受速率限制保护`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 速率限制结果
|
||||||
|
|
||||||
|
当消息被限流时,回调会收到结果对象:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface RateLimitResult {
|
||||||
|
// 是否允许请求
|
||||||
|
allowed: boolean
|
||||||
|
|
||||||
|
// 剩余配额
|
||||||
|
remaining: number
|
||||||
|
|
||||||
|
// 配额重置时间(时间戳)
|
||||||
|
resetAt: number
|
||||||
|
|
||||||
|
// 重试等待时间(毫秒)
|
||||||
|
retryAfter?: number
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 访问速率限制上下文
|
||||||
|
|
||||||
|
你可以访问任何玩家的速率限制上下文:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { getPlayerRateLimitContext } from '@esengine/server/ratelimit'
|
||||||
|
|
||||||
|
class GameRoom extends withRateLimit(Room) {
|
||||||
|
someMethod(player: Player) {
|
||||||
|
const context = this.getRateLimitContext(player)
|
||||||
|
|
||||||
|
// 检查但不消费
|
||||||
|
const status = context?.check()
|
||||||
|
console.log(`剩余: ${status?.remaining}`)
|
||||||
|
|
||||||
|
// 获取连续限流次数
|
||||||
|
console.log(`连续限流: ${context?.consecutiveLimitCount}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 或使用独立函数
|
||||||
|
const context = getPlayerRateLimitContext(player)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 自定义策略
|
||||||
|
|
||||||
|
你可以直接使用策略进行自定义实现:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import {
|
||||||
|
TokenBucketStrategy,
|
||||||
|
SlidingWindowStrategy,
|
||||||
|
FixedWindowStrategy,
|
||||||
|
createTokenBucketStrategy,
|
||||||
|
} from '@esengine/server/ratelimit'
|
||||||
|
|
||||||
|
// 直接创建策略
|
||||||
|
const strategy = createTokenBucketStrategy({
|
||||||
|
rate: 10, // 每秒令牌数
|
||||||
|
capacity: 20, // 最大令牌数
|
||||||
|
})
|
||||||
|
|
||||||
|
// 检查并消费
|
||||||
|
const result = strategy.consume('player-123')
|
||||||
|
if (result.allowed) {
|
||||||
|
// 处理消息
|
||||||
|
} else {
|
||||||
|
// 被限流,等待 result.retryAfter 毫秒
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查但不消费
|
||||||
|
const status = strategy.getStatus('player-123')
|
||||||
|
|
||||||
|
// 重置某个键
|
||||||
|
strategy.reset('player-123')
|
||||||
|
|
||||||
|
// 清理过期记录
|
||||||
|
strategy.cleanup()
|
||||||
|
```
|
||||||
|
|
||||||
|
## 速率限制上下文
|
||||||
|
|
||||||
|
`RateLimitContext` 类管理单个玩家的速率限制:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { RateLimitContext, TokenBucketStrategy } from '@esengine/server/ratelimit'
|
||||||
|
|
||||||
|
const strategy = new TokenBucketStrategy({ rate: 10, capacity: 20 })
|
||||||
|
const context = new RateLimitContext('player-123', strategy)
|
||||||
|
|
||||||
|
// 检查但不消费
|
||||||
|
context.check()
|
||||||
|
|
||||||
|
// 消费配额
|
||||||
|
context.consume()
|
||||||
|
|
||||||
|
// 带消耗量消费
|
||||||
|
context.consume(undefined, 5)
|
||||||
|
|
||||||
|
// 为特定消息类型消费
|
||||||
|
context.consume('Trade')
|
||||||
|
|
||||||
|
// 设置单消息策略
|
||||||
|
context.setMessageStrategy('Trade', new TokenBucketStrategy({ rate: 1, capacity: 2 }))
|
||||||
|
|
||||||
|
// 重置
|
||||||
|
context.reset()
|
||||||
|
|
||||||
|
// 获取连续限流次数
|
||||||
|
console.log(context.consecutiveLimitCount)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 房间生命周期钩子
|
||||||
|
|
||||||
|
你可以重写 `onRateLimited` 钩子进行自定义处理:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
class GameRoom extends withRateLimit(Room) {
|
||||||
|
onRateLimited(player: Player, messageType: string, result: RateLimitResult) {
|
||||||
|
// 记录事件
|
||||||
|
console.log(`玩家 ${player.id} 在 ${messageType} 上被限流`)
|
||||||
|
|
||||||
|
// 发送自定义错误
|
||||||
|
player.send('SystemMessage', {
|
||||||
|
type: 'warning',
|
||||||
|
message: `请慢一点!${result.retryAfter}ms 后重试`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 最佳实践
|
||||||
|
|
||||||
|
1. **从令牌桶开始**:对于游戏来说是最灵活的算法。
|
||||||
|
|
||||||
|
2. **设置合适的限制**:考虑你的游戏机制:
|
||||||
|
- 移动消息:较高限制(20-60/s)
|
||||||
|
- 聊天消息:较低限制(1-5/s)
|
||||||
|
- 交易/购买:非常低的限制(0.5-1/s)
|
||||||
|
|
||||||
|
3. **使用突发容量**:允许短暂突发以获得响应式体验:
|
||||||
|
```typescript
|
||||||
|
messagesPerSecond: 10,
|
||||||
|
burstSize: 30, // 允许 3 秒的突发
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **豁免关键消息**:不要限制心跳或系统消息:
|
||||||
|
```typescript
|
||||||
|
@noRateLimit()
|
||||||
|
@onMessage('Heartbeat')
|
||||||
|
handleHeartbeat() { }
|
||||||
|
```
|
||||||
|
|
||||||
|
5. **与认证结合**:对已认证用户按用户 ID 限流:
|
||||||
|
```typescript
|
||||||
|
getKey: (player) => player.auth?.userId ?? player.id
|
||||||
|
```
|
||||||
|
|
||||||
|
6. **监控和调整**:记录限流事件以调整限制:
|
||||||
|
```typescript
|
||||||
|
onLimited: (player, type, result) => {
|
||||||
|
metrics.increment('rate_limit', { messageType: type })
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
7. **优雅降级**:发送信息性错误而不是直接断开:
|
||||||
|
```typescript
|
||||||
|
onLimited: (player, type, result) => {
|
||||||
|
player.send('Error', {
|
||||||
|
code: 'RATE_LIMITED',
|
||||||
|
message: '请求过于频繁',
|
||||||
|
retryAfter: result.retryAfter,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 完整示例
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { Room, onMessage, type Player } from '@esengine/server'
|
||||||
|
import { withRoomAuth, type AuthPlayer } from '@esengine/server/auth'
|
||||||
|
import {
|
||||||
|
withRateLimit,
|
||||||
|
rateLimit,
|
||||||
|
noRateLimit,
|
||||||
|
type RateLimitResult,
|
||||||
|
} from '@esengine/server/ratelimit'
|
||||||
|
|
||||||
|
interface User {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
premium: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
// 组合认证和速率限制
|
||||||
|
class GameRoom extends withRateLimit(
|
||||||
|
withRoomAuth<User>(Room, { requireAuth: true }),
|
||||||
|
{
|
||||||
|
messagesPerSecond: 10,
|
||||||
|
burstSize: 30,
|
||||||
|
strategy: 'token-bucket',
|
||||||
|
|
||||||
|
// 使用用户 ID 进行限流
|
||||||
|
getKey: (player) => (player as AuthPlayer<User>).user?.id ?? player.id,
|
||||||
|
|
||||||
|
// 处理限流
|
||||||
|
onLimited: (player, type, result) => {
|
||||||
|
player.send('Error', {
|
||||||
|
code: 'RATE_LIMITED',
|
||||||
|
messageType: type,
|
||||||
|
retryAfter: result.retryAfter,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
// 连续 20 次限流后断开
|
||||||
|
maxConsecutiveLimits: 20,
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
onCreate() {
|
||||||
|
console.log('房间已创建,具有认证 + 速率限制保护')
|
||||||
|
}
|
||||||
|
|
||||||
|
onJoin(player: AuthPlayer<User>) {
|
||||||
|
this.broadcast('PlayerJoined', { name: player.user?.name })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 高频移动(默认速率限制)
|
||||||
|
@onMessage('Move')
|
||||||
|
handleMove(data: { x: number; y: number }, player: AuthPlayer<User>) {
|
||||||
|
this.broadcast('PlayerMoved', { id: player.id, ...data })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 低频交易(严格限制)
|
||||||
|
@rateLimit({ messagesPerSecond: 0.5, burstSize: 2 })
|
||||||
|
@onMessage('Trade')
|
||||||
|
handleTrade(data: TradeData, player: AuthPlayer<User>) {
|
||||||
|
// 处理交易...
|
||||||
|
}
|
||||||
|
|
||||||
|
// 聊天使用中等限制
|
||||||
|
@rateLimit({ messagesPerSecond: 2, burstSize: 5 })
|
||||||
|
@onMessage('Chat')
|
||||||
|
handleChat(data: { text: string }, player: AuthPlayer<User>) {
|
||||||
|
this.broadcast('Chat', {
|
||||||
|
from: player.user?.name,
|
||||||
|
text: data.text,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 系统消息 - 不限制
|
||||||
|
@noRateLimit()
|
||||||
|
@onMessage('Heartbeat')
|
||||||
|
handleHeartbeat(data: any, player: Player) {
|
||||||
|
player.send('Pong', { time: Date.now() })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 自定义限流处理
|
||||||
|
onRateLimited(player: Player, messageType: string, result: RateLimitResult) {
|
||||||
|
console.warn(`[限流] 玩家 ${player.id} 在 ${messageType} 上被限流`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -1,164 +1,21 @@
|
|||||||
---
|
---
|
||||||
title: "服务器端"
|
title: "服务器端"
|
||||||
description: "GameServer 和 Room 管理"
|
description: "使用 @esengine/server 构建游戏服务器"
|
||||||
---
|
---
|
||||||
|
|
||||||
## GameServer
|
## 快速开始
|
||||||
|
|
||||||
GameServer 是服务器端的核心类,管理 WebSocket 连接和房间。
|
使用 CLI 创建新的游戏服务器项目:
|
||||||
|
|
||||||
### 基本用法
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { GameServer } from '@esengine/network-server';
|
|
||||||
|
|
||||||
const server = new GameServer({
|
|
||||||
port: 3000,
|
|
||||||
roomConfig: {
|
|
||||||
maxPlayers: 16,
|
|
||||||
tickRate: 20
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 启动服务器
|
|
||||||
await server.start();
|
|
||||||
console.log('Server started on ws://localhost:3000');
|
|
||||||
|
|
||||||
// 停止服务器
|
|
||||||
await server.stop();
|
|
||||||
```
|
|
||||||
|
|
||||||
### 配置选项
|
|
||||||
|
|
||||||
| 属性 | 类型 | 描述 |
|
|
||||||
|------|------|------|
|
|
||||||
| `port` | `number` | WebSocket 端口 |
|
|
||||||
| `roomConfig.maxPlayers` | `number` | 房间最大玩家数 |
|
|
||||||
| `roomConfig.tickRate` | `number` | 同步频率 (Hz) |
|
|
||||||
|
|
||||||
### 房间管理
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// 获取或创建房间
|
|
||||||
const room = server.getOrCreateRoom('room-id');
|
|
||||||
|
|
||||||
// 获取已存在的房间
|
|
||||||
const existingRoom = server.getRoom('room-id');
|
|
||||||
|
|
||||||
// 销毁房间
|
|
||||||
server.destroyRoom('room-id');
|
|
||||||
```
|
|
||||||
|
|
||||||
## Room
|
|
||||||
|
|
||||||
Room 类管理单个游戏房间的玩家和状态。
|
|
||||||
|
|
||||||
### API
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
class Room {
|
|
||||||
readonly id: string;
|
|
||||||
readonly playerCount: number;
|
|
||||||
readonly isFull: boolean;
|
|
||||||
|
|
||||||
// 添加玩家
|
|
||||||
addPlayer(name: string, connection: Connection): IPlayer | null;
|
|
||||||
|
|
||||||
// 移除玩家
|
|
||||||
removePlayer(clientId: number): void;
|
|
||||||
|
|
||||||
// 获取玩家
|
|
||||||
getPlayer(clientId: number): IPlayer | undefined;
|
|
||||||
|
|
||||||
// 处理输入
|
|
||||||
handleInput(clientId: number, input: IPlayerInput): void;
|
|
||||||
|
|
||||||
// 销毁房间
|
|
||||||
destroy(): void;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 玩家接口
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
interface IPlayer {
|
|
||||||
clientId: number; // 客户端 ID
|
|
||||||
name: string; // 玩家名称
|
|
||||||
connection: Connection; // 连接对象
|
|
||||||
netId: number; // 网络实体 ID
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 协议类型
|
|
||||||
|
|
||||||
### 消息类型
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// 状态同步消息
|
|
||||||
interface MsgSync {
|
|
||||||
time: number;
|
|
||||||
entities: IEntityState[];
|
|
||||||
}
|
|
||||||
|
|
||||||
// 实体状态
|
|
||||||
interface IEntityState {
|
|
||||||
netId: number;
|
|
||||||
pos?: Vec2;
|
|
||||||
rot?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 生成消息
|
|
||||||
interface MsgSpawn {
|
|
||||||
netId: number;
|
|
||||||
ownerId: number;
|
|
||||||
prefab: string;
|
|
||||||
pos: Vec2;
|
|
||||||
rot: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 销毁消息
|
|
||||||
interface MsgDespawn {
|
|
||||||
netId: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 输入消息
|
|
||||||
interface MsgInput {
|
|
||||||
input: IPlayerInput;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 玩家输入
|
|
||||||
interface IPlayerInput {
|
|
||||||
seq?: number;
|
|
||||||
moveDir?: Vec2;
|
|
||||||
actions?: string[];
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### API 类型
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// 加入请求
|
|
||||||
interface ReqJoin {
|
|
||||||
playerName: string;
|
|
||||||
roomId?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 加入响应
|
|
||||||
interface ResJoin {
|
|
||||||
clientId: number;
|
|
||||||
roomId: string;
|
|
||||||
playerCount: number;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 使用 CLI 创建服务端
|
|
||||||
|
|
||||||
推荐使用 ESEngine CLI 快速创建完整的游戏服务端:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
mkdir my-game-server && cd my-game-server
|
# 使用 npm
|
||||||
npm init -y
|
npm create esengine-server my-game-server
|
||||||
npx @esengine/cli init -p nodejs
|
|
||||||
|
# 使用 pnpm
|
||||||
|
pnpm create esengine-server my-game-server
|
||||||
|
|
||||||
|
# 使用 yarn
|
||||||
|
yarn create esengine-server my-game-server
|
||||||
```
|
```
|
||||||
|
|
||||||
生成的项目结构:
|
生成的项目结构:
|
||||||
@@ -166,24 +23,20 @@ npx @esengine/cli init -p nodejs
|
|||||||
```
|
```
|
||||||
my-game-server/
|
my-game-server/
|
||||||
├── src/
|
├── src/
|
||||||
│ ├── index.ts # 入口文件
|
│ ├── shared/ # 共享协议(客户端服务端通用)
|
||||||
│ ├── server/
|
│ │ ├── protocol.ts # 类型定义
|
||||||
│ │ └── GameServer.ts # 网络服务器配置
|
│ │ └── index.ts
|
||||||
│ └── game/
|
│ ├── server/ # 服务端代码
|
||||||
│ ├── Game.ts # ECS 游戏主类
|
│ │ ├── main.ts # 入口
|
||||||
│ ├── scenes/
|
│ │ └── rooms/
|
||||||
│ │ └── MainScene.ts # 主场景
|
│ │ └── GameRoom.ts # 游戏房间
|
||||||
│ ├── components/ # ECS 组件
|
│ └── client/ # 客户端示例
|
||||||
│ │ ├── PositionComponent.ts
|
│ └── index.ts
|
||||||
│ │ └── VelocityComponent.ts
|
|
||||||
│ └── systems/ # ECS 系统
|
|
||||||
│ └── MovementSystem.ts
|
|
||||||
├── tsconfig.json
|
|
||||||
├── package.json
|
├── package.json
|
||||||
└── README.md
|
└── tsconfig.json
|
||||||
```
|
```
|
||||||
|
|
||||||
启动服务端:
|
启动服务器:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 开发模式(热重载)
|
# 开发模式(热重载)
|
||||||
@@ -193,15 +46,290 @@ npm run dev
|
|||||||
npm run start
|
npm run start
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## createServer
|
||||||
|
|
||||||
|
创建游戏服务器实例:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { createServer } from '@esengine/server'
|
||||||
|
import { GameRoom } from './rooms/GameRoom.js'
|
||||||
|
|
||||||
|
const server = await createServer({
|
||||||
|
port: 3000,
|
||||||
|
onConnect(conn) {
|
||||||
|
console.log('Client connected:', conn.id)
|
||||||
|
},
|
||||||
|
onDisconnect(conn) {
|
||||||
|
console.log('Client disconnected:', conn.id)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// 注册房间类型
|
||||||
|
server.define('game', GameRoom)
|
||||||
|
|
||||||
|
// 启动服务器
|
||||||
|
await server.start()
|
||||||
|
```
|
||||||
|
|
||||||
|
### 配置选项
|
||||||
|
|
||||||
|
| 属性 | 类型 | 默认值 | 描述 |
|
||||||
|
|------|------|--------|------|
|
||||||
|
| `port` | `number` | `3000` | WebSocket 端口 |
|
||||||
|
| `tickRate` | `number` | `20` | 全局 Tick 频率 (Hz) |
|
||||||
|
| `apiDir` | `string` | `'src/api'` | API 处理器目录 |
|
||||||
|
| `msgDir` | `string` | `'src/msg'` | 消息处理器目录 |
|
||||||
|
| `onStart` | `(port) => void` | - | 启动回调 |
|
||||||
|
| `onConnect` | `(conn) => void` | - | 连接回调 |
|
||||||
|
| `onDisconnect` | `(conn) => void` | - | 断开回调 |
|
||||||
|
|
||||||
|
## Room 系统
|
||||||
|
|
||||||
|
Room 是游戏房间的基类,管理玩家和游戏状态。
|
||||||
|
|
||||||
|
### 定义房间
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { Room, Player, onMessage } from '@esengine/server'
|
||||||
|
import type { MsgMove, MsgChat } from '../../shared/index.js'
|
||||||
|
|
||||||
|
interface PlayerData {
|
||||||
|
name: string
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export class GameRoom extends Room<{ players: any[] }, PlayerData> {
|
||||||
|
// 配置
|
||||||
|
maxPlayers = 8
|
||||||
|
tickRate = 20
|
||||||
|
autoDispose = true
|
||||||
|
|
||||||
|
// 房间状态
|
||||||
|
state = {
|
||||||
|
players: [],
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生命周期
|
||||||
|
onCreate() {
|
||||||
|
console.log(`Room ${this.id} created`)
|
||||||
|
}
|
||||||
|
|
||||||
|
onJoin(player: Player<PlayerData>) {
|
||||||
|
player.data.name = 'Player_' + player.id.slice(-4)
|
||||||
|
player.data.x = Math.random() * 800
|
||||||
|
player.data.y = Math.random() * 600
|
||||||
|
|
||||||
|
this.broadcast('Joined', {
|
||||||
|
playerId: player.id,
|
||||||
|
playerName: player.data.name,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
onLeave(player: Player<PlayerData>) {
|
||||||
|
this.broadcast('Left', { playerId: player.id })
|
||||||
|
}
|
||||||
|
|
||||||
|
onTick(dt: number) {
|
||||||
|
// 状态同步
|
||||||
|
this.broadcast('Sync', { players: this.state.players })
|
||||||
|
}
|
||||||
|
|
||||||
|
onDispose() {
|
||||||
|
console.log(`Room ${this.id} disposed`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 消息处理
|
||||||
|
@onMessage('Move')
|
||||||
|
handleMove(data: MsgMove, player: Player<PlayerData>) {
|
||||||
|
player.data.x = data.x
|
||||||
|
player.data.y = data.y
|
||||||
|
}
|
||||||
|
|
||||||
|
@onMessage('Chat')
|
||||||
|
handleChat(data: MsgChat, player: Player<PlayerData>) {
|
||||||
|
this.broadcast('Chat', {
|
||||||
|
from: player.data.name,
|
||||||
|
text: data.text,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Room 配置
|
||||||
|
|
||||||
|
| 属性 | 类型 | 默认值 | 描述 |
|
||||||
|
|------|------|--------|------|
|
||||||
|
| `maxPlayers` | `number` | `10` | 最大玩家数 |
|
||||||
|
| `tickRate` | `number` | `20` | Tick 频率 (Hz) |
|
||||||
|
| `autoDispose` | `boolean` | `true` | 空房间自动销毁 |
|
||||||
|
|
||||||
|
### Room API
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
class Room<TState, TPlayerData> {
|
||||||
|
readonly id: string // 房间 ID
|
||||||
|
readonly players: Player[] // 所有玩家
|
||||||
|
readonly playerCount: number // 玩家数量
|
||||||
|
readonly isLocked: boolean // 是否锁定
|
||||||
|
state: TState // 房间状态
|
||||||
|
|
||||||
|
// 广播消息给所有玩家
|
||||||
|
broadcast<T>(type: string, data: T): void
|
||||||
|
|
||||||
|
// 广播消息给除某玩家外的所有人
|
||||||
|
broadcastExcept<T>(type: string, data: T, except: Player): void
|
||||||
|
|
||||||
|
// 获取玩家
|
||||||
|
getPlayer(id: string): Player | undefined
|
||||||
|
|
||||||
|
// 踢出玩家
|
||||||
|
kick(player: Player, reason?: string): void
|
||||||
|
|
||||||
|
// 锁定/解锁房间
|
||||||
|
lock(): void
|
||||||
|
unlock(): void
|
||||||
|
|
||||||
|
// 销毁房间
|
||||||
|
dispose(): void
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 生命周期方法
|
||||||
|
|
||||||
|
| 方法 | 触发时机 | 用途 |
|
||||||
|
|------|----------|------|
|
||||||
|
| `onCreate()` | 房间创建时 | 初始化游戏状态 |
|
||||||
|
| `onJoin(player)` | 玩家加入时 | 欢迎消息、分配位置 |
|
||||||
|
| `onLeave(player)` | 玩家离开时 | 清理玩家数据 |
|
||||||
|
| `onTick(dt)` | 每帧调用 | 游戏逻辑、状态同步 |
|
||||||
|
| `onDispose()` | 房间销毁前 | 保存数据、清理资源 |
|
||||||
|
|
||||||
|
## Player 类
|
||||||
|
|
||||||
|
Player 代表房间中的一个玩家连接。
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
class Player<TData = Record<string, unknown>> {
|
||||||
|
readonly id: string // 玩家 ID
|
||||||
|
readonly roomId: string // 所在房间 ID
|
||||||
|
data: TData // 自定义数据
|
||||||
|
|
||||||
|
// 发送消息给此玩家
|
||||||
|
send<T>(type: string, data: T): void
|
||||||
|
|
||||||
|
// 离开房间
|
||||||
|
leave(): void
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## @onMessage 装饰器
|
||||||
|
|
||||||
|
使用装饰器简化消息处理:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { Room, Player, onMessage } from '@esengine/server'
|
||||||
|
|
||||||
|
class GameRoom extends Room {
|
||||||
|
@onMessage('Move')
|
||||||
|
handleMove(data: { x: number; y: number }, player: Player) {
|
||||||
|
// 处理移动
|
||||||
|
}
|
||||||
|
|
||||||
|
@onMessage('Attack')
|
||||||
|
handleAttack(data: { targetId: string }, player: Player) {
|
||||||
|
// 处理攻击
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 协议定义
|
||||||
|
|
||||||
|
在 `src/shared/protocol.ts` 中定义客户端和服务端共享的类型:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// API 请求/响应
|
||||||
|
export interface JoinRoomReq {
|
||||||
|
roomType: string
|
||||||
|
playerName: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface JoinRoomRes {
|
||||||
|
roomId: string
|
||||||
|
playerId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// 游戏消息
|
||||||
|
export interface MsgMove {
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MsgChat {
|
||||||
|
text: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// 服务端广播
|
||||||
|
export interface BroadcastSync {
|
||||||
|
players: PlayerState[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlayerState {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 客户端连接
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { connect } from '@esengine/rpc/client'
|
||||||
|
|
||||||
|
const client = await connect('ws://localhost:3000')
|
||||||
|
|
||||||
|
// 加入房间
|
||||||
|
const { roomId, playerId } = await client.call('JoinRoom', {
|
||||||
|
roomType: 'game',
|
||||||
|
playerName: 'Alice',
|
||||||
|
})
|
||||||
|
|
||||||
|
// 监听广播
|
||||||
|
client.onMessage('Sync', (data) => {
|
||||||
|
console.log('State:', data.players)
|
||||||
|
})
|
||||||
|
|
||||||
|
client.onMessage('Joined', (data) => {
|
||||||
|
console.log('Player joined:', data.playerName)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 发送消息
|
||||||
|
client.send('RoomMessage', {
|
||||||
|
type: 'Move',
|
||||||
|
payload: { x: 100, y: 200 },
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
## 最佳实践
|
## 最佳实践
|
||||||
|
|
||||||
1. **合理设置同步频率**:根据游戏类型选择合适的 `tickRate`
|
1. **合理设置 Tick 频率**
|
||||||
- 回合制游戏:5-10 Hz
|
- 回合制游戏:5-10 Hz
|
||||||
- 休闲游戏:10-20 Hz
|
- 休闲游戏:10-20 Hz
|
||||||
- 动作游戏:20-60 Hz
|
- 动作游戏:20-60 Hz
|
||||||
|
|
||||||
2. **房间大小控制**:根据服务器性能设置合理的 `maxPlayers`
|
2. **使用共享协议**
|
||||||
|
- 在 `shared/` 目录定义所有类型
|
||||||
|
- 客户端和服务端都从这里导入
|
||||||
|
|
||||||
3. **连接管理**:监听玩家连接/断开事件,处理异常情况
|
3. **状态验证**
|
||||||
|
- 服务器应验证客户端输入
|
||||||
|
- 不信任客户端发送的任何数据
|
||||||
|
|
||||||
4. **状态验证**:服务器应验证客户端输入,防止作弊
|
4. **断线处理**
|
||||||
|
- 实现断线重连逻辑
|
||||||
|
- 使用 `onLeave` 保存玩家状态
|
||||||
|
|
||||||
|
5. **房间生命周期**
|
||||||
|
- 使用 `autoDispose` 自动清理空房间
|
||||||
|
- 在 `onDispose` 中保存重要数据
|
||||||
|
|||||||
251
docs/src/content/docs/modules/rpc/client.md
Normal file
251
docs/src/content/docs/modules/rpc/client.md
Normal file
@@ -0,0 +1,251 @@
|
|||||||
|
---
|
||||||
|
title: "RPC 客户端 API"
|
||||||
|
description: "RpcClient 连接 RPC 服务器"
|
||||||
|
---
|
||||||
|
|
||||||
|
`RpcClient` 类提供类型安全的 WebSocket 客户端,用于 RPC 通信。
|
||||||
|
|
||||||
|
## 基础用法
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { RpcClient } from '@esengine/rpc/client';
|
||||||
|
import { gameProtocol } from './protocol';
|
||||||
|
|
||||||
|
const client = new RpcClient(gameProtocol, 'ws://localhost:3000', {
|
||||||
|
onConnect: () => console.log('已连接'),
|
||||||
|
onDisconnect: (reason) => console.log('已断开:', reason),
|
||||||
|
onError: (error) => console.error('错误:', error),
|
||||||
|
});
|
||||||
|
|
||||||
|
await client.connect();
|
||||||
|
```
|
||||||
|
|
||||||
|
## 构造选项
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface RpcClientOptions {
|
||||||
|
// 序列化编解码器(默认: json())
|
||||||
|
codec?: Codec;
|
||||||
|
|
||||||
|
// API 调用超时,毫秒(默认: 30000)
|
||||||
|
timeout?: number;
|
||||||
|
|
||||||
|
// 断开后自动重连(默认: true)
|
||||||
|
autoReconnect?: boolean;
|
||||||
|
|
||||||
|
// 重连间隔,毫秒(默认: 3000)
|
||||||
|
reconnectInterval?: number;
|
||||||
|
|
||||||
|
// 自定义 WebSocket 工厂(用于微信小游戏等)
|
||||||
|
webSocketFactory?: (url: string) => WebSocketAdapter;
|
||||||
|
|
||||||
|
// 回调函数
|
||||||
|
onConnect?: () => void;
|
||||||
|
onDisconnect?: (reason?: string) => void;
|
||||||
|
onError?: (error: Error) => void;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 连接管理
|
||||||
|
|
||||||
|
### 连接
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// connect 返回 Promise
|
||||||
|
await client.connect();
|
||||||
|
|
||||||
|
// 或链式调用
|
||||||
|
client.connect().then(() => {
|
||||||
|
console.log('已就绪');
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 检查状态
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 连接状态: 'connecting' | 'open' | 'closing' | 'closed'
|
||||||
|
console.log(client.status);
|
||||||
|
|
||||||
|
// 便捷布尔值
|
||||||
|
if (client.isConnected) {
|
||||||
|
// 可以安全调用 API
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 断开连接
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 手动断开(禁用自动重连)
|
||||||
|
client.disconnect();
|
||||||
|
```
|
||||||
|
|
||||||
|
## 调用 API
|
||||||
|
|
||||||
|
API 使用请求-响应模式,完全类型安全:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 定义协议
|
||||||
|
const protocol = rpc.define({
|
||||||
|
api: {
|
||||||
|
login: rpc.api<{ username: string }, { userId: string; token: string }>(),
|
||||||
|
getProfile: rpc.api<{ userId: string }, { name: string; level: number }>(),
|
||||||
|
},
|
||||||
|
msg: {}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 调用时类型自动推断
|
||||||
|
const { userId, token } = await client.call('login', { username: 'player1' });
|
||||||
|
const profile = await client.call('getProfile', { userId });
|
||||||
|
```
|
||||||
|
|
||||||
|
### 错误处理
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { RpcError, ErrorCode } from '@esengine/rpc/client';
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.call('login', { username: 'player1' });
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof RpcError) {
|
||||||
|
switch (error.code) {
|
||||||
|
case ErrorCode.TIMEOUT:
|
||||||
|
console.log('请求超时');
|
||||||
|
break;
|
||||||
|
case ErrorCode.CONNECTION_CLOSED:
|
||||||
|
console.log('未连接');
|
||||||
|
break;
|
||||||
|
case ErrorCode.NOT_FOUND:
|
||||||
|
console.log('API 不存在');
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
console.log('服务器错误:', error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 发送消息
|
||||||
|
|
||||||
|
消息是发送即忘模式(无响应):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 向服务器发送消息
|
||||||
|
client.send('playerMove', { x: 100, y: 200 });
|
||||||
|
client.send('chat', { text: 'Hello!' });
|
||||||
|
```
|
||||||
|
|
||||||
|
## 接收消息
|
||||||
|
|
||||||
|
监听服务器推送的消息:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 订阅消息
|
||||||
|
client.on('newMessage', (data) => {
|
||||||
|
console.log(`${data.from}: ${data.text}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
client.on('playerJoined', (data) => {
|
||||||
|
console.log(`${data.name} 加入游戏`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 取消特定处理器
|
||||||
|
const handler = (data) => console.log(data);
|
||||||
|
client.on('event', handler);
|
||||||
|
client.off('event', handler);
|
||||||
|
|
||||||
|
// 取消某消息的所有处理器
|
||||||
|
client.off('event');
|
||||||
|
|
||||||
|
// 一次性监听
|
||||||
|
client.once('gameStart', (data) => {
|
||||||
|
console.log('游戏开始!');
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## 自定义 WebSocket(平台适配器)
|
||||||
|
|
||||||
|
用于微信小游戏等平台:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 微信小游戏适配器
|
||||||
|
const wxWebSocketFactory = (url: string) => {
|
||||||
|
const ws = wx.connectSocket({ url });
|
||||||
|
|
||||||
|
return {
|
||||||
|
get readyState() { return ws.readyState; },
|
||||||
|
send: (data) => ws.send({ data }),
|
||||||
|
close: (code, reason) => ws.close({ code, reason }),
|
||||||
|
set onopen(fn) { ws.onOpen(fn); },
|
||||||
|
set onclose(fn) { ws.onClose((e) => fn({ code: e.code, reason: e.reason })); },
|
||||||
|
set onerror(fn) { ws.onError(fn); },
|
||||||
|
set onmessage(fn) { ws.onMessage((e) => fn({ data: e.data })); },
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const client = new RpcClient(protocol, 'wss://game.example.com', {
|
||||||
|
webSocketFactory: wxWebSocketFactory,
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## 便捷函数
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { connect } from '@esengine/rpc/client';
|
||||||
|
|
||||||
|
// 一次调用完成连接
|
||||||
|
const client = await connect(protocol, 'ws://localhost:3000', {
|
||||||
|
onConnect: () => console.log('已连接'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await client.call('join', { name: 'Alice' });
|
||||||
|
```
|
||||||
|
|
||||||
|
## 完整示例
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { RpcClient } from '@esengine/rpc/client';
|
||||||
|
import { gameProtocol } from './protocol';
|
||||||
|
|
||||||
|
class GameClient {
|
||||||
|
private client: RpcClient<typeof gameProtocol>;
|
||||||
|
private userId: string | null = null;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.client = new RpcClient(gameProtocol, 'ws://localhost:3000', {
|
||||||
|
onConnect: () => this.onConnected(),
|
||||||
|
onDisconnect: () => this.onDisconnected(),
|
||||||
|
onError: (e) => console.error('RPC 错误:', e),
|
||||||
|
});
|
||||||
|
|
||||||
|
// 设置消息处理器
|
||||||
|
this.client.on('gameState', (state) => this.updateState(state));
|
||||||
|
this.client.on('playerJoined', (p) => this.addPlayer(p));
|
||||||
|
this.client.on('playerLeft', (p) => this.removePlayer(p));
|
||||||
|
}
|
||||||
|
|
||||||
|
async connect() {
|
||||||
|
await this.client.connect();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async onConnected() {
|
||||||
|
const { userId, token } = await this.client.call('login', {
|
||||||
|
username: localStorage.getItem('username') || 'Guest',
|
||||||
|
});
|
||||||
|
this.userId = userId;
|
||||||
|
console.log('登录为', userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private onDisconnected() {
|
||||||
|
console.log('已断开,将自动重连...');
|
||||||
|
}
|
||||||
|
|
||||||
|
async move(x: number, y: number) {
|
||||||
|
if (!this.client.isConnected) return;
|
||||||
|
this.client.send('move', { x, y });
|
||||||
|
}
|
||||||
|
|
||||||
|
async chat(text: string) {
|
||||||
|
await this.client.call('sendChat', { text });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
160
docs/src/content/docs/modules/rpc/codec.md
Normal file
160
docs/src/content/docs/modules/rpc/codec.md
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
---
|
||||||
|
title: "RPC 编解码器"
|
||||||
|
description: "RPC 通信的序列化编解码器"
|
||||||
|
---
|
||||||
|
|
||||||
|
编解码器负责 RPC 消息的序列化和反序列化。内置两种编解码器。
|
||||||
|
|
||||||
|
## 内置编解码器
|
||||||
|
|
||||||
|
### JSON 编解码器(默认)
|
||||||
|
|
||||||
|
人类可读,兼容性好:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { json } from '@esengine/rpc/codec';
|
||||||
|
|
||||||
|
const client = new RpcClient(protocol, url, {
|
||||||
|
codec: json(),
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**优点:**
|
||||||
|
- 人类可读(方便调试)
|
||||||
|
- 无额外依赖
|
||||||
|
- 浏览器普遍支持
|
||||||
|
|
||||||
|
**缺点:**
|
||||||
|
- 消息体积较大
|
||||||
|
- 序列化速度较慢
|
||||||
|
|
||||||
|
### MessagePack 编解码器
|
||||||
|
|
||||||
|
二进制格式,更高效:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { msgpack } from '@esengine/rpc/codec';
|
||||||
|
|
||||||
|
const client = new RpcClient(protocol, url, {
|
||||||
|
codec: msgpack(),
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**优点:**
|
||||||
|
- 消息体积更小(约小30-50%)
|
||||||
|
- 序列化速度更快
|
||||||
|
- 原生支持二进制数据
|
||||||
|
|
||||||
|
**缺点:**
|
||||||
|
- 不可读
|
||||||
|
- 需要 msgpack 库
|
||||||
|
|
||||||
|
## 编解码器接口
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface Codec {
|
||||||
|
/**
|
||||||
|
* 将数据包编码为传输格式
|
||||||
|
*/
|
||||||
|
encode(packet: unknown): string | Uint8Array;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将传输格式解码为数据包
|
||||||
|
*/
|
||||||
|
decode(data: string | Uint8Array): unknown;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 自定义编解码器
|
||||||
|
|
||||||
|
为特殊需求创建自己的编解码器:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import type { Codec } from '@esengine/rpc/codec';
|
||||||
|
|
||||||
|
// 示例:压缩 JSON 编解码器
|
||||||
|
const compressedJson: () => Codec = () => ({
|
||||||
|
encode(packet: unknown): Uint8Array {
|
||||||
|
const json = JSON.stringify(packet);
|
||||||
|
return compress(new TextEncoder().encode(json));
|
||||||
|
},
|
||||||
|
|
||||||
|
decode(data: string | Uint8Array): unknown {
|
||||||
|
const bytes = typeof data === 'string'
|
||||||
|
? new TextEncoder().encode(data)
|
||||||
|
: data;
|
||||||
|
const decompressed = decompress(bytes);
|
||||||
|
return JSON.parse(new TextDecoder().decode(decompressed));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 使用自定义编解码器
|
||||||
|
const client = new RpcClient(protocol, url, {
|
||||||
|
codec: compressedJson(),
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Protocol Buffers 编解码器
|
||||||
|
|
||||||
|
对于生产级游戏,考虑使用 Protocol Buffers:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import type { Codec } from '@esengine/rpc/codec';
|
||||||
|
|
||||||
|
const protobuf = (schema: ProtobufSchema): Codec => ({
|
||||||
|
encode(packet: unknown): Uint8Array {
|
||||||
|
return schema.Packet.encode(packet).finish();
|
||||||
|
},
|
||||||
|
|
||||||
|
decode(data: string | Uint8Array): unknown {
|
||||||
|
const bytes = typeof data === 'string'
|
||||||
|
? new TextEncoder().encode(data)
|
||||||
|
: data;
|
||||||
|
return schema.Packet.decode(bytes);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## 客户端与服务器匹配
|
||||||
|
|
||||||
|
客户端和服务器必须使用相同的编解码器:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// shared/codec.ts
|
||||||
|
import { msgpack } from '@esengine/rpc/codec';
|
||||||
|
export const gameCodec = msgpack();
|
||||||
|
|
||||||
|
// client.ts
|
||||||
|
import { gameCodec } from './shared/codec';
|
||||||
|
const client = new RpcClient(protocol, url, { codec: gameCodec });
|
||||||
|
|
||||||
|
// server.ts
|
||||||
|
import { gameCodec } from './shared/codec';
|
||||||
|
const server = serve(protocol, {
|
||||||
|
port: 3000,
|
||||||
|
codec: gameCodec,
|
||||||
|
api: { /* ... */ },
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## 性能对比
|
||||||
|
|
||||||
|
| 编解码器 | 编码速度 | 解码速度 | 体积 |
|
||||||
|
|----------|----------|----------|------|
|
||||||
|
| JSON | 中等 | 中等 | 大 |
|
||||||
|
| MessagePack | 快 | 快 | 小 |
|
||||||
|
| Protobuf | 最快 | 最快 | 最小 |
|
||||||
|
|
||||||
|
对于大多数游戏,MessagePack 提供了良好的平衡。对于高性能需求使用 Protobuf。
|
||||||
|
|
||||||
|
## 文本编码工具
|
||||||
|
|
||||||
|
为自定义编解码器提供工具函数:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { textEncode, textDecode } from '@esengine/rpc/codec';
|
||||||
|
|
||||||
|
// 在所有平台上工作(浏览器、Node.js、微信)
|
||||||
|
const bytes = textEncode('Hello'); // Uint8Array
|
||||||
|
const text = textDecode(bytes); // 'Hello'
|
||||||
|
```
|
||||||
350
docs/src/content/docs/modules/rpc/server.md
Normal file
350
docs/src/content/docs/modules/rpc/server.md
Normal file
@@ -0,0 +1,350 @@
|
|||||||
|
---
|
||||||
|
title: "RPC 服务器 API"
|
||||||
|
description: "RpcServer 处理客户端连接"
|
||||||
|
---
|
||||||
|
|
||||||
|
`serve` 函数创建类型安全的 RPC 服务器,处理客户端连接和 API 调用。
|
||||||
|
|
||||||
|
## 基础用法
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { serve } from '@esengine/rpc/server';
|
||||||
|
import { gameProtocol } from './protocol';
|
||||||
|
|
||||||
|
const server = serve(gameProtocol, {
|
||||||
|
port: 3000,
|
||||||
|
api: {
|
||||||
|
login: async (input, conn) => {
|
||||||
|
console.log(`${input.username} 从 ${conn.ip} 连接`);
|
||||||
|
return { userId: conn.id, token: generateToken() };
|
||||||
|
},
|
||||||
|
sendChat: async (input, conn) => {
|
||||||
|
server.broadcast('newMessage', {
|
||||||
|
from: conn.id,
|
||||||
|
text: input.text,
|
||||||
|
time: Date.now(),
|
||||||
|
});
|
||||||
|
return { success: true };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
onStart: (port) => console.log(`服务器启动于端口 ${port}`),
|
||||||
|
});
|
||||||
|
|
||||||
|
await server.start();
|
||||||
|
```
|
||||||
|
|
||||||
|
## 服务器选项
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface ServeOptions<P, TConnData> {
|
||||||
|
// 必需
|
||||||
|
port: number;
|
||||||
|
api: ApiHandlers<P, TConnData>;
|
||||||
|
|
||||||
|
// 可选
|
||||||
|
msg?: MsgHandlers<P, TConnData>;
|
||||||
|
codec?: Codec;
|
||||||
|
createConnData?: () => TConnData;
|
||||||
|
|
||||||
|
// 回调
|
||||||
|
onConnect?: (conn: Connection<TConnData>) => void | Promise<void>;
|
||||||
|
onDisconnect?: (conn: Connection<TConnData>, reason?: string) => void | Promise<void>;
|
||||||
|
onError?: (error: Error, conn?: Connection<TConnData>) => void;
|
||||||
|
onStart?: (port: number) => void;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## API 处理器
|
||||||
|
|
||||||
|
每个 API 处理器接收输入和连接上下文:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const server = serve(protocol, {
|
||||||
|
port: 3000,
|
||||||
|
api: {
|
||||||
|
// 同步处理器
|
||||||
|
ping: (input, conn) => {
|
||||||
|
return { pong: true, time: Date.now() };
|
||||||
|
},
|
||||||
|
|
||||||
|
// 异步处理器
|
||||||
|
getProfile: async (input, conn) => {
|
||||||
|
const user = await database.findUser(input.userId);
|
||||||
|
return { name: user.name, level: user.level };
|
||||||
|
},
|
||||||
|
|
||||||
|
// 访问连接上下文
|
||||||
|
getMyInfo: (input, conn) => {
|
||||||
|
return {
|
||||||
|
connectionId: conn.id,
|
||||||
|
ip: conn.ip,
|
||||||
|
data: conn.data,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 抛出错误
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { RpcError, ErrorCode } from '@esengine/rpc/server';
|
||||||
|
|
||||||
|
const server = serve(protocol, {
|
||||||
|
port: 3000,
|
||||||
|
api: {
|
||||||
|
login: async (input, conn) => {
|
||||||
|
const user = await database.findUser(input.username);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new RpcError(ErrorCode.NOT_FOUND, '用户不存在');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!await verifyPassword(input.password, user.hash)) {
|
||||||
|
throw new RpcError('AUTH_FAILED', '密码错误');
|
||||||
|
}
|
||||||
|
|
||||||
|
return { userId: user.id, token: generateToken() };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## 消息处理器
|
||||||
|
|
||||||
|
处理客户端发送的消息:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const server = serve(protocol, {
|
||||||
|
port: 3000,
|
||||||
|
api: { /* ... */ },
|
||||||
|
msg: {
|
||||||
|
playerMove: (data, conn) => {
|
||||||
|
// 更新玩家位置
|
||||||
|
const player = players.get(conn.id);
|
||||||
|
if (player) {
|
||||||
|
player.x = data.x;
|
||||||
|
player.y = data.y;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 广播给其他玩家
|
||||||
|
server.broadcast('playerMoved', {
|
||||||
|
playerId: conn.id,
|
||||||
|
x: data.x,
|
||||||
|
y: data.y,
|
||||||
|
}, { exclude: conn });
|
||||||
|
},
|
||||||
|
|
||||||
|
chat: async (data, conn) => {
|
||||||
|
// 异步处理器也可以
|
||||||
|
await logChat(conn.id, data.text);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## 连接上下文
|
||||||
|
|
||||||
|
`Connection` 对象提供客户端信息:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface Connection<TData> {
|
||||||
|
// 唯一连接 ID
|
||||||
|
readonly id: string;
|
||||||
|
|
||||||
|
// 客户端 IP 地址
|
||||||
|
readonly ip: string;
|
||||||
|
|
||||||
|
// 连接状态
|
||||||
|
readonly isOpen: boolean;
|
||||||
|
|
||||||
|
// 附加到此连接的自定义数据
|
||||||
|
data: TData;
|
||||||
|
|
||||||
|
// 关闭连接
|
||||||
|
close(reason?: string): void;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 自定义连接数据
|
||||||
|
|
||||||
|
存储每连接的状态:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface PlayerData {
|
||||||
|
playerId: string;
|
||||||
|
username: string;
|
||||||
|
room: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const server = serve(protocol, {
|
||||||
|
port: 3000,
|
||||||
|
createConnData: () => ({
|
||||||
|
playerId: '',
|
||||||
|
username: '',
|
||||||
|
room: null,
|
||||||
|
} as PlayerData),
|
||||||
|
api: {
|
||||||
|
login: async (input, conn) => {
|
||||||
|
// 在连接上存储数据
|
||||||
|
conn.data.playerId = generateId();
|
||||||
|
conn.data.username = input.username;
|
||||||
|
return { playerId: conn.data.playerId };
|
||||||
|
},
|
||||||
|
joinRoom: async (input, conn) => {
|
||||||
|
conn.data.room = input.roomId;
|
||||||
|
return { success: true };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
onDisconnect: (conn) => {
|
||||||
|
console.log(`${conn.data.username} 离开房间 ${conn.data.room}`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## 发送消息
|
||||||
|
|
||||||
|
### 发送给单个连接
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
server.send(conn, 'notification', { text: 'Hello!' });
|
||||||
|
```
|
||||||
|
|
||||||
|
### 广播给所有人
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 给所有人
|
||||||
|
server.broadcast('announcement', { text: '服务器将在5分钟后重启' });
|
||||||
|
|
||||||
|
// 排除发送者
|
||||||
|
server.broadcast('playerMoved', { id: conn.id, x, y }, { exclude: conn });
|
||||||
|
|
||||||
|
// 排除多个
|
||||||
|
server.broadcast('gameEvent', data, { exclude: [conn1, conn2] });
|
||||||
|
```
|
||||||
|
|
||||||
|
### 发送给特定群组
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 自定义广播
|
||||||
|
function broadcastToRoom(roomId: string, name: string, data: any) {
|
||||||
|
for (const conn of server.connections) {
|
||||||
|
if (conn.data.room === roomId) {
|
||||||
|
server.send(conn, name, data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
broadcastToRoom('room1', 'roomMessage', { text: '房间内消息!' });
|
||||||
|
```
|
||||||
|
|
||||||
|
## 服务器生命周期
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const server = serve(protocol, { /* ... */ });
|
||||||
|
|
||||||
|
// 启动
|
||||||
|
await server.start();
|
||||||
|
console.log('服务器运行中');
|
||||||
|
|
||||||
|
// 访问连接列表
|
||||||
|
console.log(`${server.connections.length} 个客户端已连接`);
|
||||||
|
|
||||||
|
// 停止(关闭所有连接)
|
||||||
|
await server.stop();
|
||||||
|
console.log('服务器已停止');
|
||||||
|
```
|
||||||
|
|
||||||
|
## 完整示例
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { serve, RpcError } from '@esengine/rpc/server';
|
||||||
|
import { gameProtocol } from './protocol';
|
||||||
|
|
||||||
|
interface PlayerData {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const players = new Map<string, PlayerData>();
|
||||||
|
|
||||||
|
const server = serve(gameProtocol, {
|
||||||
|
port: 3000,
|
||||||
|
createConnData: () => ({ id: '', name: '', x: 0, y: 0 }),
|
||||||
|
|
||||||
|
api: {
|
||||||
|
join: async (input, conn) => {
|
||||||
|
const player: PlayerData = {
|
||||||
|
id: conn.id,
|
||||||
|
name: input.name,
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
};
|
||||||
|
players.set(conn.id, player);
|
||||||
|
conn.data = player;
|
||||||
|
|
||||||
|
// 通知其他玩家
|
||||||
|
server.broadcast('playerJoined', {
|
||||||
|
id: player.id,
|
||||||
|
name: player.name,
|
||||||
|
}, { exclude: conn });
|
||||||
|
|
||||||
|
// 发送当前状态给新玩家
|
||||||
|
return {
|
||||||
|
playerId: player.id,
|
||||||
|
players: Array.from(players.values()),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
chat: async (input, conn) => {
|
||||||
|
server.broadcast('chatMessage', {
|
||||||
|
from: conn.data.name,
|
||||||
|
text: input.text,
|
||||||
|
time: Date.now(),
|
||||||
|
});
|
||||||
|
return { sent: true };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
msg: {
|
||||||
|
move: (data, conn) => {
|
||||||
|
const player = players.get(conn.id);
|
||||||
|
if (player) {
|
||||||
|
player.x = data.x;
|
||||||
|
player.y = data.y;
|
||||||
|
|
||||||
|
server.broadcast('playerMoved', {
|
||||||
|
id: conn.id,
|
||||||
|
x: data.x,
|
||||||
|
y: data.y,
|
||||||
|
}, { exclude: conn });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
onConnect: (conn) => {
|
||||||
|
console.log(`客户端已连接: ${conn.id} 来自 ${conn.ip}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
onDisconnect: (conn) => {
|
||||||
|
const player = players.get(conn.id);
|
||||||
|
if (player) {
|
||||||
|
players.delete(conn.id);
|
||||||
|
server.broadcast('playerLeft', { id: conn.id });
|
||||||
|
console.log(`${player.name} 已断开`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
onError: (error, conn) => {
|
||||||
|
console.error(`来自 ${conn?.id} 的错误:`, error);
|
||||||
|
},
|
||||||
|
|
||||||
|
onStart: (port) => {
|
||||||
|
console.log(`游戏服务器运行于 ws://localhost:${port}`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
server.start();
|
||||||
|
```
|
||||||
261
docs/src/content/docs/modules/transaction/core.md
Normal file
261
docs/src/content/docs/modules/transaction/core.md
Normal file
@@ -0,0 +1,261 @@
|
|||||||
|
---
|
||||||
|
title: "核心概念"
|
||||||
|
description: "事务系统的核心概念:事务上下文、事务管理器、Saga 模式"
|
||||||
|
---
|
||||||
|
|
||||||
|
## 事务状态
|
||||||
|
|
||||||
|
事务有以下几种状态:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
type TransactionState =
|
||||||
|
| 'pending' // 等待执行
|
||||||
|
| 'executing' // 执行中
|
||||||
|
| 'committed' // 已提交
|
||||||
|
| 'rolledback' // 已回滚
|
||||||
|
| 'failed' // 失败
|
||||||
|
```
|
||||||
|
|
||||||
|
## TransactionContext
|
||||||
|
|
||||||
|
事务上下文封装了事务的状态、操作和执行逻辑。
|
||||||
|
|
||||||
|
### 创建事务
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { TransactionManager } from '@esengine/transaction';
|
||||||
|
|
||||||
|
const manager = new TransactionManager();
|
||||||
|
|
||||||
|
// 方式 1:使用 begin() 手动管理
|
||||||
|
const tx = manager.begin({ timeout: 5000 });
|
||||||
|
tx.addOperation(op1);
|
||||||
|
tx.addOperation(op2);
|
||||||
|
const result = await tx.execute();
|
||||||
|
|
||||||
|
// 方式 2:使用 run() 自动管理
|
||||||
|
const result = await manager.run((tx) => {
|
||||||
|
tx.addOperation(op1);
|
||||||
|
tx.addOperation(op2);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 链式添加操作
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const result = await manager.run((tx) => {
|
||||||
|
tx.addOperation(new CurrencyOperation({ ... }))
|
||||||
|
.addOperation(new InventoryOperation({ ... }))
|
||||||
|
.addOperation(new InventoryOperation({ ... }));
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 上下文数据
|
||||||
|
|
||||||
|
操作之间可以通过上下文共享数据:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
class CustomOperation extends BaseOperation<MyData, MyResult> {
|
||||||
|
async execute(ctx: ITransactionContext): Promise<OperationResult<MyResult>> {
|
||||||
|
// 读取之前操作设置的数据
|
||||||
|
const previousResult = ctx.get<number>('previousValue');
|
||||||
|
|
||||||
|
// 设置数据供后续操作使用
|
||||||
|
ctx.set('myResult', { value: 123 });
|
||||||
|
|
||||||
|
return this.success({ ... });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## TransactionManager
|
||||||
|
|
||||||
|
事务管理器负责创建、执行和恢复事务。
|
||||||
|
|
||||||
|
### 配置选项
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface TransactionManagerConfig {
|
||||||
|
storage?: ITransactionStorage; // 存储实例
|
||||||
|
defaultTimeout?: number; // 默认超时(毫秒)
|
||||||
|
serverId?: string; // 服务器 ID(分布式用)
|
||||||
|
autoRecover?: boolean; // 自动恢复未完成事务
|
||||||
|
}
|
||||||
|
|
||||||
|
const manager = new TransactionManager({
|
||||||
|
storage: new RedisStorage({ client: redis }),
|
||||||
|
defaultTimeout: 10000,
|
||||||
|
serverId: 'server-1',
|
||||||
|
autoRecover: true,
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 分布式锁
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 获取锁
|
||||||
|
const token = await manager.acquireLock('player:123:inventory', 10000);
|
||||||
|
|
||||||
|
if (token) {
|
||||||
|
try {
|
||||||
|
// 执行操作
|
||||||
|
await doSomething();
|
||||||
|
} finally {
|
||||||
|
// 释放锁
|
||||||
|
await manager.releaseLock('player:123:inventory', token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 或使用 withLock 简化
|
||||||
|
await manager.withLock('player:123:inventory', async () => {
|
||||||
|
await doSomething();
|
||||||
|
}, 10000);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 事务恢复
|
||||||
|
|
||||||
|
服务器重启时恢复未完成的事务:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const manager = new TransactionManager({
|
||||||
|
storage: new RedisStorage({ client: redis }),
|
||||||
|
serverId: 'server-1',
|
||||||
|
});
|
||||||
|
|
||||||
|
// 恢复未完成的事务
|
||||||
|
const recoveredCount = await manager.recover();
|
||||||
|
console.log(`Recovered ${recoveredCount} transactions`);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Saga 模式
|
||||||
|
|
||||||
|
事务系统采用 Saga 模式,每个操作必须实现 `execute` 和 `compensate` 方法:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface ITransactionOperation<TData, TResult> {
|
||||||
|
readonly name: string;
|
||||||
|
readonly data: TData;
|
||||||
|
|
||||||
|
// 验证前置条件
|
||||||
|
validate(ctx: ITransactionContext): Promise<boolean>;
|
||||||
|
|
||||||
|
// 正向执行
|
||||||
|
execute(ctx: ITransactionContext): Promise<OperationResult<TResult>>;
|
||||||
|
|
||||||
|
// 补偿操作(回滚)
|
||||||
|
compensate(ctx: ITransactionContext): Promise<void>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 执行流程
|
||||||
|
|
||||||
|
```
|
||||||
|
开始事务
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────────┐
|
||||||
|
│ validate(op1) │──失败──► 返回失败
|
||||||
|
└─────────────────────┘
|
||||||
|
│成功
|
||||||
|
▼
|
||||||
|
┌─────────────────────┐
|
||||||
|
│ execute(op1) │──失败──┐
|
||||||
|
└─────────────────────┘ │
|
||||||
|
│成功 │
|
||||||
|
▼ │
|
||||||
|
┌─────────────────────┐ │
|
||||||
|
│ validate(op2) │──失败──┤
|
||||||
|
└─────────────────────┘ │
|
||||||
|
│成功 │
|
||||||
|
▼ │
|
||||||
|
┌─────────────────────┐ │
|
||||||
|
│ execute(op2) │──失败──┤
|
||||||
|
└─────────────────────┘ │
|
||||||
|
│成功 ▼
|
||||||
|
▼ ┌─────────────────────┐
|
||||||
|
提交事务 │ compensate(op1) │
|
||||||
|
└─────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
返回失败(已回滚)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 自定义操作
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { BaseOperation, ITransactionContext, OperationResult } from '@esengine/transaction';
|
||||||
|
|
||||||
|
interface UpgradeData {
|
||||||
|
playerId: string;
|
||||||
|
itemId: string;
|
||||||
|
targetLevel: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UpgradeResult {
|
||||||
|
newLevel: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
class UpgradeOperation extends BaseOperation<UpgradeData, UpgradeResult> {
|
||||||
|
readonly name = 'upgrade';
|
||||||
|
|
||||||
|
private _previousLevel: number = 0;
|
||||||
|
|
||||||
|
async validate(ctx: ITransactionContext): Promise<boolean> {
|
||||||
|
// 验证物品存在且可升级
|
||||||
|
const item = await this.getItem(ctx);
|
||||||
|
return item !== null && item.level < this.data.targetLevel;
|
||||||
|
}
|
||||||
|
|
||||||
|
async execute(ctx: ITransactionContext): Promise<OperationResult<UpgradeResult>> {
|
||||||
|
const item = await this.getItem(ctx);
|
||||||
|
if (!item) {
|
||||||
|
return this.failure('Item not found', 'ITEM_NOT_FOUND');
|
||||||
|
}
|
||||||
|
|
||||||
|
this._previousLevel = item.level;
|
||||||
|
item.level = this.data.targetLevel;
|
||||||
|
await this.saveItem(ctx, item);
|
||||||
|
|
||||||
|
return this.success({ newLevel: item.level });
|
||||||
|
}
|
||||||
|
|
||||||
|
async compensate(ctx: ITransactionContext): Promise<void> {
|
||||||
|
const item = await this.getItem(ctx);
|
||||||
|
if (item) {
|
||||||
|
item.level = this._previousLevel;
|
||||||
|
await this.saveItem(ctx, item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getItem(ctx: ITransactionContext) {
|
||||||
|
// 从存储获取物品
|
||||||
|
}
|
||||||
|
|
||||||
|
private async saveItem(ctx: ITransactionContext, item: any) {
|
||||||
|
// 保存物品到存储
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 事务结果
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface TransactionResult<T = unknown> {
|
||||||
|
success: boolean; // 是否成功
|
||||||
|
transactionId: string; // 事务 ID
|
||||||
|
results: OperationResult[]; // 各操作结果
|
||||||
|
data?: T; // 最终数据
|
||||||
|
error?: string; // 错误信息
|
||||||
|
duration: number; // 执行时间(毫秒)
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await manager.run((tx) => { ... });
|
||||||
|
|
||||||
|
console.log(`Transaction ${result.transactionId}`);
|
||||||
|
console.log(`Success: ${result.success}`);
|
||||||
|
console.log(`Duration: ${result.duration}ms`);
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
console.log(`Error: ${result.error}`);
|
||||||
|
}
|
||||||
|
```
|
||||||
355
docs/src/content/docs/modules/transaction/distributed.md
Normal file
355
docs/src/content/docs/modules/transaction/distributed.md
Normal file
@@ -0,0 +1,355 @@
|
|||||||
|
---
|
||||||
|
title: "分布式事务"
|
||||||
|
description: "Saga 编排器和跨服务器事务支持"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Saga 编排器
|
||||||
|
|
||||||
|
`SagaOrchestrator` 用于编排跨服务器的分布式事务。
|
||||||
|
|
||||||
|
### 基本用法
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { SagaOrchestrator, RedisStorage } from '@esengine/transaction';
|
||||||
|
|
||||||
|
const orchestrator = new SagaOrchestrator({
|
||||||
|
storage: new RedisStorage({ client: redis }),
|
||||||
|
timeout: 30000,
|
||||||
|
serverId: 'orchestrator-1',
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await orchestrator.execute([
|
||||||
|
{
|
||||||
|
name: 'deduct_currency',
|
||||||
|
serverId: 'game-server-1',
|
||||||
|
data: { playerId: 'player1', amount: 100 },
|
||||||
|
execute: async (data) => {
|
||||||
|
// 调用游戏服务器 API 扣除货币
|
||||||
|
const response = await gameServerApi.deductCurrency(data);
|
||||||
|
return { success: response.ok };
|
||||||
|
},
|
||||||
|
compensate: async (data) => {
|
||||||
|
// 调用游戏服务器 API 恢复货币
|
||||||
|
await gameServerApi.addCurrency(data);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'add_item',
|
||||||
|
serverId: 'inventory-server-1',
|
||||||
|
data: { playerId: 'player1', itemId: 'sword' },
|
||||||
|
execute: async (data) => {
|
||||||
|
const response = await inventoryServerApi.addItem(data);
|
||||||
|
return { success: response.ok };
|
||||||
|
},
|
||||||
|
compensate: async (data) => {
|
||||||
|
await inventoryServerApi.removeItem(data);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
console.log('Saga completed successfully');
|
||||||
|
} else {
|
||||||
|
console.log('Saga failed:', result.error);
|
||||||
|
console.log('Completed steps:', result.completedSteps);
|
||||||
|
console.log('Failed at:', result.failedStep);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 配置选项
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface SagaOrchestratorConfig {
|
||||||
|
storage?: ITransactionStorage; // 存储实例
|
||||||
|
timeout?: number; // 超时时间(毫秒)
|
||||||
|
serverId?: string; // 编排器服务器 ID
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Saga 步骤
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface SagaStep<T = unknown> {
|
||||||
|
name: string; // 步骤名称
|
||||||
|
serverId?: string; // 目标服务器 ID
|
||||||
|
data: T; // 步骤数据
|
||||||
|
execute: (data: T) => Promise<OperationResult>; // 执行函数
|
||||||
|
compensate: (data: T) => Promise<void>; // 补偿函数
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Saga 结果
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface SagaResult {
|
||||||
|
success: boolean; // 是否成功
|
||||||
|
sagaId: string; // Saga ID
|
||||||
|
completedSteps: string[]; // 已完成的步骤
|
||||||
|
failedStep?: string; // 失败的步骤
|
||||||
|
error?: string; // 错误信息
|
||||||
|
duration: number; // 执行时间(毫秒)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 执行流程
|
||||||
|
|
||||||
|
```
|
||||||
|
开始 Saga
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────────┐
|
||||||
|
│ Step 1: execute │──失败──┐
|
||||||
|
└─────────────────────┘ │
|
||||||
|
│成功 │
|
||||||
|
▼ │
|
||||||
|
┌─────────────────────┐ │
|
||||||
|
│ Step 2: execute │──失败──┤
|
||||||
|
└─────────────────────┘ │
|
||||||
|
│成功 │
|
||||||
|
▼ │
|
||||||
|
┌─────────────────────┐ │
|
||||||
|
│ Step 3: execute │──失败──┤
|
||||||
|
└─────────────────────┘ │
|
||||||
|
│成功 ▼
|
||||||
|
▼ ┌─────────────────────┐
|
||||||
|
Saga 完成 │ Step 2: compensate │
|
||||||
|
└─────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────────┐
|
||||||
|
│ Step 1: compensate │
|
||||||
|
└─────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Saga 失败(已补偿)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Saga 日志
|
||||||
|
|
||||||
|
编排器会记录详细的执行日志:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface SagaLog {
|
||||||
|
id: string; // Saga ID
|
||||||
|
state: SagaLogState; // 状态
|
||||||
|
steps: SagaStepLog[]; // 步骤日志
|
||||||
|
createdAt: number; // 创建时间
|
||||||
|
updatedAt: number; // 更新时间
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
type SagaLogState =
|
||||||
|
| 'pending' // 等待执行
|
||||||
|
| 'running' // 执行中
|
||||||
|
| 'completed' // 已完成
|
||||||
|
| 'compensating' // 补偿中
|
||||||
|
| 'compensated' // 已补偿
|
||||||
|
| 'failed' // 失败
|
||||||
|
|
||||||
|
interface SagaStepLog {
|
||||||
|
name: string; // 步骤名称
|
||||||
|
serverId?: string; // 服务器 ID
|
||||||
|
state: SagaStepState; // 状态
|
||||||
|
startedAt?: number; // 开始时间
|
||||||
|
completedAt?: number; // 完成时间
|
||||||
|
error?: string; // 错误信息
|
||||||
|
}
|
||||||
|
|
||||||
|
type SagaStepState =
|
||||||
|
| 'pending' // 等待执行
|
||||||
|
| 'executing' // 执行中
|
||||||
|
| 'completed' // 已完成
|
||||||
|
| 'compensating' // 补偿中
|
||||||
|
| 'compensated' // 已补偿
|
||||||
|
| 'failed' // 失败
|
||||||
|
```
|
||||||
|
|
||||||
|
### 查询 Saga 日志
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const log = await orchestrator.getSagaLog('saga_xxx');
|
||||||
|
|
||||||
|
if (log) {
|
||||||
|
console.log('Saga state:', log.state);
|
||||||
|
for (const step of log.steps) {
|
||||||
|
console.log(` ${step.name}: ${step.state}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 跨服务器事务示例
|
||||||
|
|
||||||
|
### 场景:跨服购买
|
||||||
|
|
||||||
|
玩家在游戏服务器购买物品,货币在账户服务器,物品在背包服务器。
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const orchestrator = new SagaOrchestrator({
|
||||||
|
storage: redisStorage,
|
||||||
|
serverId: 'purchase-orchestrator',
|
||||||
|
});
|
||||||
|
|
||||||
|
async function crossServerPurchase(
|
||||||
|
playerId: string,
|
||||||
|
itemId: string,
|
||||||
|
price: number
|
||||||
|
): Promise<SagaResult> {
|
||||||
|
return orchestrator.execute([
|
||||||
|
// 步骤 1:在账户服务器扣款
|
||||||
|
{
|
||||||
|
name: 'deduct_balance',
|
||||||
|
serverId: 'account-server',
|
||||||
|
data: { playerId, amount: price },
|
||||||
|
execute: async (data) => {
|
||||||
|
const result = await accountService.deduct(data.playerId, data.amount);
|
||||||
|
return { success: result.ok, error: result.error };
|
||||||
|
},
|
||||||
|
compensate: async (data) => {
|
||||||
|
await accountService.refund(data.playerId, data.amount);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// 步骤 2:在背包服务器添加物品
|
||||||
|
{
|
||||||
|
name: 'add_item',
|
||||||
|
serverId: 'inventory-server',
|
||||||
|
data: { playerId, itemId },
|
||||||
|
execute: async (data) => {
|
||||||
|
const result = await inventoryService.addItem(data.playerId, data.itemId);
|
||||||
|
return { success: result.ok, error: result.error };
|
||||||
|
},
|
||||||
|
compensate: async (data) => {
|
||||||
|
await inventoryService.removeItem(data.playerId, data.itemId);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// 步骤 3:记录购买日志
|
||||||
|
{
|
||||||
|
name: 'log_purchase',
|
||||||
|
serverId: 'log-server',
|
||||||
|
data: { playerId, itemId, price, timestamp: Date.now() },
|
||||||
|
execute: async (data) => {
|
||||||
|
await logService.recordPurchase(data);
|
||||||
|
return { success: true };
|
||||||
|
},
|
||||||
|
compensate: async (data) => {
|
||||||
|
await logService.cancelPurchase(data);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 场景:跨服交易
|
||||||
|
|
||||||
|
两个玩家在不同服务器上进行交易。
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
async function crossServerTrade(
|
||||||
|
playerA: { id: string; server: string; items: string[] },
|
||||||
|
playerB: { id: string; server: string; items: string[] }
|
||||||
|
): Promise<SagaResult> {
|
||||||
|
const steps: SagaStep[] = [];
|
||||||
|
|
||||||
|
// 移除 A 的物品
|
||||||
|
for (const itemId of playerA.items) {
|
||||||
|
steps.push({
|
||||||
|
name: `remove_${playerA.id}_${itemId}`,
|
||||||
|
serverId: playerA.server,
|
||||||
|
data: { playerId: playerA.id, itemId },
|
||||||
|
execute: async (data) => {
|
||||||
|
return await inventoryService.removeItem(data.playerId, data.itemId);
|
||||||
|
},
|
||||||
|
compensate: async (data) => {
|
||||||
|
await inventoryService.addItem(data.playerId, data.itemId);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加物品到 B
|
||||||
|
for (const itemId of playerA.items) {
|
||||||
|
steps.push({
|
||||||
|
name: `add_${playerB.id}_${itemId}`,
|
||||||
|
serverId: playerB.server,
|
||||||
|
data: { playerId: playerB.id, itemId },
|
||||||
|
execute: async (data) => {
|
||||||
|
return await inventoryService.addItem(data.playerId, data.itemId);
|
||||||
|
},
|
||||||
|
compensate: async (data) => {
|
||||||
|
await inventoryService.removeItem(data.playerId, data.itemId);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 类似地处理 B 的物品...
|
||||||
|
|
||||||
|
return orchestrator.execute(steps);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 恢复未完成的 Saga
|
||||||
|
|
||||||
|
服务器重启后恢复未完成的 Saga:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const orchestrator = new SagaOrchestrator({
|
||||||
|
storage: redisStorage,
|
||||||
|
serverId: 'my-orchestrator',
|
||||||
|
});
|
||||||
|
|
||||||
|
// 恢复未完成的 Saga(会执行补偿)
|
||||||
|
const recoveredCount = await orchestrator.recover();
|
||||||
|
console.log(`Recovered ${recoveredCount} sagas`);
|
||||||
|
```
|
||||||
|
|
||||||
|
## 最佳实践
|
||||||
|
|
||||||
|
### 1. 幂等性
|
||||||
|
|
||||||
|
确保所有操作都是幂等的:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
execute: async (data) => {
|
||||||
|
// 使用唯一 ID 确保幂等
|
||||||
|
const result = await service.process(data.requestId, data);
|
||||||
|
return { success: result.ok };
|
||||||
|
},
|
||||||
|
compensate: async (data) => {
|
||||||
|
// 补偿也要幂等
|
||||||
|
await service.rollback(data.requestId);
|
||||||
|
},
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 超时处理
|
||||||
|
|
||||||
|
设置合适的超时时间:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const orchestrator = new SagaOrchestrator({
|
||||||
|
timeout: 60000, // 跨服务器操作需要更长超时
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 监控和告警
|
||||||
|
|
||||||
|
记录 Saga 执行结果:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const result = await orchestrator.execute(steps);
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
// 发送告警
|
||||||
|
alertService.send({
|
||||||
|
type: 'saga_failed',
|
||||||
|
sagaId: result.sagaId,
|
||||||
|
failedStep: result.failedStep,
|
||||||
|
error: result.error,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 记录详细日志
|
||||||
|
const log = await orchestrator.getSagaLog(result.sagaId);
|
||||||
|
logger.error('Saga failed', { log });
|
||||||
|
}
|
||||||
|
```
|
||||||
238
docs/src/content/docs/modules/transaction/index.md
Normal file
238
docs/src/content/docs/modules/transaction/index.md
Normal file
@@ -0,0 +1,238 @@
|
|||||||
|
---
|
||||||
|
title: "事务系统 (Transaction)"
|
||||||
|
description: "游戏事务处理系统,支持商店购买、玩家交易、分布式事务"
|
||||||
|
---
|
||||||
|
|
||||||
|
`@esengine/transaction` 提供完整的游戏事务处理能力,基于 Saga 模式实现,支持商店购买、玩家交易、多步骤任务等场景,并提供 Redis/MongoDB 分布式事务支持。
|
||||||
|
|
||||||
|
## 概述
|
||||||
|
|
||||||
|
事务系统解决游戏中常见的数据一致性问题:
|
||||||
|
|
||||||
|
| 场景 | 问题 | 解决方案 |
|
||||||
|
|------|------|----------|
|
||||||
|
| 商店购买 | 扣款成功但物品未发放 | 原子事务,失败自动回滚 |
|
||||||
|
| 玩家交易 | 一方物品转移另一方未收到 | Saga 补偿机制 |
|
||||||
|
| 跨服操作 | 多服务器数据不一致 | 分布式锁 + 事务日志 |
|
||||||
|
|
||||||
|
## 安装
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install @esengine/transaction
|
||||||
|
```
|
||||||
|
|
||||||
|
可选依赖(根据存储需求安装):
|
||||||
|
```bash
|
||||||
|
npm install ioredis # Redis 存储
|
||||||
|
npm install mongodb # MongoDB 存储
|
||||||
|
```
|
||||||
|
|
||||||
|
## 架构
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ Transaction Layer │
|
||||||
|
├─────────────────────────────────────────────────────────────────┤
|
||||||
|
│ TransactionManager - 事务管理器,协调事务生命周期 │
|
||||||
|
│ TransactionContext - 事务上下文,封装操作和状态 │
|
||||||
|
│ SagaOrchestrator - 分布式 Saga 编排器 │
|
||||||
|
├─────────────────────────────────────────────────────────────────┤
|
||||||
|
│ Storage Layer │
|
||||||
|
├─────────────────────────────────────────────────────────────────┤
|
||||||
|
│ MemoryStorage - 内存存储(开发/测试) │
|
||||||
|
│ RedisStorage - Redis(分布式锁 + 缓存) │
|
||||||
|
│ MongoStorage - MongoDB(持久化日志) │
|
||||||
|
├─────────────────────────────────────────────────────────────────┤
|
||||||
|
│ Operation Layer │
|
||||||
|
├─────────────────────────────────────────────────────────────────┤
|
||||||
|
│ CurrencyOperation - 货币操作 │
|
||||||
|
│ InventoryOperation - 背包操作 │
|
||||||
|
│ TradeOperation - 交易操作 │
|
||||||
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
|
### 基础用法
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import {
|
||||||
|
TransactionManager,
|
||||||
|
MemoryStorage,
|
||||||
|
CurrencyOperation,
|
||||||
|
InventoryOperation,
|
||||||
|
} from '@esengine/transaction';
|
||||||
|
|
||||||
|
// 创建事务管理器
|
||||||
|
const manager = new TransactionManager({
|
||||||
|
storage: new MemoryStorage(),
|
||||||
|
defaultTimeout: 10000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 执行事务
|
||||||
|
const result = await manager.run((tx) => {
|
||||||
|
// 扣除金币
|
||||||
|
tx.addOperation(new CurrencyOperation({
|
||||||
|
type: 'deduct',
|
||||||
|
playerId: 'player1',
|
||||||
|
currency: 'gold',
|
||||||
|
amount: 100,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 添加物品
|
||||||
|
tx.addOperation(new InventoryOperation({
|
||||||
|
type: 'add',
|
||||||
|
playerId: 'player1',
|
||||||
|
itemId: 'sword_001',
|
||||||
|
quantity: 1,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
console.log('购买成功!');
|
||||||
|
} else {
|
||||||
|
console.log('购买失败:', result.error);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 玩家交易
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { TradeOperation } from '@esengine/transaction';
|
||||||
|
|
||||||
|
const result = await manager.run((tx) => {
|
||||||
|
tx.addOperation(new TradeOperation({
|
||||||
|
tradeId: 'trade_001',
|
||||||
|
partyA: {
|
||||||
|
playerId: 'player1',
|
||||||
|
items: [{ itemId: 'sword', quantity: 1 }],
|
||||||
|
},
|
||||||
|
partyB: {
|
||||||
|
playerId: 'player2',
|
||||||
|
currencies: [{ currency: 'gold', amount: 1000 }],
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}, { timeout: 30000 });
|
||||||
|
```
|
||||||
|
|
||||||
|
### 使用 Redis 存储
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import Redis from 'ioredis';
|
||||||
|
import { TransactionManager, RedisStorage } from '@esengine/transaction';
|
||||||
|
|
||||||
|
const redis = new Redis('redis://localhost:6379');
|
||||||
|
const storage = new RedisStorage({ client: redis });
|
||||||
|
|
||||||
|
const manager = new TransactionManager({ storage });
|
||||||
|
```
|
||||||
|
|
||||||
|
### 使用 MongoDB 存储
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { MongoClient } from 'mongodb';
|
||||||
|
import { TransactionManager, MongoStorage } from '@esengine/transaction';
|
||||||
|
|
||||||
|
const client = new MongoClient('mongodb://localhost:27017');
|
||||||
|
await client.connect();
|
||||||
|
const db = client.db('game');
|
||||||
|
|
||||||
|
const storage = new MongoStorage({ db });
|
||||||
|
await storage.ensureIndexes();
|
||||||
|
|
||||||
|
const manager = new TransactionManager({ storage });
|
||||||
|
```
|
||||||
|
|
||||||
|
## 与 Room 集成
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { Room } from '@esengine/server';
|
||||||
|
import { withTransactions, CurrencyOperation, RedisStorage } from '@esengine/transaction';
|
||||||
|
|
||||||
|
class GameRoom extends withTransactions(Room, {
|
||||||
|
storage: new RedisStorage({ client: redisClient }),
|
||||||
|
}) {
|
||||||
|
@onMessage('Buy')
|
||||||
|
async handleBuy(data: { itemId: string }, player: Player) {
|
||||||
|
const result = await this.runTransaction((tx) => {
|
||||||
|
tx.addOperation(new CurrencyOperation({
|
||||||
|
type: 'deduct',
|
||||||
|
playerId: player.id,
|
||||||
|
currency: 'gold',
|
||||||
|
amount: getItemPrice(data.itemId),
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
player.send('buy_success', { itemId: data.itemId });
|
||||||
|
} else {
|
||||||
|
player.send('buy_failed', { error: result.error });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 文档导航
|
||||||
|
|
||||||
|
- [核心概念](/modules/transaction/core/) - 事务上下文、管理器、Saga 模式
|
||||||
|
- [存储层](/modules/transaction/storage/) - MemoryStorage、RedisStorage、MongoStorage
|
||||||
|
- [操作类](/modules/transaction/operations/) - 货币、背包、交易操作
|
||||||
|
- [分布式事务](/modules/transaction/distributed/) - Saga 编排器、跨服务器事务
|
||||||
|
- [API 参考](/modules/transaction/api/) - 完整 API 文档
|
||||||
|
|
||||||
|
## 服务令牌
|
||||||
|
|
||||||
|
用于依赖注入:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import {
|
||||||
|
TransactionManagerToken,
|
||||||
|
TransactionStorageToken,
|
||||||
|
} from '@esengine/transaction';
|
||||||
|
|
||||||
|
const manager = services.get(TransactionManagerToken);
|
||||||
|
```
|
||||||
|
|
||||||
|
## 最佳实践
|
||||||
|
|
||||||
|
### 1. 操作粒度
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ✅ 好:细粒度操作,便于回滚
|
||||||
|
tx.addOperation(new CurrencyOperation({ type: 'deduct', ... }));
|
||||||
|
tx.addOperation(new InventoryOperation({ type: 'add', ... }));
|
||||||
|
|
||||||
|
// ❌ 差:粗粒度操作,难以部分回滚
|
||||||
|
tx.addOperation(new ComplexPurchaseOperation({ ... }));
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 超时设置
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 简单操作:短超时
|
||||||
|
await manager.run(tx => { ... }, { timeout: 5000 });
|
||||||
|
|
||||||
|
// 复杂交易:长超时
|
||||||
|
await manager.run(tx => { ... }, { timeout: 30000 });
|
||||||
|
|
||||||
|
// 跨服务器:更长超时
|
||||||
|
await manager.run(tx => { ... }, { timeout: 60000, distributed: true });
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 错误处理
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const result = await manager.run((tx) => { ... });
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
// 记录日志
|
||||||
|
logger.error('Transaction failed', {
|
||||||
|
transactionId: result.transactionId,
|
||||||
|
error: result.error,
|
||||||
|
duration: result.duration,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 通知用户
|
||||||
|
player.send('error', { message: getErrorMessage(result.error) });
|
||||||
|
}
|
||||||
|
```
|
||||||
313
docs/src/content/docs/modules/transaction/operations.md
Normal file
313
docs/src/content/docs/modules/transaction/operations.md
Normal file
@@ -0,0 +1,313 @@
|
|||||||
|
---
|
||||||
|
title: "操作类"
|
||||||
|
description: "内置的事务操作:货币、背包、交易"
|
||||||
|
---
|
||||||
|
|
||||||
|
## BaseOperation
|
||||||
|
|
||||||
|
所有操作类的基类,提供通用的实现模板。
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { BaseOperation, ITransactionContext, OperationResult } from '@esengine/transaction';
|
||||||
|
|
||||||
|
class MyOperation extends BaseOperation<MyData, MyResult> {
|
||||||
|
readonly name = 'myOperation';
|
||||||
|
|
||||||
|
async validate(ctx: ITransactionContext): Promise<boolean> {
|
||||||
|
// 验证前置条件
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async execute(ctx: ITransactionContext): Promise<OperationResult<MyResult>> {
|
||||||
|
// 执行操作
|
||||||
|
return this.success({ result: 'ok' });
|
||||||
|
// 或
|
||||||
|
return this.failure('Something went wrong', 'ERROR_CODE');
|
||||||
|
}
|
||||||
|
|
||||||
|
async compensate(ctx: ITransactionContext): Promise<void> {
|
||||||
|
// 回滚操作
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## CurrencyOperation
|
||||||
|
|
||||||
|
处理货币的增加和扣除。
|
||||||
|
|
||||||
|
### 扣除货币
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { CurrencyOperation } from '@esengine/transaction';
|
||||||
|
|
||||||
|
tx.addOperation(new CurrencyOperation({
|
||||||
|
type: 'deduct',
|
||||||
|
playerId: 'player1',
|
||||||
|
currency: 'gold',
|
||||||
|
amount: 100,
|
||||||
|
reason: 'purchase_item',
|
||||||
|
}));
|
||||||
|
```
|
||||||
|
|
||||||
|
### 增加货币
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
tx.addOperation(new CurrencyOperation({
|
||||||
|
type: 'add',
|
||||||
|
playerId: 'player1',
|
||||||
|
currency: 'diamond',
|
||||||
|
amount: 50,
|
||||||
|
reason: 'daily_reward',
|
||||||
|
}));
|
||||||
|
```
|
||||||
|
|
||||||
|
### 操作数据
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface CurrencyOperationData {
|
||||||
|
type: 'add' | 'deduct'; // 操作类型
|
||||||
|
playerId: string; // 玩家 ID
|
||||||
|
currency: string; // 货币类型
|
||||||
|
amount: number; // 数量
|
||||||
|
reason?: string; // 原因/来源
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 操作结果
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface CurrencyOperationResult {
|
||||||
|
beforeBalance: number; // 操作前余额
|
||||||
|
afterBalance: number; // 操作后余额
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 自定义数据提供者
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface ICurrencyProvider {
|
||||||
|
getBalance(playerId: string, currency: string): Promise<number>;
|
||||||
|
setBalance(playerId: string, currency: string, amount: number): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
class MyCurrencyProvider implements ICurrencyProvider {
|
||||||
|
async getBalance(playerId: string, currency: string): Promise<number> {
|
||||||
|
// 从数据库获取余额
|
||||||
|
return await db.getCurrency(playerId, currency);
|
||||||
|
}
|
||||||
|
|
||||||
|
async setBalance(playerId: string, currency: string, amount: number): Promise<void> {
|
||||||
|
// 保存到数据库
|
||||||
|
await db.setCurrency(playerId, currency, amount);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用自定义提供者
|
||||||
|
const op = new CurrencyOperation({ ... });
|
||||||
|
op.setProvider(new MyCurrencyProvider());
|
||||||
|
tx.addOperation(op);
|
||||||
|
```
|
||||||
|
|
||||||
|
## InventoryOperation
|
||||||
|
|
||||||
|
处理物品的添加、移除和更新。
|
||||||
|
|
||||||
|
### 添加物品
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { InventoryOperation } from '@esengine/transaction';
|
||||||
|
|
||||||
|
tx.addOperation(new InventoryOperation({
|
||||||
|
type: 'add',
|
||||||
|
playerId: 'player1',
|
||||||
|
itemId: 'sword_001',
|
||||||
|
quantity: 1,
|
||||||
|
properties: { enchant: 'fire' },
|
||||||
|
}));
|
||||||
|
```
|
||||||
|
|
||||||
|
### 移除物品
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
tx.addOperation(new InventoryOperation({
|
||||||
|
type: 'remove',
|
||||||
|
playerId: 'player1',
|
||||||
|
itemId: 'potion_hp',
|
||||||
|
quantity: 5,
|
||||||
|
}));
|
||||||
|
```
|
||||||
|
|
||||||
|
### 更新物品
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
tx.addOperation(new InventoryOperation({
|
||||||
|
type: 'update',
|
||||||
|
playerId: 'player1',
|
||||||
|
itemId: 'sword_001',
|
||||||
|
quantity: 1, // 可选,不传则保持原数量
|
||||||
|
properties: { enchant: 'lightning', level: 5 },
|
||||||
|
}));
|
||||||
|
```
|
||||||
|
|
||||||
|
### 操作数据
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface InventoryOperationData {
|
||||||
|
type: 'add' | 'remove' | 'update'; // 操作类型
|
||||||
|
playerId: string; // 玩家 ID
|
||||||
|
itemId: string; // 物品 ID
|
||||||
|
quantity: number; // 数量
|
||||||
|
properties?: Record<string, unknown>; // 物品属性
|
||||||
|
reason?: string; // 原因/来源
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 操作结果
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface InventoryOperationResult {
|
||||||
|
beforeItem?: ItemData; // 操作前物品
|
||||||
|
afterItem?: ItemData; // 操作后物品
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ItemData {
|
||||||
|
itemId: string;
|
||||||
|
quantity: number;
|
||||||
|
properties?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 自定义数据提供者
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface IInventoryProvider {
|
||||||
|
getItem(playerId: string, itemId: string): Promise<ItemData | null>;
|
||||||
|
setItem(playerId: string, itemId: string, item: ItemData | null): Promise<void>;
|
||||||
|
hasCapacity?(playerId: string, count: number): Promise<boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
class MyInventoryProvider implements IInventoryProvider {
|
||||||
|
async getItem(playerId: string, itemId: string): Promise<ItemData | null> {
|
||||||
|
return await db.getItem(playerId, itemId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async setItem(playerId: string, itemId: string, item: ItemData | null): Promise<void> {
|
||||||
|
if (item) {
|
||||||
|
await db.saveItem(playerId, itemId, item);
|
||||||
|
} else {
|
||||||
|
await db.deleteItem(playerId, itemId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async hasCapacity(playerId: string, count: number): Promise<boolean> {
|
||||||
|
const current = await db.getItemCount(playerId);
|
||||||
|
const max = await db.getMaxCapacity(playerId);
|
||||||
|
return current + count <= max;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## TradeOperation
|
||||||
|
|
||||||
|
处理玩家之间的物品和货币交换。
|
||||||
|
|
||||||
|
### 基本用法
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { TradeOperation } from '@esengine/transaction';
|
||||||
|
|
||||||
|
tx.addOperation(new TradeOperation({
|
||||||
|
tradeId: 'trade_001',
|
||||||
|
partyA: {
|
||||||
|
playerId: 'player1',
|
||||||
|
items: [{ itemId: 'sword', quantity: 1 }],
|
||||||
|
currencies: [{ currency: 'diamond', amount: 10 }],
|
||||||
|
},
|
||||||
|
partyB: {
|
||||||
|
playerId: 'player2',
|
||||||
|
currencies: [{ currency: 'gold', amount: 1000 }],
|
||||||
|
},
|
||||||
|
reason: 'player_trade',
|
||||||
|
}));
|
||||||
|
```
|
||||||
|
|
||||||
|
### 操作数据
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface TradeOperationData {
|
||||||
|
tradeId: string; // 交易 ID
|
||||||
|
partyA: TradeParty; // 交易发起方
|
||||||
|
partyB: TradeParty; // 交易接收方
|
||||||
|
reason?: string; // 原因/备注
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TradeParty {
|
||||||
|
playerId: string; // 玩家 ID
|
||||||
|
items?: TradeItem[]; // 给出的物品
|
||||||
|
currencies?: TradeCurrency[]; // 给出的货币
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TradeItem {
|
||||||
|
itemId: string;
|
||||||
|
quantity: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TradeCurrency {
|
||||||
|
currency: string;
|
||||||
|
amount: number;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 执行流程
|
||||||
|
|
||||||
|
TradeOperation 内部会生成以下子操作序列:
|
||||||
|
|
||||||
|
```
|
||||||
|
1. 移除 partyA 的物品
|
||||||
|
2. 添加 partyB 的物品(来自 partyA)
|
||||||
|
3. 扣除 partyA 的货币
|
||||||
|
4. 增加 partyB 的货币(来自 partyA)
|
||||||
|
5. 移除 partyB 的物品
|
||||||
|
6. 添加 partyA 的物品(来自 partyB)
|
||||||
|
7. 扣除 partyB 的货币
|
||||||
|
8. 增加 partyA 的货币(来自 partyB)
|
||||||
|
```
|
||||||
|
|
||||||
|
任何一步失败都会回滚之前的所有操作。
|
||||||
|
|
||||||
|
### 使用自定义提供者
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const op = new TradeOperation({ ... });
|
||||||
|
op.setProvider({
|
||||||
|
currencyProvider: new MyCurrencyProvider(),
|
||||||
|
inventoryProvider: new MyInventoryProvider(),
|
||||||
|
});
|
||||||
|
tx.addOperation(op);
|
||||||
|
```
|
||||||
|
|
||||||
|
## 创建工厂函数
|
||||||
|
|
||||||
|
每个操作类都提供工厂函数:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import {
|
||||||
|
createCurrencyOperation,
|
||||||
|
createInventoryOperation,
|
||||||
|
createTradeOperation,
|
||||||
|
} from '@esengine/transaction';
|
||||||
|
|
||||||
|
tx.addOperation(createCurrencyOperation({
|
||||||
|
type: 'deduct',
|
||||||
|
playerId: 'player1',
|
||||||
|
currency: 'gold',
|
||||||
|
amount: 100,
|
||||||
|
}));
|
||||||
|
|
||||||
|
tx.addOperation(createInventoryOperation({
|
||||||
|
type: 'add',
|
||||||
|
playerId: 'player1',
|
||||||
|
itemId: 'sword',
|
||||||
|
quantity: 1,
|
||||||
|
}));
|
||||||
|
```
|
||||||
234
docs/src/content/docs/modules/transaction/storage.md
Normal file
234
docs/src/content/docs/modules/transaction/storage.md
Normal file
@@ -0,0 +1,234 @@
|
|||||||
|
---
|
||||||
|
title: "存储层"
|
||||||
|
description: "事务存储接口和实现:MemoryStorage、RedisStorage、MongoStorage"
|
||||||
|
---
|
||||||
|
|
||||||
|
## 存储接口
|
||||||
|
|
||||||
|
所有存储实现都需要实现 `ITransactionStorage` 接口:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface ITransactionStorage {
|
||||||
|
// 生命周期
|
||||||
|
close?(): Promise<void>;
|
||||||
|
|
||||||
|
// 分布式锁
|
||||||
|
acquireLock(key: string, ttl: number): Promise<string | null>;
|
||||||
|
releaseLock(key: string, token: string): Promise<boolean>;
|
||||||
|
|
||||||
|
// 事务日志
|
||||||
|
saveTransaction(tx: TransactionLog): Promise<void>;
|
||||||
|
getTransaction(id: string): Promise<TransactionLog | null>;
|
||||||
|
updateTransactionState(id: string, state: TransactionState): Promise<void>;
|
||||||
|
updateOperationState(txId: string, opIndex: number, state: string, error?: string): Promise<void>;
|
||||||
|
getPendingTransactions(serverId?: string): Promise<TransactionLog[]>;
|
||||||
|
deleteTransaction(id: string): Promise<void>;
|
||||||
|
|
||||||
|
// 数据操作
|
||||||
|
get<T>(key: string): Promise<T | null>;
|
||||||
|
set<T>(key: string, value: T, ttl?: number): Promise<void>;
|
||||||
|
delete(key: string): Promise<boolean>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## MemoryStorage
|
||||||
|
|
||||||
|
内存存储,适用于开发和测试环境。
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { MemoryStorage } from '@esengine/transaction';
|
||||||
|
|
||||||
|
const storage = new MemoryStorage({
|
||||||
|
maxTransactions: 1000, // 最大事务日志数量
|
||||||
|
});
|
||||||
|
|
||||||
|
const manager = new TransactionManager({ storage });
|
||||||
|
```
|
||||||
|
|
||||||
|
### 特点
|
||||||
|
|
||||||
|
- ✅ 无需外部依赖
|
||||||
|
- ✅ 快速,适合开发调试
|
||||||
|
- ❌ 数据仅保存在内存中
|
||||||
|
- ❌ 不支持真正的分布式锁
|
||||||
|
- ❌ 服务重启后数据丢失
|
||||||
|
|
||||||
|
### 测试辅助
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 清空所有数据
|
||||||
|
storage.clear();
|
||||||
|
|
||||||
|
// 获取事务数量
|
||||||
|
console.log(storage.transactionCount);
|
||||||
|
```
|
||||||
|
|
||||||
|
## RedisStorage
|
||||||
|
|
||||||
|
Redis 存储,适用于生产环境的分布式系统。使用工厂模式实现惰性连接。
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import Redis from 'ioredis';
|
||||||
|
import { RedisStorage } from '@esengine/transaction';
|
||||||
|
|
||||||
|
// 工厂模式:惰性连接,首次操作时才创建连接
|
||||||
|
const storage = new RedisStorage({
|
||||||
|
factory: () => new Redis('redis://localhost:6379'),
|
||||||
|
prefix: 'tx:', // 键前缀
|
||||||
|
transactionTTL: 86400, // 事务日志过期时间(秒)
|
||||||
|
});
|
||||||
|
|
||||||
|
const manager = new TransactionManager({ storage });
|
||||||
|
|
||||||
|
// 使用后关闭连接
|
||||||
|
await storage.close();
|
||||||
|
|
||||||
|
// 或使用 await using 自动关闭 (TypeScript 5.2+)
|
||||||
|
await using storage = new RedisStorage({
|
||||||
|
factory: () => new Redis('redis://localhost:6379')
|
||||||
|
});
|
||||||
|
// 作用域结束时自动关闭
|
||||||
|
```
|
||||||
|
|
||||||
|
### 特点
|
||||||
|
|
||||||
|
- ✅ 高性能分布式锁
|
||||||
|
- ✅ 快速读写
|
||||||
|
- ✅ 支持 TTL 自动过期
|
||||||
|
- ✅ 适合高并发场景
|
||||||
|
- ❌ 需要 Redis 服务器
|
||||||
|
|
||||||
|
### 分布式锁实现
|
||||||
|
|
||||||
|
使用 Redis `SET NX EX` 实现分布式锁:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 获取锁(原子操作)
|
||||||
|
SET tx:lock:player:123 <token> NX EX 10
|
||||||
|
|
||||||
|
// 释放锁(Lua 脚本保证原子性)
|
||||||
|
if redis.call("get", KEYS[1]) == ARGV[1] then
|
||||||
|
return redis.call("del", KEYS[1])
|
||||||
|
else
|
||||||
|
return 0
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
### 键结构
|
||||||
|
|
||||||
|
```
|
||||||
|
tx:lock:{key} - 分布式锁
|
||||||
|
tx:tx:{id} - 事务日志
|
||||||
|
tx:server:{id}:txs - 服务器事务索引
|
||||||
|
tx:data:{key} - 业务数据
|
||||||
|
```
|
||||||
|
|
||||||
|
## MongoStorage
|
||||||
|
|
||||||
|
MongoDB 存储,适用于需要持久化和复杂查询的场景。使用工厂模式实现惰性连接。
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { MongoClient } from 'mongodb';
|
||||||
|
import { MongoStorage } from '@esengine/transaction';
|
||||||
|
|
||||||
|
// 工厂模式:惰性连接,首次操作时才创建连接
|
||||||
|
const storage = new MongoStorage({
|
||||||
|
factory: async () => {
|
||||||
|
const client = new MongoClient('mongodb://localhost:27017');
|
||||||
|
await client.connect();
|
||||||
|
return client;
|
||||||
|
},
|
||||||
|
database: 'game',
|
||||||
|
transactionCollection: 'transactions', // 事务日志集合
|
||||||
|
dataCollection: 'transaction_data', // 业务数据集合
|
||||||
|
lockCollection: 'transaction_locks', // 锁集合
|
||||||
|
});
|
||||||
|
|
||||||
|
// 创建索引(首次运行时执行)
|
||||||
|
await storage.ensureIndexes();
|
||||||
|
|
||||||
|
const manager = new TransactionManager({ storage });
|
||||||
|
|
||||||
|
// 使用后关闭连接
|
||||||
|
await storage.close();
|
||||||
|
|
||||||
|
// 或使用 await using 自动关闭 (TypeScript 5.2+)
|
||||||
|
await using storage = new MongoStorage({ ... });
|
||||||
|
```
|
||||||
|
|
||||||
|
### 特点
|
||||||
|
|
||||||
|
- ✅ 持久化存储
|
||||||
|
- ✅ 支持复杂查询
|
||||||
|
- ✅ 事务日志可追溯
|
||||||
|
- ✅ 适合需要审计的场景
|
||||||
|
- ❌ 相比 Redis 性能略低
|
||||||
|
- ❌ 需要 MongoDB 服务器
|
||||||
|
|
||||||
|
### 索引结构
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// transactions 集合
|
||||||
|
{ state: 1 }
|
||||||
|
{ 'metadata.serverId': 1 }
|
||||||
|
{ createdAt: 1 }
|
||||||
|
|
||||||
|
// transaction_locks 集合
|
||||||
|
{ expireAt: 1 } // TTL 索引
|
||||||
|
|
||||||
|
// transaction_data 集合
|
||||||
|
{ expireAt: 1 } // TTL 索引
|
||||||
|
```
|
||||||
|
|
||||||
|
### 分布式锁实现
|
||||||
|
|
||||||
|
使用 MongoDB 唯一索引实现分布式锁:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 获取锁
|
||||||
|
db.transaction_locks.insertOne({
|
||||||
|
_id: 'player:123',
|
||||||
|
token: '<token>',
|
||||||
|
expireAt: new Date(Date.now() + 10000)
|
||||||
|
});
|
||||||
|
|
||||||
|
// 如果键已存在,检查是否过期
|
||||||
|
db.transaction_locks.updateOne(
|
||||||
|
{ _id: 'player:123', expireAt: { $lt: new Date() } },
|
||||||
|
{ $set: { token: '<token>', expireAt: new Date(Date.now() + 10000) } }
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
## 存储选择指南
|
||||||
|
|
||||||
|
| 场景 | 推荐存储 | 理由 |
|
||||||
|
|------|----------|------|
|
||||||
|
| 开发/测试 | MemoryStorage | 无依赖,快速启动 |
|
||||||
|
| 单机生产 | RedisStorage | 高性能,简单 |
|
||||||
|
| 分布式系统 | RedisStorage | 真正的分布式锁 |
|
||||||
|
| 需要审计 | MongoStorage | 持久化日志 |
|
||||||
|
| 混合需求 | Redis + Mongo | Redis 做锁,Mongo 做日志 |
|
||||||
|
|
||||||
|
## 自定义存储
|
||||||
|
|
||||||
|
实现 `ITransactionStorage` 接口创建自定义存储:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { ITransactionStorage, TransactionLog, TransactionState } from '@esengine/transaction';
|
||||||
|
|
||||||
|
class MyCustomStorage implements ITransactionStorage {
|
||||||
|
async acquireLock(key: string, ttl: number): Promise<string | null> {
|
||||||
|
// 实现分布式锁获取逻辑
|
||||||
|
}
|
||||||
|
|
||||||
|
async releaseLock(key: string, token: string): Promise<boolean> {
|
||||||
|
// 实现分布式锁释放逻辑
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveTransaction(tx: TransactionLog): Promise<void> {
|
||||||
|
// 保存事务日志
|
||||||
|
}
|
||||||
|
|
||||||
|
// ... 实现其他方法
|
||||||
|
}
|
||||||
|
```
|
||||||
167
docs/src/content/docs/modules/world-streaming/chunk-manager.md
Normal file
167
docs/src/content/docs/modules/world-streaming/chunk-manager.md
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
---
|
||||||
|
title: "区块管理器 API"
|
||||||
|
description: "ChunkManager 负责区块生命周期、加载队列和空间查询"
|
||||||
|
---
|
||||||
|
|
||||||
|
`ChunkManager` 是管理区块生命周期的核心服务,包括加载、卸载和空间查询。
|
||||||
|
|
||||||
|
## 基础用法
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { ChunkManager } from '@esengine/world-streaming';
|
||||||
|
|
||||||
|
// 创建 512 单位大小的区块管理器
|
||||||
|
const chunkManager = new ChunkManager(512);
|
||||||
|
chunkManager.setScene(scene);
|
||||||
|
|
||||||
|
// 设置数据提供器
|
||||||
|
chunkManager.setDataProvider(myProvider);
|
||||||
|
|
||||||
|
// 设置事件回调
|
||||||
|
chunkManager.setEvents({
|
||||||
|
onChunkLoaded: (coord, entities) => {
|
||||||
|
console.log(`区块 (${coord.x}, ${coord.y}) 已加载,包含 ${entities.length} 个实体`);
|
||||||
|
},
|
||||||
|
onChunkUnloaded: (coord) => {
|
||||||
|
console.log(`区块 (${coord.x}, ${coord.y}) 已卸载`);
|
||||||
|
},
|
||||||
|
onChunkLoadFailed: (coord, error) => {
|
||||||
|
console.error(`加载区块 (${coord.x}, ${coord.y}) 失败:`, error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## 加载与卸载
|
||||||
|
|
||||||
|
### 请求加载
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { EChunkPriority } from '@esengine/world-streaming';
|
||||||
|
|
||||||
|
// 按优先级请求加载
|
||||||
|
chunkManager.requestLoad({ x: 0, y: 0 }, EChunkPriority.Immediate);
|
||||||
|
chunkManager.requestLoad({ x: 1, y: 0 }, EChunkPriority.High);
|
||||||
|
chunkManager.requestLoad({ x: 2, y: 0 }, EChunkPriority.Normal);
|
||||||
|
chunkManager.requestLoad({ x: 3, y: 0 }, EChunkPriority.Low);
|
||||||
|
chunkManager.requestLoad({ x: 4, y: 0 }, EChunkPriority.Prefetch);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 优先级说明
|
||||||
|
|
||||||
|
| 优先级 | 值 | 说明 |
|
||||||
|
|--------|------|------|
|
||||||
|
| `Immediate` | 0 | 当前区块(玩家所在) |
|
||||||
|
| `High` | 1 | 相邻区块 |
|
||||||
|
| `Normal` | 2 | 附近区块 |
|
||||||
|
| `Low` | 3 | 远处可见区块 |
|
||||||
|
| `Prefetch` | 4 | 移动方向预加载 |
|
||||||
|
|
||||||
|
### 请求卸载
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 请求卸载,延迟 3 秒
|
||||||
|
chunkManager.requestUnload({ x: 5, y: 5 }, 3000);
|
||||||
|
|
||||||
|
// 取消待卸载请求(玩家返回了)
|
||||||
|
chunkManager.cancelUnload({ x: 5, y: 5 });
|
||||||
|
```
|
||||||
|
|
||||||
|
### 处理队列
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 在更新循环或系统中
|
||||||
|
await chunkManager.processLoads(2); // 每帧最多加载 2 个区块
|
||||||
|
chunkManager.processUnloads(1); // 每帧最多卸载 1 个区块
|
||||||
|
```
|
||||||
|
|
||||||
|
## 空间查询
|
||||||
|
|
||||||
|
### 坐标转换
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 世界坐标转区块坐标
|
||||||
|
const coord = chunkManager.worldToChunk(1500, 2300);
|
||||||
|
// 结果: { x: 2, y: 4 }(512单位区块)
|
||||||
|
|
||||||
|
// 获取区块世界边界
|
||||||
|
const bounds = chunkManager.getChunkBounds({ x: 2, y: 4 });
|
||||||
|
// 结果: { minX: 1024, minY: 2048, maxX: 1536, maxY: 2560 }
|
||||||
|
```
|
||||||
|
|
||||||
|
### 区块查询
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 检查区块是否已加载
|
||||||
|
if (chunkManager.isChunkLoaded({ x: 0, y: 0 })) {
|
||||||
|
const chunk = chunkManager.getChunk({ x: 0, y: 0 });
|
||||||
|
console.log('实体数量:', chunk.entities.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取半径内未加载的区块
|
||||||
|
const missing = chunkManager.getMissingChunks({ x: 0, y: 0 }, 2);
|
||||||
|
for (const coord of missing) {
|
||||||
|
chunkManager.requestLoad(coord);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取超出范围的区块(用于卸载)
|
||||||
|
const outside = chunkManager.getChunksOutsideRadius({ x: 0, y: 0 }, 4);
|
||||||
|
for (const coord of outside) {
|
||||||
|
chunkManager.requestUnload(coord, 3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 遍历所有已加载区块
|
||||||
|
chunkManager.forEachChunk((info, coord) => {
|
||||||
|
console.log(`区块 (${coord.x}, ${coord.y}): ${info.state}`);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## 统计信息
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
console.log('已加载区块:', chunkManager.loadedChunkCount);
|
||||||
|
console.log('待加载:', chunkManager.pendingLoadCount);
|
||||||
|
console.log('待卸载:', chunkManager.pendingUnloadCount);
|
||||||
|
console.log('区块大小:', chunkManager.chunkSize);
|
||||||
|
```
|
||||||
|
|
||||||
|
## 区块状态
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { EChunkState } from '@esengine/world-streaming';
|
||||||
|
|
||||||
|
// 区块生命周期状态
|
||||||
|
EChunkState.Unloaded // 未加载
|
||||||
|
EChunkState.Loading // 加载中
|
||||||
|
EChunkState.Loaded // 已加载
|
||||||
|
EChunkState.Unloading // 卸载中
|
||||||
|
EChunkState.Failed // 加载失败
|
||||||
|
```
|
||||||
|
|
||||||
|
## 数据提供器接口
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import type { IChunkDataProvider, IChunkCoord, IChunkData } from '@esengine/world-streaming';
|
||||||
|
|
||||||
|
class MyChunkProvider implements IChunkDataProvider {
|
||||||
|
async loadChunkData(coord: IChunkCoord): Promise<IChunkData | null> {
|
||||||
|
// 从数据库、文件或程序化生成加载
|
||||||
|
const data = await fetchChunkFromServer(coord);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveChunkData(data: IChunkData): Promise<void> {
|
||||||
|
// 保存修改过的区块
|
||||||
|
await saveChunkToServer(data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 清理
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 卸载所有区块
|
||||||
|
chunkManager.clear();
|
||||||
|
|
||||||
|
// 完全释放(实现 IService 接口)
|
||||||
|
chunkManager.dispose();
|
||||||
|
```
|
||||||
330
docs/src/content/docs/modules/world-streaming/examples.md
Normal file
330
docs/src/content/docs/modules/world-streaming/examples.md
Normal file
@@ -0,0 +1,330 @@
|
|||||||
|
---
|
||||||
|
title: "示例"
|
||||||
|
description: "世界流式加载实践示例"
|
||||||
|
---
|
||||||
|
|
||||||
|
## 无限程序化世界
|
||||||
|
|
||||||
|
无限大世界的程序化资源生成示例。
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import {
|
||||||
|
ChunkManager,
|
||||||
|
ChunkStreamingSystem,
|
||||||
|
ChunkLoaderComponent,
|
||||||
|
StreamingAnchorComponent
|
||||||
|
} from '@esengine/world-streaming';
|
||||||
|
import type { IChunkDataProvider, IChunkCoord, IChunkData } from '@esengine/world-streaming';
|
||||||
|
|
||||||
|
// 程序化世界生成器
|
||||||
|
class WorldGenerator implements IChunkDataProvider {
|
||||||
|
private seed: number;
|
||||||
|
private nextEntityId = 1;
|
||||||
|
|
||||||
|
constructor(seed: number = 12345) {
|
||||||
|
this.seed = seed;
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadChunkData(coord: IChunkCoord): Promise<IChunkData | null> {
|
||||||
|
const rng = this.createChunkRNG(coord);
|
||||||
|
const entities = [];
|
||||||
|
|
||||||
|
// 每区块生成 5-15 个资源
|
||||||
|
const resourceCount = 5 + Math.floor(rng() * 10);
|
||||||
|
|
||||||
|
for (let i = 0; i < resourceCount; i++) {
|
||||||
|
const type = this.randomResourceType(rng);
|
||||||
|
|
||||||
|
entities.push({
|
||||||
|
name: `Resource_${this.nextEntityId++}`,
|
||||||
|
localPosition: {
|
||||||
|
x: rng() * 512,
|
||||||
|
y: rng() * 512
|
||||||
|
},
|
||||||
|
components: {
|
||||||
|
ResourceNode: {
|
||||||
|
type,
|
||||||
|
amount: this.getResourceAmount(type, rng),
|
||||||
|
regenRate: this.getRegenRate(type)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { coord, entities, version: 1 };
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveChunkData(_data: IChunkData): Promise<void> {
|
||||||
|
// 程序化生成 - 无需持久化
|
||||||
|
}
|
||||||
|
|
||||||
|
private createChunkRNG(coord: IChunkCoord) {
|
||||||
|
let seed = this.seed ^ (coord.x * 73856093) ^ (coord.y * 19349663);
|
||||||
|
return () => {
|
||||||
|
seed = (seed * 1103515245 + 12345) & 0x7fffffff;
|
||||||
|
return seed / 0x7fffffff;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private randomResourceType(rng: () => number) {
|
||||||
|
const types = ['energyWell', 'oreVein', 'crystalDeposit'];
|
||||||
|
const weights = [0.5, 0.35, 0.15];
|
||||||
|
|
||||||
|
let random = rng();
|
||||||
|
for (let i = 0; i < types.length; i++) {
|
||||||
|
random -= weights[i];
|
||||||
|
if (random <= 0) return types[i];
|
||||||
|
}
|
||||||
|
return types[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
private getResourceAmount(type: string, rng: () => number) {
|
||||||
|
switch (type) {
|
||||||
|
case 'energyWell': return 300 + Math.floor(rng() * 200);
|
||||||
|
case 'oreVein': return 500 + Math.floor(rng() * 300);
|
||||||
|
case 'crystalDeposit': return 100 + Math.floor(rng() * 100);
|
||||||
|
default: return 100;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private getRegenRate(type: string) {
|
||||||
|
switch (type) {
|
||||||
|
case 'energyWell': return 2;
|
||||||
|
case 'oreVein': return 1;
|
||||||
|
case 'crystalDeposit': return 0.2;
|
||||||
|
default: return 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置
|
||||||
|
const chunkManager = new ChunkManager(512);
|
||||||
|
chunkManager.setScene(scene);
|
||||||
|
chunkManager.setDataProvider(new WorldGenerator(12345));
|
||||||
|
|
||||||
|
const streamingSystem = new ChunkStreamingSystem();
|
||||||
|
streamingSystem.setChunkManager(chunkManager);
|
||||||
|
scene.addSystem(streamingSystem);
|
||||||
|
```
|
||||||
|
|
||||||
|
## MMO 服务端区块
|
||||||
|
|
||||||
|
带数据库持久化的 MMO 服务端区块管理。
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
class ServerChunkProvider implements IChunkDataProvider {
|
||||||
|
private db: Database;
|
||||||
|
private cache = new Map<string, IChunkData>();
|
||||||
|
|
||||||
|
constructor(db: Database) {
|
||||||
|
this.db = db;
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadChunkData(coord: IChunkCoord): Promise<IChunkData | null> {
|
||||||
|
const key = `${coord.x},${coord.y}`;
|
||||||
|
|
||||||
|
// 检查缓存
|
||||||
|
if (this.cache.has(key)) {
|
||||||
|
return this.cache.get(key)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从数据库加载
|
||||||
|
const row = await this.db.query(
|
||||||
|
'SELECT data FROM chunks WHERE x = ? AND y = ?',
|
||||||
|
[coord.x, coord.y]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (row) {
|
||||||
|
const data = JSON.parse(row.data);
|
||||||
|
this.cache.set(key, data);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成新区块
|
||||||
|
const data = this.generateChunk(coord);
|
||||||
|
await this.saveChunkData(data);
|
||||||
|
this.cache.set(key, data);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveChunkData(data: IChunkData): Promise<void> {
|
||||||
|
const key = `${data.coord.x},${data.coord.y}`;
|
||||||
|
this.cache.set(key, data);
|
||||||
|
|
||||||
|
await this.db.query(
|
||||||
|
`INSERT INTO chunks (x, y, data) VALUES (?, ?, ?)
|
||||||
|
ON DUPLICATE KEY UPDATE data = VALUES(data)`,
|
||||||
|
[data.coord.x, data.coord.y, JSON.stringify(data)]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private generateChunk(coord: IChunkCoord): IChunkData {
|
||||||
|
// 新区块的程序化生成
|
||||||
|
return { coord, entities: [], version: 1 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 服务端按玩家加载区块
|
||||||
|
class PlayerChunkManager {
|
||||||
|
private chunkManager: ChunkManager;
|
||||||
|
private playerChunks = new Map<string, Set<string>>();
|
||||||
|
|
||||||
|
async updatePlayerPosition(playerId: string, x: number, y: number) {
|
||||||
|
const centerCoord = this.chunkManager.worldToChunk(x, y);
|
||||||
|
const loadRadius = 2;
|
||||||
|
|
||||||
|
const newChunks = new Set<string>();
|
||||||
|
|
||||||
|
// 加载玩家周围的区块
|
||||||
|
for (let dx = -loadRadius; dx <= loadRadius; dx++) {
|
||||||
|
for (let dy = -loadRadius; dy <= loadRadius; dy++) {
|
||||||
|
const coord = { x: centerCoord.x + dx, y: centerCoord.y + dy };
|
||||||
|
const key = `${coord.x},${coord.y}`;
|
||||||
|
newChunks.add(key);
|
||||||
|
|
||||||
|
if (!this.chunkManager.isChunkLoaded(coord)) {
|
||||||
|
await this.chunkManager.requestLoad(coord);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 记录玩家已加载的区块
|
||||||
|
this.playerChunks.set(playerId, newChunks);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 瓦片地图世界
|
||||||
|
|
||||||
|
瓦片地图与区块流式加载集成。
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { TilemapComponent } from '@esengine/tilemap';
|
||||||
|
|
||||||
|
class TilemapChunkProvider implements IChunkDataProvider {
|
||||||
|
private tilemapData: number[][]; // 完整瓦片地图
|
||||||
|
private tileSize = 32;
|
||||||
|
private chunkTiles = 16; // 每区块 16x16 瓦片
|
||||||
|
|
||||||
|
async loadChunkData(coord: IChunkCoord): Promise<IChunkData | null> {
|
||||||
|
const startTileX = coord.x * this.chunkTiles;
|
||||||
|
const startTileY = coord.y * this.chunkTiles;
|
||||||
|
|
||||||
|
// 提取此区块的瓦片
|
||||||
|
const tiles: number[][] = [];
|
||||||
|
for (let y = 0; y < this.chunkTiles; y++) {
|
||||||
|
const row: number[] = [];
|
||||||
|
for (let x = 0; x < this.chunkTiles; x++) {
|
||||||
|
const tileX = startTileX + x;
|
||||||
|
const tileY = startTileY + y;
|
||||||
|
row.push(this.getTile(tileX, tileY));
|
||||||
|
}
|
||||||
|
tiles.push(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
coord,
|
||||||
|
entities: [{
|
||||||
|
name: `TileChunk_${coord.x}_${coord.y}`,
|
||||||
|
localPosition: { x: 0, y: 0 },
|
||||||
|
components: {
|
||||||
|
TilemapChunk: { tiles }
|
||||||
|
}
|
||||||
|
}],
|
||||||
|
version: 1
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private getTile(x: number, y: number): number {
|
||||||
|
if (x < 0 || y < 0 || y >= this.tilemapData.length) {
|
||||||
|
return 0; // 超出边界 = 空
|
||||||
|
}
|
||||||
|
return this.tilemapData[y]?.[x] ?? 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 瓦片地图自定义序列化器
|
||||||
|
class TilemapSerializer extends ChunkSerializer {
|
||||||
|
protected deserializeComponents(entity: Entity, components: Record<string, unknown>): void {
|
||||||
|
if (components.TilemapChunk) {
|
||||||
|
const data = components.TilemapChunk as { tiles: number[][] };
|
||||||
|
const tilemap = entity.addComponent(new TilemapComponent());
|
||||||
|
tilemap.loadTiles(data.tiles);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 动态加载事件
|
||||||
|
|
||||||
|
响应区块加载用于游戏逻辑。
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
chunkManager.setEvents({
|
||||||
|
onChunkLoaded: (coord, entities) => {
|
||||||
|
// 启用物理
|
||||||
|
for (const entity of entities) {
|
||||||
|
const collider = entity.getComponent(ColliderComponent);
|
||||||
|
collider?.enable();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 为已加载区块生成 NPC
|
||||||
|
npcManager.spawnForChunk(coord);
|
||||||
|
|
||||||
|
// 更新战争迷雾
|
||||||
|
fogOfWar.revealChunk(coord);
|
||||||
|
|
||||||
|
// 通知客户端(服务端)
|
||||||
|
broadcast('ChunkLoaded', { coord, entityCount: entities.length });
|
||||||
|
},
|
||||||
|
|
||||||
|
onChunkUnloaded: (coord) => {
|
||||||
|
// 保存 NPC 状态
|
||||||
|
npcManager.saveAndRemoveForChunk(coord);
|
||||||
|
|
||||||
|
// 更新迷雾
|
||||||
|
fogOfWar.hideChunk(coord);
|
||||||
|
|
||||||
|
// 通知客户端
|
||||||
|
broadcast('ChunkUnloaded', { coord });
|
||||||
|
},
|
||||||
|
|
||||||
|
onChunkLoadFailed: (coord, error) => {
|
||||||
|
console.error(`加载区块 ${coord.x},${coord.y} 失败:`, error);
|
||||||
|
|
||||||
|
// 延迟后重试
|
||||||
|
setTimeout(() => {
|
||||||
|
chunkManager.requestLoad(coord);
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## 性能优化
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 根据设备性能调整
|
||||||
|
function configureForDevice(loader: ChunkLoaderComponent) {
|
||||||
|
const memory = navigator.deviceMemory ?? 4;
|
||||||
|
const cores = navigator.hardwareConcurrency ?? 4;
|
||||||
|
|
||||||
|
if (memory <= 2 || cores <= 2) {
|
||||||
|
// 低端设备
|
||||||
|
loader.loadRadius = 1;
|
||||||
|
loader.unloadRadius = 2;
|
||||||
|
loader.maxLoadsPerFrame = 1;
|
||||||
|
loader.bEnablePrefetch = false;
|
||||||
|
} else if (memory <= 4) {
|
||||||
|
// 中端设备
|
||||||
|
loader.loadRadius = 2;
|
||||||
|
loader.unloadRadius = 3;
|
||||||
|
loader.maxLoadsPerFrame = 2;
|
||||||
|
} else {
|
||||||
|
// 高端设备
|
||||||
|
loader.loadRadius = 3;
|
||||||
|
loader.unloadRadius = 5;
|
||||||
|
loader.maxLoadsPerFrame = 4;
|
||||||
|
loader.prefetchRadius = 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
158
docs/src/content/docs/modules/world-streaming/index.md
Normal file
158
docs/src/content/docs/modules/world-streaming/index.md
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
---
|
||||||
|
title: "世界流式加载"
|
||||||
|
description: "基于区块的开放世界流式加载系统"
|
||||||
|
---
|
||||||
|
|
||||||
|
`@esengine/world-streaming` 提供基于区块的世界流式加载与管理,适用于开放世界游戏。根据玩家位置动态加载/卸载世界区块。
|
||||||
|
|
||||||
|
## 安装
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install @esengine/world-streaming
|
||||||
|
```
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
|
### 基础设置
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import {
|
||||||
|
ChunkManager,
|
||||||
|
ChunkStreamingSystem,
|
||||||
|
StreamingAnchorComponent,
|
||||||
|
ChunkLoaderComponent
|
||||||
|
} from '@esengine/world-streaming';
|
||||||
|
|
||||||
|
// 创建区块管理器 (512单位区块)
|
||||||
|
const chunkManager = new ChunkManager(512);
|
||||||
|
chunkManager.setScene(scene);
|
||||||
|
|
||||||
|
// 添加流式加载系统
|
||||||
|
const streamingSystem = new ChunkStreamingSystem();
|
||||||
|
streamingSystem.setChunkManager(chunkManager);
|
||||||
|
scene.addSystem(streamingSystem);
|
||||||
|
|
||||||
|
// 创建加载器实体
|
||||||
|
const loaderEntity = scene.createEntity('ChunkLoader');
|
||||||
|
const loader = loaderEntity.addComponent(new ChunkLoaderComponent());
|
||||||
|
loader.chunkSize = 512;
|
||||||
|
loader.loadRadius = 2;
|
||||||
|
loader.unloadRadius = 4;
|
||||||
|
|
||||||
|
// 创建玩家作为流式锚点
|
||||||
|
const playerEntity = scene.createEntity('Player');
|
||||||
|
const anchor = playerEntity.addComponent(new StreamingAnchorComponent());
|
||||||
|
|
||||||
|
// 每帧更新锚点位置
|
||||||
|
function update() {
|
||||||
|
anchor.x = player.position.x;
|
||||||
|
anchor.y = player.position.y;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 程序化生成
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import type { IChunkDataProvider, IChunkCoord, IChunkData } from '@esengine/world-streaming';
|
||||||
|
|
||||||
|
class ProceduralChunkProvider implements IChunkDataProvider {
|
||||||
|
private seed: number;
|
||||||
|
|
||||||
|
constructor(seed: number) {
|
||||||
|
this.seed = seed;
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadChunkData(coord: IChunkCoord): Promise<IChunkData | null> {
|
||||||
|
// 使用种子+坐标生成确定性随机数
|
||||||
|
const chunkSeed = this.hashCoord(coord);
|
||||||
|
const rng = this.createRNG(chunkSeed);
|
||||||
|
|
||||||
|
// 生成区块内容
|
||||||
|
const entities = this.generateEntities(coord, rng);
|
||||||
|
|
||||||
|
return {
|
||||||
|
coord,
|
||||||
|
entities,
|
||||||
|
version: 1
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveChunkData(data: IChunkData): Promise<void> {
|
||||||
|
// 可选:持久化已修改的区块
|
||||||
|
}
|
||||||
|
|
||||||
|
private hashCoord(coord: IChunkCoord): number {
|
||||||
|
return this.seed ^ (coord.x * 73856093) ^ (coord.y * 19349663);
|
||||||
|
}
|
||||||
|
|
||||||
|
private createRNG(seed: number) {
|
||||||
|
// 简单的种子随机数生成器
|
||||||
|
return () => {
|
||||||
|
seed = (seed * 1103515245 + 12345) & 0x7fffffff;
|
||||||
|
return seed / 0x7fffffff;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private generateEntities(coord: IChunkCoord, rng: () => number) {
|
||||||
|
// 生成资源、树木等
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用数据提供器
|
||||||
|
chunkManager.setDataProvider(new ProceduralChunkProvider(12345));
|
||||||
|
```
|
||||||
|
|
||||||
|
## 核心概念
|
||||||
|
|
||||||
|
### 区块生命周期
|
||||||
|
|
||||||
|
```
|
||||||
|
未加载 → 加载中 → 已加载 → 卸载中 → 未加载
|
||||||
|
↓ ↓
|
||||||
|
失败 (发生错误时)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 流式锚点
|
||||||
|
|
||||||
|
`StreamingAnchorComponent` 用于标记作为区块加载锚点的实体。系统会在所有锚点周围加载区块,在超出范围时卸载区块。
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// StreamingAnchorComponent 实现 IPositionable 接口
|
||||||
|
interface IPositionable {
|
||||||
|
readonly position: { x: number; y: number };
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 配置参数
|
||||||
|
|
||||||
|
| 属性 | 默认值 | 说明 |
|
||||||
|
|------|--------|------|
|
||||||
|
| `chunkSize` | 512 | 区块大小(世界单位) |
|
||||||
|
| `loadRadius` | 2 | 锚点周围加载的区块半径 |
|
||||||
|
| `unloadRadius` | 4 | 超过此半径的区块会被卸载 |
|
||||||
|
| `maxLoadsPerFrame` | 2 | 每帧最大异步加载数 |
|
||||||
|
| `unloadDelay` | 3000 | 卸载前的延迟(毫秒) |
|
||||||
|
| `bEnablePrefetch` | true | 沿移动方向预加载 |
|
||||||
|
|
||||||
|
## 模块设置(可选)
|
||||||
|
|
||||||
|
使用模块辅助函数快速配置:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { worldStreamingModule } from '@esengine/world-streaming';
|
||||||
|
|
||||||
|
const chunkManager = worldStreamingModule.setup(
|
||||||
|
scene,
|
||||||
|
services,
|
||||||
|
componentRegistry,
|
||||||
|
{ chunkSize: 256, bEnableCulling: true }
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
## 文档
|
||||||
|
|
||||||
|
- [区块管理器 API](./chunk-manager) - 加载队列、区块生命周期
|
||||||
|
- [流式系统](./streaming-system) - 基于锚点的加载
|
||||||
|
- [序列化](./serialization) - 自定义区块序列化
|
||||||
|
- [示例](./examples) - 程序化世界、MMO 区块
|
||||||
227
docs/src/content/docs/modules/world-streaming/serialization.md
Normal file
227
docs/src/content/docs/modules/world-streaming/serialization.md
Normal file
@@ -0,0 +1,227 @@
|
|||||||
|
---
|
||||||
|
title: "区块序列化"
|
||||||
|
description: "自定义区块数据序列化"
|
||||||
|
---
|
||||||
|
|
||||||
|
`ChunkSerializer` 负责实体数据与区块存储格式之间的转换。
|
||||||
|
|
||||||
|
## 默认序列化器
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { ChunkSerializer, ChunkManager } from '@esengine/world-streaming';
|
||||||
|
|
||||||
|
const serializer = new ChunkSerializer();
|
||||||
|
const chunkManager = new ChunkManager(512, serializer);
|
||||||
|
```
|
||||||
|
|
||||||
|
## 自定义序列化器
|
||||||
|
|
||||||
|
继承 `ChunkSerializer` 实现自定义序列化逻辑:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { ChunkSerializer } from '@esengine/world-streaming';
|
||||||
|
import type { Entity, IScene } from '@esengine/ecs-framework';
|
||||||
|
import type { IChunkCoord, IChunkData, IChunkBounds } from '@esengine/world-streaming';
|
||||||
|
|
||||||
|
class GameChunkSerializer extends ChunkSerializer {
|
||||||
|
/**
|
||||||
|
* 获取实体位置
|
||||||
|
* 重写以使用你的位置组件
|
||||||
|
*/
|
||||||
|
protected getPositionable(entity: Entity) {
|
||||||
|
const transform = entity.getComponent(TransformComponent);
|
||||||
|
if (transform) {
|
||||||
|
return { position: { x: transform.x, y: transform.y } };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 反序列化后设置实体位置
|
||||||
|
*/
|
||||||
|
protected setEntityPosition(entity: Entity, x: number, y: number): void {
|
||||||
|
const transform = entity.addComponent(new TransformComponent());
|
||||||
|
transform.x = x;
|
||||||
|
transform.y = y;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 序列化组件
|
||||||
|
*/
|
||||||
|
protected serializeComponents(entity: Entity): Record<string, unknown> {
|
||||||
|
const data: Record<string, unknown> = {};
|
||||||
|
|
||||||
|
const resource = entity.getComponent(ResourceComponent);
|
||||||
|
if (resource) {
|
||||||
|
data.ResourceComponent = {
|
||||||
|
type: resource.type,
|
||||||
|
amount: resource.amount,
|
||||||
|
maxAmount: resource.maxAmount
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const npc = entity.getComponent(NPCComponent);
|
||||||
|
if (npc) {
|
||||||
|
data.NPCComponent = {
|
||||||
|
id: npc.id,
|
||||||
|
state: npc.state
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 反序列化组件
|
||||||
|
*/
|
||||||
|
protected deserializeComponents(entity: Entity, components: Record<string, unknown>): void {
|
||||||
|
if (components.ResourceComponent) {
|
||||||
|
const data = components.ResourceComponent as any;
|
||||||
|
const resource = entity.addComponent(new ResourceComponent());
|
||||||
|
resource.type = data.type;
|
||||||
|
resource.amount = data.amount;
|
||||||
|
resource.maxAmount = data.maxAmount;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (components.NPCComponent) {
|
||||||
|
const data = components.NPCComponent as any;
|
||||||
|
const npc = entity.addComponent(new NPCComponent());
|
||||||
|
npc.id = data.id;
|
||||||
|
npc.state = data.state;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 过滤需要序列化的组件
|
||||||
|
*/
|
||||||
|
protected shouldSerializeComponent(componentName: string): boolean {
|
||||||
|
const include = ['ResourceComponent', 'NPCComponent', 'BuildingComponent'];
|
||||||
|
return include.includes(componentName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 区块数据格式
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface IChunkData {
|
||||||
|
coord: IChunkCoord; // 区块坐标
|
||||||
|
entities: ISerializedEntity[]; // 实体数据
|
||||||
|
version: number; // 数据版本
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ISerializedEntity {
|
||||||
|
name: string; // 实体名称
|
||||||
|
localPosition: { x: number; y: number }; // 区块内位置
|
||||||
|
components: Record<string, unknown>; // 组件数据
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IChunkCoord {
|
||||||
|
x: number; // 区块 X 坐标
|
||||||
|
y: number; // 区块 Y 坐标
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 带序列化的数据提供器
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
class DatabaseChunkProvider implements IChunkDataProvider {
|
||||||
|
async loadChunkData(coord: IChunkCoord): Promise<IChunkData | null> {
|
||||||
|
const key = `chunk_${coord.x}_${coord.y}`;
|
||||||
|
const json = await database.get(key);
|
||||||
|
|
||||||
|
if (!json) return null;
|
||||||
|
return JSON.parse(json) as IChunkData;
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveChunkData(data: IChunkData): Promise<void> {
|
||||||
|
const key = `chunk_${data.coord.x}_${data.coord.y}`;
|
||||||
|
await database.set(key, JSON.stringify(data));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 程序化生成与序列化器
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
class ProceduralProvider implements IChunkDataProvider {
|
||||||
|
private serializer: GameChunkSerializer;
|
||||||
|
|
||||||
|
async loadChunkData(coord: IChunkCoord): Promise<IChunkData | null> {
|
||||||
|
const entities = this.generateEntities(coord);
|
||||||
|
|
||||||
|
return {
|
||||||
|
coord,
|
||||||
|
entities,
|
||||||
|
version: 1
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private generateEntities(coord: IChunkCoord): ISerializedEntity[] {
|
||||||
|
const entities: ISerializedEntity[] = [];
|
||||||
|
const rng = this.createRNG(coord);
|
||||||
|
|
||||||
|
// 生成树木
|
||||||
|
const treeCount = Math.floor(rng() * 10);
|
||||||
|
for (let i = 0; i < treeCount; i++) {
|
||||||
|
entities.push({
|
||||||
|
name: `Tree_${coord.x}_${coord.y}_${i}`,
|
||||||
|
localPosition: {
|
||||||
|
x: rng() * 512,
|
||||||
|
y: rng() * 512
|
||||||
|
},
|
||||||
|
components: {
|
||||||
|
TreeComponent: { type: 'oak', health: 100 }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成资源
|
||||||
|
if (rng() > 0.7) {
|
||||||
|
entities.push({
|
||||||
|
name: `Resource_${coord.x}_${coord.y}`,
|
||||||
|
localPosition: { x: 256, y: 256 },
|
||||||
|
components: {
|
||||||
|
ResourceComponent: {
|
||||||
|
type: 'iron',
|
||||||
|
amount: 500,
|
||||||
|
maxAmount: 500
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return entities;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 版本迁移
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
class VersionedSerializer extends ChunkSerializer {
|
||||||
|
private static readonly CURRENT_VERSION = 2;
|
||||||
|
|
||||||
|
deserialize(data: IChunkData, scene: IScene): Entity[] {
|
||||||
|
// 迁移旧数据
|
||||||
|
if (data.version < 2) {
|
||||||
|
data = this.migrateV1toV2(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
return super.deserialize(data, scene);
|
||||||
|
}
|
||||||
|
|
||||||
|
private migrateV1toV2(data: IChunkData): IChunkData {
|
||||||
|
// 转换旧组件格式
|
||||||
|
for (const entity of data.entities) {
|
||||||
|
if (entity.components.OldResource) {
|
||||||
|
entity.components.ResourceComponent = entity.components.OldResource;
|
||||||
|
delete entity.components.OldResource;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data.version = 2;
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
---
|
||||||
|
title: "流式加载系统"
|
||||||
|
description: "ChunkStreamingSystem 根据锚点位置自动管理区块加载"
|
||||||
|
---
|
||||||
|
|
||||||
|
`ChunkStreamingSystem` 根据 `StreamingAnchorComponent` 的位置自动管理区块的加载和卸载。
|
||||||
|
|
||||||
|
## 设置
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import {
|
||||||
|
ChunkManager,
|
||||||
|
ChunkStreamingSystem,
|
||||||
|
ChunkLoaderComponent,
|
||||||
|
StreamingAnchorComponent
|
||||||
|
} from '@esengine/world-streaming';
|
||||||
|
|
||||||
|
// 创建并配置区块管理器
|
||||||
|
const chunkManager = new ChunkManager(512);
|
||||||
|
chunkManager.setScene(scene);
|
||||||
|
chunkManager.setDataProvider(myProvider);
|
||||||
|
|
||||||
|
// 创建流式系统
|
||||||
|
const streamingSystem = new ChunkStreamingSystem();
|
||||||
|
streamingSystem.setChunkManager(chunkManager);
|
||||||
|
scene.addSystem(streamingSystem);
|
||||||
|
|
||||||
|
// 创建加载器实体
|
||||||
|
const loaderEntity = scene.createEntity('ChunkLoader');
|
||||||
|
const loader = loaderEntity.addComponent(new ChunkLoaderComponent());
|
||||||
|
loader.chunkSize = 512;
|
||||||
|
loader.loadRadius = 2;
|
||||||
|
loader.unloadRadius = 4;
|
||||||
|
```
|
||||||
|
|
||||||
|
## 流式锚点
|
||||||
|
|
||||||
|
`StreamingAnchorComponent` 标记实体为区块加载锚点。区块会在所有锚点周围加载。
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 创建玩家作为流式锚点
|
||||||
|
const playerEntity = scene.createEntity('Player');
|
||||||
|
const anchor = playerEntity.addComponent(new StreamingAnchorComponent());
|
||||||
|
|
||||||
|
// 每帧更新位置
|
||||||
|
function update() {
|
||||||
|
anchor.x = player.worldX;
|
||||||
|
anchor.y = player.worldY;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 锚点属性
|
||||||
|
|
||||||
|
| 属性 | 类型 | 默认值 | 说明 |
|
||||||
|
|------|------|--------|------|
|
||||||
|
| `x` | number | 0 | 世界 X 坐标 |
|
||||||
|
| `y` | number | 0 | 世界 Y 坐标 |
|
||||||
|
| `weight` | number | 1.0 | 加载半径倍数 |
|
||||||
|
| `bEnablePrefetch` | boolean | true | 是否启用预加载 |
|
||||||
|
|
||||||
|
### 多锚点
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 主玩家 - 完整加载半径
|
||||||
|
const playerAnchor = player.addComponent(new StreamingAnchorComponent());
|
||||||
|
playerAnchor.weight = 1.0;
|
||||||
|
|
||||||
|
// 相机预览 - 较小半径
|
||||||
|
const cameraAnchor = camera.addComponent(new StreamingAnchorComponent());
|
||||||
|
cameraAnchor.weight = 0.5; // 加载半径减半
|
||||||
|
cameraAnchor.bEnablePrefetch = false;
|
||||||
|
```
|
||||||
|
|
||||||
|
## 加载器配置
|
||||||
|
|
||||||
|
`ChunkLoaderComponent` 配置流式加载行为。
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const loader = entity.addComponent(new ChunkLoaderComponent());
|
||||||
|
|
||||||
|
// 区块尺寸
|
||||||
|
loader.chunkSize = 512; // 每区块世界单位
|
||||||
|
|
||||||
|
// 加载半径
|
||||||
|
loader.loadRadius = 2; // 锚点周围 2 个区块内加载
|
||||||
|
loader.unloadRadius = 4; // 超过 4 个区块卸载
|
||||||
|
|
||||||
|
// 性能调优
|
||||||
|
loader.maxLoadsPerFrame = 2; // 每帧最大异步加载数
|
||||||
|
loader.maxUnloadsPerFrame = 1; // 每帧最大卸载数
|
||||||
|
loader.unloadDelay = 3000; // 卸载前延迟(毫秒)
|
||||||
|
|
||||||
|
// 预加载
|
||||||
|
loader.bEnablePrefetch = true; // 启用移动方向预加载
|
||||||
|
loader.prefetchRadius = 1; // 预加载额外区块数
|
||||||
|
```
|
||||||
|
|
||||||
|
### 坐标辅助方法
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 世界坐标转区块坐标
|
||||||
|
const coord = loader.worldToChunk(1500, 2300);
|
||||||
|
|
||||||
|
// 获取区块边界
|
||||||
|
const bounds = loader.getChunkBounds(coord);
|
||||||
|
```
|
||||||
|
|
||||||
|
## 预加载系统
|
||||||
|
|
||||||
|
启用后,系统会沿移动方向预加载区块:
|
||||||
|
|
||||||
|
```
|
||||||
|
移动方向 →
|
||||||
|
|
||||||
|
[ ][ ][ ] [ ][P][P] P = 预加载
|
||||||
|
[L][L][L] → [L][L][L] L = 已加载
|
||||||
|
[ ][ ][ ] [ ][ ][ ]
|
||||||
|
```
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 启用预加载
|
||||||
|
loader.bEnablePrefetch = true;
|
||||||
|
loader.prefetchRadius = 2; // 向前预加载 2 个区块
|
||||||
|
|
||||||
|
// 单独控制锚点的预加载
|
||||||
|
anchor.bEnablePrefetch = true; // 主玩家启用
|
||||||
|
cameraAnchor.bEnablePrefetch = false; // 相机禁用
|
||||||
|
```
|
||||||
|
|
||||||
|
## 系统处理流程
|
||||||
|
|
||||||
|
系统每帧运行:
|
||||||
|
|
||||||
|
1. 更新锚点速度
|
||||||
|
2. 请求加载范围内的区块
|
||||||
|
3. 取消已回到范围内的区块卸载
|
||||||
|
4. 请求卸载超出范围的区块
|
||||||
|
5. 处理加载/卸载队列
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 从系统访问区块管理器
|
||||||
|
const system = scene.getSystem(ChunkStreamingSystem);
|
||||||
|
const manager = system?.chunkManager;
|
||||||
|
|
||||||
|
if (manager) {
|
||||||
|
console.log('已加载:', manager.loadedChunkCount);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 基于优先级的加载
|
||||||
|
|
||||||
|
区块按距离分配加载优先级:
|
||||||
|
|
||||||
|
| 距离 | 优先级 | 说明 |
|
||||||
|
|------|--------|------|
|
||||||
|
| 0 | Immediate | 玩家当前区块 |
|
||||||
|
| 1 | High | 相邻区块 |
|
||||||
|
| 2-4 | Normal | 附近区块 |
|
||||||
|
| 5+ | Low | 远处区块 |
|
||||||
|
| 预加载 | Prefetch | 移动方向 |
|
||||||
|
|
||||||
|
## 事件
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
chunkManager.setEvents({
|
||||||
|
onChunkLoaded: (coord, entities) => {
|
||||||
|
// 区块就绪 - 生成 NPC,启用碰撞
|
||||||
|
for (const entity of entities) {
|
||||||
|
entity.getComponent(ColliderComponent)?.enable();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onChunkUnloaded: (coord) => {
|
||||||
|
// 清理 - 保存状态,释放资源
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
@@ -1,5 +1,47 @@
|
|||||||
# @esengine/network
|
# @esengine/network
|
||||||
|
|
||||||
|
## 2.2.0
|
||||||
|
|
||||||
|
### Minor Changes
|
||||||
|
|
||||||
|
- [#379](https://github.com/esengine/esengine/pull/379) [`fb8bde6`](https://github.com/esengine/esengine/commit/fb8bde64856ef71ea8e20906496682ccfb27f9b3) Thanks [@esengine](https://github.com/esengine)! - feat(network): 网络模块增强
|
||||||
|
|
||||||
|
### 新增功能
|
||||||
|
- **客户端预测 (NetworkPredictionSystem)**
|
||||||
|
- 本地输入预测和服务器校正
|
||||||
|
- 平滑的校正偏移应用
|
||||||
|
- 可配置移动速度、校正阈值等
|
||||||
|
- **兴趣区域管理 (NetworkAOISystem)**
|
||||||
|
- 基于网格的 AOI 实现
|
||||||
|
- 观察者进入/离开事件
|
||||||
|
- 同步数据过滤
|
||||||
|
- **状态增量压缩 (StateDeltaCompressor)**
|
||||||
|
- 只发送变化的字段
|
||||||
|
- 可配置变化阈值
|
||||||
|
- 定期完整快照
|
||||||
|
- **断线重连**
|
||||||
|
- 自动重连机制
|
||||||
|
- Token 认证
|
||||||
|
- 完整状态恢复
|
||||||
|
|
||||||
|
### 协议增强
|
||||||
|
- 添加输入序列号和时间戳
|
||||||
|
- 添加速度和角速度字段
|
||||||
|
- 添加自定义数据字段
|
||||||
|
- 新增重连协议
|
||||||
|
|
||||||
|
### 文档
|
||||||
|
- 添加客户端预测文档(中英文)
|
||||||
|
- 添加 AOI 文档(中英文)
|
||||||
|
- 添加增量压缩文档(中英文)
|
||||||
|
|
||||||
|
## 2.1.1
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- Updated dependencies [[`a000cc0`](https://github.com/esengine/esengine/commit/a000cc07d7cebe8ccbfa983fde610296bfba2f1b)]:
|
||||||
|
- @esengine/rpc@1.1.1
|
||||||
|
|
||||||
## 2.1.0
|
## 2.1.0
|
||||||
|
|
||||||
### Minor Changes
|
### Minor Changes
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@esengine/network",
|
"name": "@esengine/network",
|
||||||
"version": "2.1.0",
|
"version": "2.2.0",
|
||||||
"description": "Network synchronization for multiplayer games",
|
"description": "Network synchronization for multiplayer games",
|
||||||
"esengine": {
|
"esengine": {
|
||||||
"plugin": true,
|
"plugin": true,
|
||||||
|
|||||||
@@ -1,13 +1,126 @@
|
|||||||
/**
|
/**
|
||||||
* @zh 网络插件
|
* @zh 网络插件
|
||||||
* @en Network Plugin
|
* @en Network Plugin
|
||||||
|
*
|
||||||
|
* @zh 提供基于 @esengine/rpc 的网络同步功能,支持客户端预测和断线重连
|
||||||
|
* @en Provides @esengine/rpc based network synchronization with client prediction and reconnection
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { type IPlugin, Core, type ServiceContainer, type Scene } from '@esengine/ecs-framework'
|
import { type IPlugin, Core, type ServiceContainer, type Scene } from '@esengine/ecs-framework'
|
||||||
import { GameNetworkService, type NetworkServiceOptions } from './services/NetworkService'
|
import {
|
||||||
import { NetworkSyncSystem } from './systems/NetworkSyncSystem'
|
GameNetworkService,
|
||||||
|
type NetworkServiceOptions,
|
||||||
|
NetworkState,
|
||||||
|
} from './services/NetworkService'
|
||||||
|
import { NetworkSyncSystem, type NetworkSyncConfig } from './systems/NetworkSyncSystem'
|
||||||
import { NetworkSpawnSystem, type PrefabFactory } from './systems/NetworkSpawnSystem'
|
import { NetworkSpawnSystem, type PrefabFactory } from './systems/NetworkSpawnSystem'
|
||||||
import { NetworkInputSystem } from './systems/NetworkInputSystem'
|
import { NetworkInputSystem, type NetworkInputConfig } from './systems/NetworkInputSystem'
|
||||||
|
import {
|
||||||
|
NetworkPredictionSystem,
|
||||||
|
type NetworkPredictionConfig,
|
||||||
|
} from './systems/NetworkPredictionSystem'
|
||||||
|
import {
|
||||||
|
NetworkAOISystem,
|
||||||
|
type NetworkAOIConfig,
|
||||||
|
} from './systems/NetworkAOISystem'
|
||||||
|
import type { FullStateData, SyncData } from './protocol'
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Types | 类型定义
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 网络插件配置
|
||||||
|
* @en Network plugin configuration
|
||||||
|
*/
|
||||||
|
export interface NetworkPluginConfig {
|
||||||
|
/**
|
||||||
|
* @zh 是否启用客户端预测
|
||||||
|
* @en Whether to enable client prediction
|
||||||
|
*/
|
||||||
|
enablePrediction: boolean
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 是否启用自动重连
|
||||||
|
* @en Whether to enable auto reconnection
|
||||||
|
*/
|
||||||
|
enableAutoReconnect: boolean
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 重连最大尝试次数
|
||||||
|
* @en Maximum reconnection attempts
|
||||||
|
*/
|
||||||
|
maxReconnectAttempts: number
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 重连间隔(毫秒)
|
||||||
|
* @en Reconnection interval in milliseconds
|
||||||
|
*/
|
||||||
|
reconnectInterval: number
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 同步系统配置
|
||||||
|
* @en Sync system configuration
|
||||||
|
*/
|
||||||
|
syncConfig?: Partial<NetworkSyncConfig>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 输入系统配置
|
||||||
|
* @en Input system configuration
|
||||||
|
*/
|
||||||
|
inputConfig?: Partial<NetworkInputConfig>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 预测系统配置
|
||||||
|
* @en Prediction system configuration
|
||||||
|
*/
|
||||||
|
predictionConfig?: Partial<NetworkPredictionConfig>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 是否启用 AOI 兴趣管理
|
||||||
|
* @en Whether to enable AOI interest management
|
||||||
|
*/
|
||||||
|
enableAOI: boolean
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh AOI 系统配置
|
||||||
|
* @en AOI system configuration
|
||||||
|
*/
|
||||||
|
aoiConfig?: Partial<NetworkAOIConfig>
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_CONFIG: NetworkPluginConfig = {
|
||||||
|
enablePrediction: true,
|
||||||
|
enableAutoReconnect: true,
|
||||||
|
maxReconnectAttempts: 5,
|
||||||
|
reconnectInterval: 2000,
|
||||||
|
enableAOI: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 连接选项
|
||||||
|
* @en Connection options
|
||||||
|
*/
|
||||||
|
export interface ConnectOptions extends NetworkServiceOptions {
|
||||||
|
playerName: string
|
||||||
|
roomId?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 重连状态
|
||||||
|
* @en Reconnection state
|
||||||
|
*/
|
||||||
|
interface ReconnectState {
|
||||||
|
token: string
|
||||||
|
playerId: number
|
||||||
|
roomId: string
|
||||||
|
attempts: number
|
||||||
|
isReconnecting: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// NetworkPlugin | 网络插件
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @zh 网络插件
|
* @zh 网络插件
|
||||||
@@ -21,7 +134,10 @@ import { NetworkInputSystem } from './systems/NetworkInputSystem'
|
|||||||
* import { Core } from '@esengine/ecs-framework'
|
* import { Core } from '@esengine/ecs-framework'
|
||||||
* import { NetworkPlugin } from '@esengine/network'
|
* import { NetworkPlugin } from '@esengine/network'
|
||||||
*
|
*
|
||||||
* const networkPlugin = new NetworkPlugin()
|
* const networkPlugin = new NetworkPlugin({
|
||||||
|
* enablePrediction: true,
|
||||||
|
* enableAutoReconnect: true
|
||||||
|
* })
|
||||||
* await Core.installPlugin(networkPlugin)
|
* await Core.installPlugin(networkPlugin)
|
||||||
*
|
*
|
||||||
* // 连接到服务器
|
* // 连接到服务器
|
||||||
@@ -36,13 +152,28 @@ import { NetworkInputSystem } from './systems/NetworkInputSystem'
|
|||||||
*/
|
*/
|
||||||
export class NetworkPlugin implements IPlugin {
|
export class NetworkPlugin implements IPlugin {
|
||||||
public readonly name = '@esengine/network'
|
public readonly name = '@esengine/network'
|
||||||
public readonly version = '2.0.0'
|
public readonly version = '2.1.0'
|
||||||
|
|
||||||
|
private readonly _config: NetworkPluginConfig
|
||||||
private _networkService!: GameNetworkService
|
private _networkService!: GameNetworkService
|
||||||
private _syncSystem!: NetworkSyncSystem
|
private _syncSystem!: NetworkSyncSystem
|
||||||
private _spawnSystem!: NetworkSpawnSystem
|
private _spawnSystem!: NetworkSpawnSystem
|
||||||
private _inputSystem!: NetworkInputSystem
|
private _inputSystem!: NetworkInputSystem
|
||||||
|
private _predictionSystem: NetworkPredictionSystem | null = null
|
||||||
|
private _aoiSystem: NetworkAOISystem | null = null
|
||||||
|
|
||||||
private _localPlayerId: number = 0
|
private _localPlayerId: number = 0
|
||||||
|
private _reconnectState: ReconnectState | null = null
|
||||||
|
private _reconnectTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
private _lastConnectOptions: ConnectOptions | null = null
|
||||||
|
|
||||||
|
constructor(config?: Partial<NetworkPluginConfig>) {
|
||||||
|
this._config = { ...DEFAULT_CONFIG, ...config }
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Getters | 属性访问器
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @zh 网络服务
|
* @zh 网络服务
|
||||||
@@ -76,6 +207,22 @@ export class NetworkPlugin implements IPlugin {
|
|||||||
return this._inputSystem
|
return this._inputSystem
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 预测系统
|
||||||
|
* @en Prediction system
|
||||||
|
*/
|
||||||
|
get predictionSystem(): NetworkPredictionSystem | null {
|
||||||
|
return this._predictionSystem
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh AOI 系统
|
||||||
|
* @en AOI system
|
||||||
|
*/
|
||||||
|
get aoiSystem(): NetworkAOISystem | null {
|
||||||
|
return this._aoiSystem
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @zh 本地玩家 ID
|
* @zh 本地玩家 ID
|
||||||
* @en Local player ID
|
* @en Local player ID
|
||||||
@@ -92,6 +239,34 @@ export class NetworkPlugin implements IPlugin {
|
|||||||
return this._networkService?.isConnected ?? false
|
return this._networkService?.isConnected ?? false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 是否正在重连
|
||||||
|
* @en Is reconnecting
|
||||||
|
*/
|
||||||
|
get isReconnecting(): boolean {
|
||||||
|
return this._reconnectState?.isReconnecting ?? false
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 是否启用预测
|
||||||
|
* @en Is prediction enabled
|
||||||
|
*/
|
||||||
|
get isPredictionEnabled(): boolean {
|
||||||
|
return this._config.enablePrediction && this._predictionSystem !== null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 是否启用 AOI
|
||||||
|
* @en Is AOI enabled
|
||||||
|
*/
|
||||||
|
get isAOIEnabled(): boolean {
|
||||||
|
return this._config.enableAOI && this._aoiSystem !== null
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Plugin Lifecycle | 插件生命周期
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @zh 安装插件
|
* @zh 安装插件
|
||||||
* @en Install plugin
|
* @en Install plugin
|
||||||
@@ -110,13 +285,28 @@ export class NetworkPlugin implements IPlugin {
|
|||||||
* @en Uninstall plugin
|
* @en Uninstall plugin
|
||||||
*/
|
*/
|
||||||
uninstall(): void {
|
uninstall(): void {
|
||||||
|
this._clearReconnectTimer()
|
||||||
this._networkService?.disconnect()
|
this._networkService?.disconnect()
|
||||||
}
|
}
|
||||||
|
|
||||||
private _setupSystems(scene: Scene): void {
|
private _setupSystems(scene: Scene): void {
|
||||||
this._syncSystem = new NetworkSyncSystem()
|
// Create systems
|
||||||
|
this._syncSystem = new NetworkSyncSystem(this._config.syncConfig)
|
||||||
this._spawnSystem = new NetworkSpawnSystem(this._syncSystem)
|
this._spawnSystem = new NetworkSpawnSystem(this._syncSystem)
|
||||||
this._inputSystem = new NetworkInputSystem(this._networkService)
|
this._inputSystem = new NetworkInputSystem(this._networkService, this._config.inputConfig)
|
||||||
|
|
||||||
|
// Create prediction system if enabled
|
||||||
|
if (this._config.enablePrediction) {
|
||||||
|
this._predictionSystem = new NetworkPredictionSystem(this._config.predictionConfig)
|
||||||
|
this._inputSystem.setPredictionSystem(this._predictionSystem)
|
||||||
|
scene.addSystem(this._predictionSystem)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create AOI system if enabled
|
||||||
|
if (this._config.enableAOI) {
|
||||||
|
this._aoiSystem = new NetworkAOISystem(this._config.aoiConfig)
|
||||||
|
scene.addSystem(this._aoiSystem)
|
||||||
|
}
|
||||||
|
|
||||||
scene.addSystem(this._syncSystem)
|
scene.addSystem(this._syncSystem)
|
||||||
scene.addSystem(this._spawnSystem)
|
scene.addSystem(this._spawnSystem)
|
||||||
@@ -127,8 +317,14 @@ export class NetworkPlugin implements IPlugin {
|
|||||||
|
|
||||||
private _setupMessageHandlers(): void {
|
private _setupMessageHandlers(): void {
|
||||||
this._networkService
|
this._networkService
|
||||||
.onSync((data) => {
|
.onSync((data: SyncData) => {
|
||||||
this._syncSystem.handleSync({ entities: data.entities })
|
// Use new sync handler with timestamps
|
||||||
|
this._syncSystem.handleSyncData(data)
|
||||||
|
|
||||||
|
// Reconcile prediction if enabled
|
||||||
|
if (this._predictionSystem) {
|
||||||
|
this._predictionSystem.reconcileWithServer(data)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.onSpawn((data) => {
|
.onSpawn((data) => {
|
||||||
this._spawnSystem.handleSpawn(data)
|
this._spawnSystem.handleSpawn(data)
|
||||||
@@ -136,14 +332,32 @@ export class NetworkPlugin implements IPlugin {
|
|||||||
.onDespawn((data) => {
|
.onDespawn((data) => {
|
||||||
this._spawnSystem.handleDespawn(data)
|
this._spawnSystem.handleDespawn(data)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Handle full state for reconnection
|
||||||
|
this._networkService.on('fullState', (data: FullStateData) => {
|
||||||
|
this._handleFullState(data)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Connection | 连接管理
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @zh 连接到服务器
|
* @zh 连接到服务器
|
||||||
* @en Connect to server
|
* @en Connect to server
|
||||||
*/
|
*/
|
||||||
public async connect(options: NetworkServiceOptions & { playerName: string; roomId?: string }): Promise<boolean> {
|
public async connect(options: ConnectOptions): Promise<boolean> {
|
||||||
|
this._lastConnectOptions = options
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// Setup disconnect handler for auto-reconnect
|
||||||
|
const originalOnDisconnect = options.onDisconnect
|
||||||
|
options.onDisconnect = (reason) => {
|
||||||
|
originalOnDisconnect?.(reason)
|
||||||
|
this._handleDisconnect(reason)
|
||||||
|
}
|
||||||
|
|
||||||
await this._networkService.connect(options)
|
await this._networkService.connect(options)
|
||||||
|
|
||||||
const result = await this._networkService.call('join', {
|
const result = await this._networkService.call('join', {
|
||||||
@@ -154,8 +368,25 @@ export class NetworkPlugin implements IPlugin {
|
|||||||
this._localPlayerId = result.playerId
|
this._localPlayerId = result.playerId
|
||||||
this._spawnSystem.setLocalPlayerId(this._localPlayerId)
|
this._spawnSystem.setLocalPlayerId(this._localPlayerId)
|
||||||
|
|
||||||
|
// Setup prediction for local player
|
||||||
|
if (this._predictionSystem) {
|
||||||
|
// Will be set when local player entity is spawned
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save reconnect state
|
||||||
|
if (this._config.enableAutoReconnect) {
|
||||||
|
this._reconnectState = {
|
||||||
|
token: this._generateReconnectToken(),
|
||||||
|
playerId: result.playerId,
|
||||||
|
roomId: result.roomId,
|
||||||
|
attempts: 0,
|
||||||
|
isReconnecting: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return true
|
return true
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
console.error('[NetworkPlugin] Connection failed:', err)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -165,14 +396,114 @@ export class NetworkPlugin implements IPlugin {
|
|||||||
* @en Disconnect
|
* @en Disconnect
|
||||||
*/
|
*/
|
||||||
public async disconnect(): Promise<void> {
|
public async disconnect(): Promise<void> {
|
||||||
|
this._clearReconnectTimer()
|
||||||
|
this._reconnectState = null
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this._networkService.call('leave', undefined)
|
await this._networkService.call('leave', undefined)
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
this._networkService.disconnect()
|
this._networkService.disconnect()
|
||||||
|
this._cleanup()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private _handleDisconnect(reason?: string): void {
|
||||||
|
console.log('[NetworkPlugin] Disconnected:', reason)
|
||||||
|
|
||||||
|
if (this._config.enableAutoReconnect && this._reconnectState && !this._reconnectState.isReconnecting) {
|
||||||
|
this._attemptReconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private _attemptReconnect(): void {
|
||||||
|
if (!this._reconnectState || !this._lastConnectOptions) return
|
||||||
|
|
||||||
|
if (this._reconnectState.attempts >= this._config.maxReconnectAttempts) {
|
||||||
|
console.error('[NetworkPlugin] Max reconnection attempts reached')
|
||||||
|
this._reconnectState = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this._reconnectState.isReconnecting = true
|
||||||
|
this._reconnectState.attempts++
|
||||||
|
|
||||||
|
console.log(`[NetworkPlugin] Attempting reconnection (${this._reconnectState.attempts}/${this._config.maxReconnectAttempts})`)
|
||||||
|
|
||||||
|
this._reconnectTimer = setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
await this._networkService.connect(this._lastConnectOptions!)
|
||||||
|
|
||||||
|
const result = await this._networkService.call('reconnect', {
|
||||||
|
playerId: this._reconnectState!.playerId,
|
||||||
|
roomId: this._reconnectState!.roomId,
|
||||||
|
token: this._reconnectState!.token,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
console.log('[NetworkPlugin] Reconnection successful')
|
||||||
|
this._reconnectState!.isReconnecting = false
|
||||||
|
this._reconnectState!.attempts = 0
|
||||||
|
|
||||||
|
// Restore state
|
||||||
|
if (result.state) {
|
||||||
|
this._handleFullState(result.state)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.error('[NetworkPlugin] Reconnection rejected:', result.error)
|
||||||
|
this._attemptReconnect()
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[NetworkPlugin] Reconnection failed:', err)
|
||||||
|
if (this._reconnectState) {
|
||||||
|
this._reconnectState.isReconnecting = false
|
||||||
|
}
|
||||||
|
this._attemptReconnect()
|
||||||
|
}
|
||||||
|
}, this._config.reconnectInterval)
|
||||||
|
}
|
||||||
|
|
||||||
|
private _handleFullState(data: FullStateData): void {
|
||||||
|
// Clear existing entities
|
||||||
|
this._syncSystem.clearSnapshots()
|
||||||
|
|
||||||
|
// Spawn all entities from full state
|
||||||
|
for (const entityData of data.entities) {
|
||||||
|
this._spawnSystem.handleSpawn(entityData)
|
||||||
|
|
||||||
|
// Apply initial state if available
|
||||||
|
if (entityData.state) {
|
||||||
|
this._syncSystem.handleSyncData({
|
||||||
|
frame: data.frame,
|
||||||
|
timestamp: data.timestamp,
|
||||||
|
entities: [entityData.state],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private _clearReconnectTimer(): void {
|
||||||
|
if (this._reconnectTimer) {
|
||||||
|
clearTimeout(this._reconnectTimer)
|
||||||
|
this._reconnectTimer = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private _generateReconnectToken(): string {
|
||||||
|
return `${Date.now()}-${Math.random().toString(36).substring(2, 15)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
private _cleanup(): void {
|
||||||
|
this._localPlayerId = 0
|
||||||
|
this._syncSystem?.clearSnapshots()
|
||||||
|
this._predictionSystem?.reset()
|
||||||
|
this._inputSystem?.reset()
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Game API | 游戏接口
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @zh 注册预制体工厂
|
* @zh 注册预制体工厂
|
||||||
* @en Register prefab factory
|
* @en Register prefab factory
|
||||||
@@ -196,4 +527,78 @@ export class NetworkPlugin implements IPlugin {
|
|||||||
public sendActionInput(action: string): void {
|
public sendActionInput(action: string): void {
|
||||||
this._inputSystem?.addActionInput(action)
|
this._inputSystem?.addActionInput(action)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 设置本地玩家网络 ID(用于预测)
|
||||||
|
* @en Set local player network ID (for prediction)
|
||||||
|
*/
|
||||||
|
public setLocalPlayerNetId(netId: number): void {
|
||||||
|
if (this._predictionSystem) {
|
||||||
|
this._predictionSystem.setLocalPlayerNetId(netId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 启用/禁用预测
|
||||||
|
* @en Enable/disable prediction
|
||||||
|
*/
|
||||||
|
public setPredictionEnabled(enabled: boolean): void {
|
||||||
|
if (this._predictionSystem) {
|
||||||
|
this._predictionSystem.enabled = enabled
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// AOI API | AOI 接口
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 添加 AOI 观察者(玩家)
|
||||||
|
* @en Add AOI observer (player)
|
||||||
|
*/
|
||||||
|
public addAOIObserver(netId: number, x: number, y: number, viewRange?: number): void {
|
||||||
|
this._aoiSystem?.addObserver(netId, x, y, viewRange)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 移除 AOI 观察者
|
||||||
|
* @en Remove AOI observer
|
||||||
|
*/
|
||||||
|
public removeAOIObserver(netId: number): void {
|
||||||
|
this._aoiSystem?.removeObserver(netId)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 更新 AOI 观察者位置
|
||||||
|
* @en Update AOI observer position
|
||||||
|
*/
|
||||||
|
public updateAOIObserverPosition(netId: number, x: number, y: number): void {
|
||||||
|
this._aoiSystem?.updateObserverPosition(netId, x, y)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 获取观察者可见的实体
|
||||||
|
* @en Get entities visible to observer
|
||||||
|
*/
|
||||||
|
public getVisibleEntities(observerNetId: number): number[] {
|
||||||
|
return this._aoiSystem?.getVisibleEntities(observerNetId) ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 检查是否可见
|
||||||
|
* @en Check if visible
|
||||||
|
*/
|
||||||
|
public canSee(observerNetId: number, targetNetId: number): boolean {
|
||||||
|
return this._aoiSystem?.canSee(observerNetId, targetNetId) ?? true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 启用/禁用 AOI
|
||||||
|
* @en Enable/disable AOI
|
||||||
|
*/
|
||||||
|
public setAOIEnabled(enabled: boolean): void {
|
||||||
|
if (this._aoiSystem) {
|
||||||
|
this._aoiSystem.enabled = enabled
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,8 +35,11 @@ export {
|
|||||||
type SyncData,
|
type SyncData,
|
||||||
type SpawnData,
|
type SpawnData,
|
||||||
type DespawnData,
|
type DespawnData,
|
||||||
|
type FullStateData,
|
||||||
type JoinRequest,
|
type JoinRequest,
|
||||||
type JoinResponse,
|
type JoinResponse,
|
||||||
|
type ReconnectRequest,
|
||||||
|
type ReconnectResponse,
|
||||||
} from './protocol'
|
} from './protocol'
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -48,6 +51,8 @@ export {
|
|||||||
NetworkSyncSystemToken,
|
NetworkSyncSystemToken,
|
||||||
NetworkSpawnSystemToken,
|
NetworkSpawnSystemToken,
|
||||||
NetworkInputSystemToken,
|
NetworkInputSystemToken,
|
||||||
|
NetworkPredictionSystemToken,
|
||||||
|
NetworkAOISystemToken,
|
||||||
} from './tokens'
|
} from './tokens'
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -81,10 +86,30 @@ export { NetworkTransform } from './components/NetworkTransform'
|
|||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
export { NetworkSyncSystem } from './systems/NetworkSyncSystem'
|
export { NetworkSyncSystem } from './systems/NetworkSyncSystem'
|
||||||
export type { SyncMessage } from './systems/NetworkSyncSystem'
|
export type { SyncMessage, NetworkSyncConfig } from './systems/NetworkSyncSystem'
|
||||||
export { NetworkSpawnSystem } from './systems/NetworkSpawnSystem'
|
export { NetworkSpawnSystem } from './systems/NetworkSpawnSystem'
|
||||||
export type { PrefabFactory, SpawnMessage, DespawnMessage } from './systems/NetworkSpawnSystem'
|
export type { PrefabFactory, SpawnMessage, DespawnMessage } from './systems/NetworkSpawnSystem'
|
||||||
export { NetworkInputSystem } from './systems/NetworkInputSystem'
|
export { NetworkInputSystem, createNetworkInputSystem } from './systems/NetworkInputSystem'
|
||||||
|
export type { NetworkInputConfig } from './systems/NetworkInputSystem'
|
||||||
|
export {
|
||||||
|
NetworkPredictionSystem,
|
||||||
|
createNetworkPredictionSystem,
|
||||||
|
} from './systems/NetworkPredictionSystem'
|
||||||
|
export type {
|
||||||
|
NetworkPredictionConfig,
|
||||||
|
MovementInput,
|
||||||
|
PredictedTransform,
|
||||||
|
} from './systems/NetworkPredictionSystem'
|
||||||
|
export {
|
||||||
|
NetworkAOISystem,
|
||||||
|
createNetworkAOISystem,
|
||||||
|
} from './systems/NetworkAOISystem'
|
||||||
|
export type {
|
||||||
|
NetworkAOIConfig,
|
||||||
|
NetworkAOIEvent,
|
||||||
|
NetworkAOIEventType,
|
||||||
|
NetworkAOIEventListener,
|
||||||
|
} from './systems/NetworkAOISystem'
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// State Sync | 状态同步
|
// State Sync | 状态同步
|
||||||
@@ -105,6 +130,9 @@ export type {
|
|||||||
IPredictedState,
|
IPredictedState,
|
||||||
IPredictor,
|
IPredictor,
|
||||||
ClientPredictionConfig,
|
ClientPredictionConfig,
|
||||||
|
EntityDeltaState,
|
||||||
|
DeltaSyncData,
|
||||||
|
DeltaCompressionConfig,
|
||||||
} from './sync'
|
} from './sync'
|
||||||
|
|
||||||
export {
|
export {
|
||||||
@@ -119,6 +147,9 @@ export {
|
|||||||
createHermiteTransformInterpolator,
|
createHermiteTransformInterpolator,
|
||||||
ClientPrediction,
|
ClientPrediction,
|
||||||
createClientPrediction,
|
createClientPrediction,
|
||||||
|
DeltaFlags,
|
||||||
|
StateDeltaCompressor,
|
||||||
|
createStateDeltaCompressor,
|
||||||
} from './sync'
|
} from './sync'
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
@@ -17,12 +17,24 @@ import { rpc } from '@esengine/rpc'
|
|||||||
* @en Player input
|
* @en Player input
|
||||||
*/
|
*/
|
||||||
export interface PlayerInput {
|
export interface PlayerInput {
|
||||||
|
/**
|
||||||
|
* @zh 输入序列号(用于客户端预测)
|
||||||
|
* @en Input sequence number (for client prediction)
|
||||||
|
*/
|
||||||
|
seq: number
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @zh 帧序号
|
* @zh 帧序号
|
||||||
* @en Frame number
|
* @en Frame number
|
||||||
*/
|
*/
|
||||||
frame: number
|
frame: number
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 客户端时间戳
|
||||||
|
* @en Client timestamp
|
||||||
|
*/
|
||||||
|
timestamp: number
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @zh 移动方向
|
* @zh 移动方向
|
||||||
* @en Move direction
|
* @en Move direction
|
||||||
@@ -41,9 +53,41 @@ export interface PlayerInput {
|
|||||||
* @en Entity sync state
|
* @en Entity sync state
|
||||||
*/
|
*/
|
||||||
export interface EntitySyncState {
|
export interface EntitySyncState {
|
||||||
|
/**
|
||||||
|
* @zh 网络实体 ID
|
||||||
|
* @en Network entity ID
|
||||||
|
*/
|
||||||
netId: number
|
netId: number
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 位置
|
||||||
|
* @en Position
|
||||||
|
*/
|
||||||
pos?: { x: number; y: number }
|
pos?: { x: number; y: number }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 旋转角度
|
||||||
|
* @en Rotation angle
|
||||||
|
*/
|
||||||
rot?: number
|
rot?: number
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 速度(用于外推)
|
||||||
|
* @en Velocity (for extrapolation)
|
||||||
|
*/
|
||||||
|
vel?: { x: number; y: number }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 角速度
|
||||||
|
* @en Angular velocity
|
||||||
|
*/
|
||||||
|
angVel?: number
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 自定义数据
|
||||||
|
* @en Custom data
|
||||||
|
*/
|
||||||
|
custom?: Record<string, unknown>
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -57,6 +101,18 @@ export interface SyncData {
|
|||||||
*/
|
*/
|
||||||
frame: number
|
frame: number
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 服务器时间戳(用于插值)
|
||||||
|
* @en Server timestamp (for interpolation)
|
||||||
|
*/
|
||||||
|
timestamp: number
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 已确认的输入序列号(用于客户端预测校正)
|
||||||
|
* @en Acknowledged input sequence (for client prediction reconciliation)
|
||||||
|
*/
|
||||||
|
ackSeq?: number
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @zh 实体状态列表
|
* @zh 实体状态列表
|
||||||
* @en Entity state list
|
* @en Entity state list
|
||||||
@@ -84,6 +140,30 @@ export interface DespawnData {
|
|||||||
netId: number
|
netId: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 完整状态快照(用于重连)
|
||||||
|
* @en Full state snapshot (for reconnection)
|
||||||
|
*/
|
||||||
|
export interface FullStateData {
|
||||||
|
/**
|
||||||
|
* @zh 服务器帧号
|
||||||
|
* @en Server frame number
|
||||||
|
*/
|
||||||
|
frame: number
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 服务器时间戳
|
||||||
|
* @en Server timestamp
|
||||||
|
*/
|
||||||
|
timestamp: number
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 所有实体状态
|
||||||
|
* @en All entity states
|
||||||
|
*/
|
||||||
|
entities: Array<SpawnData & { state?: EntitySyncState }>
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// API Types | API 类型
|
// API Types | API 类型
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -106,6 +186,54 @@ export interface JoinResponse {
|
|||||||
roomId: string
|
roomId: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 重连请求
|
||||||
|
* @en Reconnect request
|
||||||
|
*/
|
||||||
|
export interface ReconnectRequest {
|
||||||
|
/**
|
||||||
|
* @zh 之前的玩家 ID
|
||||||
|
* @en Previous player ID
|
||||||
|
*/
|
||||||
|
playerId: number
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 房间 ID
|
||||||
|
* @en Room ID
|
||||||
|
*/
|
||||||
|
roomId: string
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 重连令牌
|
||||||
|
* @en Reconnection token
|
||||||
|
*/
|
||||||
|
token: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 重连响应
|
||||||
|
* @en Reconnect response
|
||||||
|
*/
|
||||||
|
export interface ReconnectResponse {
|
||||||
|
/**
|
||||||
|
* @zh 是否成功
|
||||||
|
* @en Whether successful
|
||||||
|
*/
|
||||||
|
success: boolean
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 完整状态(成功时)
|
||||||
|
* @en Full state (when successful)
|
||||||
|
*/
|
||||||
|
state?: FullStateData
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 错误信息(失败时)
|
||||||
|
* @en Error message (when failed)
|
||||||
|
*/
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Protocol Definition | 协议定义
|
// Protocol Definition | 协议定义
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -145,6 +273,12 @@ export const gameProtocol = rpc.define({
|
|||||||
* @en Leave room
|
* @en Leave room
|
||||||
*/
|
*/
|
||||||
leave: rpc.api<void, void>(),
|
leave: rpc.api<void, void>(),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 重连
|
||||||
|
* @en Reconnect
|
||||||
|
*/
|
||||||
|
reconnect: rpc.api<ReconnectRequest, ReconnectResponse>(),
|
||||||
},
|
},
|
||||||
msg: {
|
msg: {
|
||||||
/**
|
/**
|
||||||
@@ -170,6 +304,12 @@ export const gameProtocol = rpc.define({
|
|||||||
* @en Entity despawn
|
* @en Entity despawn
|
||||||
*/
|
*/
|
||||||
despawn: rpc.msg<DespawnData>(),
|
despawn: rpc.msg<DespawnData>(),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 完整状态快照
|
||||||
|
* @en Full state snapshot
|
||||||
|
*/
|
||||||
|
fullState: rpc.msg<FullStateData>(),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
440
packages/framework/network/src/sync/StateDelta.ts
Normal file
440
packages/framework/network/src/sync/StateDelta.ts
Normal file
@@ -0,0 +1,440 @@
|
|||||||
|
/**
|
||||||
|
* @zh 状态增量压缩
|
||||||
|
* @en State Delta Compression
|
||||||
|
*
|
||||||
|
* @zh 通过只发送变化的字段来减少网络带宽
|
||||||
|
* @en Reduces network bandwidth by only sending changed fields
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { EntitySyncState, SyncData } from '../protocol'
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Types | 类型定义
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 增量类型标志
|
||||||
|
* @en Delta type flags
|
||||||
|
*/
|
||||||
|
export const DeltaFlags = {
|
||||||
|
NONE: 0,
|
||||||
|
POSITION: 1 << 0,
|
||||||
|
ROTATION: 1 << 1,
|
||||||
|
VELOCITY: 1 << 2,
|
||||||
|
ANGULAR_VELOCITY: 1 << 3,
|
||||||
|
CUSTOM: 1 << 4,
|
||||||
|
} as const
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 增量状态(只包含变化的字段)
|
||||||
|
* @en Delta state (only contains changed fields)
|
||||||
|
*/
|
||||||
|
export interface EntityDeltaState {
|
||||||
|
/**
|
||||||
|
* @zh 网络标识
|
||||||
|
* @en Network identity
|
||||||
|
*/
|
||||||
|
netId: number
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 变化标志
|
||||||
|
* @en Change flags
|
||||||
|
*/
|
||||||
|
flags: number
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 位置(如果变化)
|
||||||
|
* @en Position (if changed)
|
||||||
|
*/
|
||||||
|
pos?: { x: number; y: number }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 旋转(如果变化)
|
||||||
|
* @en Rotation (if changed)
|
||||||
|
*/
|
||||||
|
rot?: number
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 速度(如果变化)
|
||||||
|
* @en Velocity (if changed)
|
||||||
|
*/
|
||||||
|
vel?: { x: number; y: number }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 角速度(如果变化)
|
||||||
|
* @en Angular velocity (if changed)
|
||||||
|
*/
|
||||||
|
angVel?: number
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 自定义数据(如果变化)
|
||||||
|
* @en Custom data (if changed)
|
||||||
|
*/
|
||||||
|
custom?: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 增量同步数据
|
||||||
|
* @en Delta sync data
|
||||||
|
*/
|
||||||
|
export interface DeltaSyncData {
|
||||||
|
/**
|
||||||
|
* @zh 帧号
|
||||||
|
* @en Frame number
|
||||||
|
*/
|
||||||
|
frame: number
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 时间戳
|
||||||
|
* @en Timestamp
|
||||||
|
*/
|
||||||
|
timestamp: number
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 已确认的输入序列号
|
||||||
|
* @en Acknowledged input sequence
|
||||||
|
*/
|
||||||
|
ackSeq?: number
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 增量实体状态
|
||||||
|
* @en Delta entity states
|
||||||
|
*/
|
||||||
|
entities: EntityDeltaState[]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 是否为完整快照
|
||||||
|
* @en Whether this is a full snapshot
|
||||||
|
*/
|
||||||
|
isFullSnapshot?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 增量压缩配置
|
||||||
|
* @en Delta compression configuration
|
||||||
|
*/
|
||||||
|
export interface DeltaCompressionConfig {
|
||||||
|
/**
|
||||||
|
* @zh 位置变化阈值
|
||||||
|
* @en Position change threshold
|
||||||
|
*/
|
||||||
|
positionThreshold: number
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 旋转变化阈值(弧度)
|
||||||
|
* @en Rotation change threshold (radians)
|
||||||
|
*/
|
||||||
|
rotationThreshold: number
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 速度变化阈值
|
||||||
|
* @en Velocity change threshold
|
||||||
|
*/
|
||||||
|
velocityThreshold: number
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 强制完整快照间隔(帧数)
|
||||||
|
* @en Forced full snapshot interval (frames)
|
||||||
|
*/
|
||||||
|
fullSnapshotInterval: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_CONFIG: DeltaCompressionConfig = {
|
||||||
|
positionThreshold: 0.01,
|
||||||
|
rotationThreshold: 0.001,
|
||||||
|
velocityThreshold: 0.1,
|
||||||
|
fullSnapshotInterval: 60,
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// StateDeltaCompressor | 状态增量压缩器
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 状态增量压缩器
|
||||||
|
* @en State delta compressor
|
||||||
|
*
|
||||||
|
* @zh 追踪实体状态变化,生成增量更新
|
||||||
|
* @en Tracks entity state changes and generates delta updates
|
||||||
|
*/
|
||||||
|
export class StateDeltaCompressor {
|
||||||
|
private readonly _config: DeltaCompressionConfig
|
||||||
|
private readonly _lastStates: Map<number, EntitySyncState> = new Map()
|
||||||
|
private _frameCounter: number = 0
|
||||||
|
|
||||||
|
constructor(config?: Partial<DeltaCompressionConfig>) {
|
||||||
|
this._config = { ...DEFAULT_CONFIG, ...config }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 获取配置
|
||||||
|
* @en Get configuration
|
||||||
|
*/
|
||||||
|
get config(): Readonly<DeltaCompressionConfig> {
|
||||||
|
return this._config
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 压缩同步数据为增量格式
|
||||||
|
* @en Compress sync data to delta format
|
||||||
|
*/
|
||||||
|
compress(data: SyncData): DeltaSyncData {
|
||||||
|
this._frameCounter++
|
||||||
|
|
||||||
|
const isFullSnapshot = this._frameCounter % this._config.fullSnapshotInterval === 0
|
||||||
|
const deltaEntities: EntityDeltaState[] = []
|
||||||
|
|
||||||
|
for (const entity of data.entities) {
|
||||||
|
const lastState = this._lastStates.get(entity.netId)
|
||||||
|
|
||||||
|
if (isFullSnapshot || !lastState) {
|
||||||
|
// Send full state
|
||||||
|
deltaEntities.push(this._createFullDelta(entity))
|
||||||
|
} else {
|
||||||
|
// Calculate delta
|
||||||
|
const delta = this._calculateDelta(lastState, entity)
|
||||||
|
if (delta) {
|
||||||
|
deltaEntities.push(delta)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update last state
|
||||||
|
this._lastStates.set(entity.netId, { ...entity })
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
frame: data.frame,
|
||||||
|
timestamp: data.timestamp,
|
||||||
|
ackSeq: data.ackSeq,
|
||||||
|
entities: deltaEntities,
|
||||||
|
isFullSnapshot,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 解压增量数据为完整同步数据
|
||||||
|
* @en Decompress delta data to full sync data
|
||||||
|
*/
|
||||||
|
decompress(data: DeltaSyncData): SyncData {
|
||||||
|
const entities: EntitySyncState[] = []
|
||||||
|
|
||||||
|
for (const delta of data.entities) {
|
||||||
|
const lastState = this._lastStates.get(delta.netId)
|
||||||
|
const fullState = this._applyDelta(lastState, delta)
|
||||||
|
entities.push(fullState)
|
||||||
|
|
||||||
|
// Update last state
|
||||||
|
this._lastStates.set(delta.netId, fullState)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
frame: data.frame,
|
||||||
|
timestamp: data.timestamp,
|
||||||
|
ackSeq: data.ackSeq,
|
||||||
|
entities,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 移除实体状态
|
||||||
|
* @en Remove entity state
|
||||||
|
*/
|
||||||
|
removeEntity(netId: number): void {
|
||||||
|
this._lastStates.delete(netId)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 清除所有状态
|
||||||
|
* @en Clear all states
|
||||||
|
*/
|
||||||
|
clear(): void {
|
||||||
|
this._lastStates.clear()
|
||||||
|
this._frameCounter = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 强制下一次发送完整快照
|
||||||
|
* @en Force next send to be a full snapshot
|
||||||
|
*/
|
||||||
|
forceFullSnapshot(): void {
|
||||||
|
this._frameCounter = this._config.fullSnapshotInterval - 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// 私有方法 | Private Methods
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
private _createFullDelta(entity: EntitySyncState): EntityDeltaState {
|
||||||
|
let flags = 0
|
||||||
|
|
||||||
|
if (entity.pos) flags |= DeltaFlags.POSITION
|
||||||
|
if (entity.rot !== undefined) flags |= DeltaFlags.ROTATION
|
||||||
|
if (entity.vel) flags |= DeltaFlags.VELOCITY
|
||||||
|
if (entity.angVel !== undefined) flags |= DeltaFlags.ANGULAR_VELOCITY
|
||||||
|
if (entity.custom) flags |= DeltaFlags.CUSTOM
|
||||||
|
|
||||||
|
return {
|
||||||
|
netId: entity.netId,
|
||||||
|
flags,
|
||||||
|
pos: entity.pos,
|
||||||
|
rot: entity.rot,
|
||||||
|
vel: entity.vel,
|
||||||
|
angVel: entity.angVel,
|
||||||
|
custom: entity.custom,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private _calculateDelta(
|
||||||
|
lastState: EntitySyncState,
|
||||||
|
currentState: EntitySyncState
|
||||||
|
): EntityDeltaState | null {
|
||||||
|
let flags = 0
|
||||||
|
const delta: EntityDeltaState = {
|
||||||
|
netId: currentState.netId,
|
||||||
|
flags: 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check position change
|
||||||
|
if (currentState.pos) {
|
||||||
|
const posChanged = !lastState.pos ||
|
||||||
|
Math.abs(currentState.pos.x - lastState.pos.x) > this._config.positionThreshold ||
|
||||||
|
Math.abs(currentState.pos.y - lastState.pos.y) > this._config.positionThreshold
|
||||||
|
|
||||||
|
if (posChanged) {
|
||||||
|
flags |= DeltaFlags.POSITION
|
||||||
|
delta.pos = currentState.pos
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check rotation change
|
||||||
|
if (currentState.rot !== undefined) {
|
||||||
|
const rotChanged = lastState.rot === undefined ||
|
||||||
|
Math.abs(currentState.rot - lastState.rot) > this._config.rotationThreshold
|
||||||
|
|
||||||
|
if (rotChanged) {
|
||||||
|
flags |= DeltaFlags.ROTATION
|
||||||
|
delta.rot = currentState.rot
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check velocity change
|
||||||
|
if (currentState.vel) {
|
||||||
|
const velChanged = !lastState.vel ||
|
||||||
|
Math.abs(currentState.vel.x - lastState.vel.x) > this._config.velocityThreshold ||
|
||||||
|
Math.abs(currentState.vel.y - lastState.vel.y) > this._config.velocityThreshold
|
||||||
|
|
||||||
|
if (velChanged) {
|
||||||
|
flags |= DeltaFlags.VELOCITY
|
||||||
|
delta.vel = currentState.vel
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check angular velocity change
|
||||||
|
if (currentState.angVel !== undefined) {
|
||||||
|
const angVelChanged = lastState.angVel === undefined ||
|
||||||
|
Math.abs(currentState.angVel - lastState.angVel) > this._config.velocityThreshold
|
||||||
|
|
||||||
|
if (angVelChanged) {
|
||||||
|
flags |= DeltaFlags.ANGULAR_VELOCITY
|
||||||
|
delta.angVel = currentState.angVel
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check custom data change (simple reference comparison)
|
||||||
|
if (currentState.custom) {
|
||||||
|
const customChanged = !this._customDataEqual(lastState.custom, currentState.custom)
|
||||||
|
|
||||||
|
if (customChanged) {
|
||||||
|
flags |= DeltaFlags.CUSTOM
|
||||||
|
delta.custom = currentState.custom
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return null if no changes
|
||||||
|
if (flags === 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
delta.flags = flags
|
||||||
|
return delta
|
||||||
|
}
|
||||||
|
|
||||||
|
private _applyDelta(
|
||||||
|
lastState: EntitySyncState | undefined,
|
||||||
|
delta: EntityDeltaState
|
||||||
|
): EntitySyncState {
|
||||||
|
const state: EntitySyncState = {
|
||||||
|
netId: delta.netId,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply position
|
||||||
|
if (delta.flags & DeltaFlags.POSITION) {
|
||||||
|
state.pos = delta.pos
|
||||||
|
} else if (lastState?.pos) {
|
||||||
|
state.pos = lastState.pos
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply rotation
|
||||||
|
if (delta.flags & DeltaFlags.ROTATION) {
|
||||||
|
state.rot = delta.rot
|
||||||
|
} else if (lastState?.rot !== undefined) {
|
||||||
|
state.rot = lastState.rot
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply velocity
|
||||||
|
if (delta.flags & DeltaFlags.VELOCITY) {
|
||||||
|
state.vel = delta.vel
|
||||||
|
} else if (lastState?.vel) {
|
||||||
|
state.vel = lastState.vel
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply angular velocity
|
||||||
|
if (delta.flags & DeltaFlags.ANGULAR_VELOCITY) {
|
||||||
|
state.angVel = delta.angVel
|
||||||
|
} else if (lastState?.angVel !== undefined) {
|
||||||
|
state.angVel = lastState.angVel
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply custom data
|
||||||
|
if (delta.flags & DeltaFlags.CUSTOM) {
|
||||||
|
state.custom = delta.custom
|
||||||
|
} else if (lastState?.custom) {
|
||||||
|
state.custom = lastState.custom
|
||||||
|
}
|
||||||
|
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
|
||||||
|
private _customDataEqual(
|
||||||
|
a: Record<string, unknown> | undefined,
|
||||||
|
b: Record<string, unknown> | undefined
|
||||||
|
): boolean {
|
||||||
|
if (a === b) return true
|
||||||
|
if (!a || !b) return false
|
||||||
|
|
||||||
|
const keysA = Object.keys(a)
|
||||||
|
const keysB = Object.keys(b)
|
||||||
|
|
||||||
|
if (keysA.length !== keysB.length) return false
|
||||||
|
|
||||||
|
for (const key of keysA) {
|
||||||
|
if (a[key] !== b[key]) return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// 工厂函数 | Factory Functions
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 创建状态增量压缩器
|
||||||
|
* @en Create state delta compressor
|
||||||
|
*/
|
||||||
|
export function createStateDeltaCompressor(
|
||||||
|
config?: Partial<DeltaCompressionConfig>
|
||||||
|
): StateDeltaCompressor {
|
||||||
|
return new StateDeltaCompressor(config)
|
||||||
|
}
|
||||||
@@ -46,3 +46,19 @@ export type {
|
|||||||
} from './ClientPrediction';
|
} from './ClientPrediction';
|
||||||
|
|
||||||
export { ClientPrediction, createClientPrediction } from './ClientPrediction';
|
export { ClientPrediction, createClientPrediction } from './ClientPrediction';
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// 状态增量压缩 | State Delta Compression
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export type {
|
||||||
|
EntityDeltaState,
|
||||||
|
DeltaSyncData,
|
||||||
|
DeltaCompressionConfig
|
||||||
|
} from './StateDelta';
|
||||||
|
|
||||||
|
export {
|
||||||
|
DeltaFlags,
|
||||||
|
StateDeltaCompressor,
|
||||||
|
createStateDeltaCompressor
|
||||||
|
} from './StateDelta';
|
||||||
|
|||||||
500
packages/framework/network/src/systems/NetworkAOISystem.ts
Normal file
500
packages/framework/network/src/systems/NetworkAOISystem.ts
Normal file
@@ -0,0 +1,500 @@
|
|||||||
|
/**
|
||||||
|
* @zh 网络 AOI 系统
|
||||||
|
* @en Network AOI System
|
||||||
|
*
|
||||||
|
* @zh 集成 AOI 兴趣区域管理,过滤网络同步数据
|
||||||
|
* @en Integrates AOI interest management to filter network sync data
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { EntitySystem, Matcher, type Entity } from '@esengine/ecs-framework'
|
||||||
|
import { NetworkIdentity } from '../components/NetworkIdentity'
|
||||||
|
import { NetworkTransform } from '../components/NetworkTransform'
|
||||||
|
import type { EntitySyncState } from '../protocol'
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Types | 类型定义
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh AOI 事件类型
|
||||||
|
* @en AOI event type
|
||||||
|
*/
|
||||||
|
export type NetworkAOIEventType = 'enter' | 'exit'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh AOI 事件
|
||||||
|
* @en AOI event
|
||||||
|
*/
|
||||||
|
export interface NetworkAOIEvent {
|
||||||
|
/**
|
||||||
|
* @zh 事件类型
|
||||||
|
* @en Event type
|
||||||
|
*/
|
||||||
|
type: NetworkAOIEventType
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 观察者网络 ID(玩家)
|
||||||
|
* @en Observer network ID (player)
|
||||||
|
*/
|
||||||
|
observerNetId: number
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 目标网络 ID(进入/离开视野的实体)
|
||||||
|
* @en Target network ID (entity entering/exiting view)
|
||||||
|
*/
|
||||||
|
targetNetId: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh AOI 事件监听器
|
||||||
|
* @en AOI event listener
|
||||||
|
*/
|
||||||
|
export type NetworkAOIEventListener = (event: NetworkAOIEvent) => void
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 网络 AOI 配置
|
||||||
|
* @en Network AOI configuration
|
||||||
|
*/
|
||||||
|
export interface NetworkAOIConfig {
|
||||||
|
/**
|
||||||
|
* @zh 网格单元格大小
|
||||||
|
* @en Grid cell size
|
||||||
|
*/
|
||||||
|
cellSize: number
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 默认视野范围
|
||||||
|
* @en Default view range
|
||||||
|
*/
|
||||||
|
defaultViewRange: number
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 是否启用 AOI 过滤
|
||||||
|
* @en Whether to enable AOI filtering
|
||||||
|
*/
|
||||||
|
enabled: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_CONFIG: NetworkAOIConfig = {
|
||||||
|
cellSize: 100,
|
||||||
|
defaultViewRange: 500,
|
||||||
|
enabled: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 观察者数据
|
||||||
|
* @en Observer data
|
||||||
|
*/
|
||||||
|
interface ObserverData {
|
||||||
|
netId: number
|
||||||
|
position: { x: number; y: number }
|
||||||
|
viewRange: number
|
||||||
|
viewRangeSq: number
|
||||||
|
cellKey: string
|
||||||
|
visibleEntities: Set<number>
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// NetworkAOISystem | 网络 AOI 系统
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 网络 AOI 系统
|
||||||
|
* @en Network AOI system
|
||||||
|
*
|
||||||
|
* @zh 管理网络实体的兴趣区域,过滤同步数据
|
||||||
|
* @en Manages network entities' areas of interest and filters sync data
|
||||||
|
*/
|
||||||
|
export class NetworkAOISystem extends EntitySystem {
|
||||||
|
private readonly _config: NetworkAOIConfig
|
||||||
|
private readonly _observers: Map<number, ObserverData> = new Map()
|
||||||
|
private readonly _cells: Map<string, Set<number>> = new Map()
|
||||||
|
private readonly _listeners: Set<NetworkAOIEventListener> = new Set()
|
||||||
|
private readonly _entityNetIdMap: Map<Entity, number> = new Map()
|
||||||
|
private readonly _netIdEntityMap: Map<number, Entity> = new Map()
|
||||||
|
|
||||||
|
constructor(config?: Partial<NetworkAOIConfig>) {
|
||||||
|
super(Matcher.all(NetworkIdentity, NetworkTransform))
|
||||||
|
this._config = { ...DEFAULT_CONFIG, ...config }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 获取配置
|
||||||
|
* @en Get configuration
|
||||||
|
*/
|
||||||
|
get config(): Readonly<NetworkAOIConfig> {
|
||||||
|
return this._config
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 是否启用
|
||||||
|
* @en Is enabled
|
||||||
|
*/
|
||||||
|
get enabled(): boolean {
|
||||||
|
return this._config.enabled
|
||||||
|
}
|
||||||
|
|
||||||
|
set enabled(value: boolean) {
|
||||||
|
this._config.enabled = value
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 观察者数量
|
||||||
|
* @en Observer count
|
||||||
|
*/
|
||||||
|
get observerCount(): number {
|
||||||
|
return this._observers.size
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// 观察者管理 | Observer Management
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 添加观察者(通常是玩家实体)
|
||||||
|
* @en Add observer (usually player entity)
|
||||||
|
*/
|
||||||
|
addObserver(netId: number, x: number, y: number, viewRange?: number): void {
|
||||||
|
if (this._observers.has(netId)) {
|
||||||
|
this.updateObserverPosition(netId, x, y)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const range = viewRange ?? this._config.defaultViewRange
|
||||||
|
const cellKey = this._getCellKey(x, y)
|
||||||
|
const data: ObserverData = {
|
||||||
|
netId,
|
||||||
|
position: { x, y },
|
||||||
|
viewRange: range,
|
||||||
|
viewRangeSq: range * range,
|
||||||
|
cellKey,
|
||||||
|
visibleEntities: new Set(),
|
||||||
|
}
|
||||||
|
|
||||||
|
this._observers.set(netId, data)
|
||||||
|
this._addToCell(cellKey, netId)
|
||||||
|
this._updateVisibility(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 移除观察者
|
||||||
|
* @en Remove observer
|
||||||
|
*/
|
||||||
|
removeObserver(netId: number): boolean {
|
||||||
|
const data = this._observers.get(netId)
|
||||||
|
if (!data) return false
|
||||||
|
|
||||||
|
// Emit exit events for all visible entities
|
||||||
|
for (const visibleNetId of data.visibleEntities) {
|
||||||
|
this._emitEvent({
|
||||||
|
type: 'exit',
|
||||||
|
observerNetId: netId,
|
||||||
|
targetNetId: visibleNetId,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
this._removeFromCell(data.cellKey, netId)
|
||||||
|
this._observers.delete(netId)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 更新观察者位置
|
||||||
|
* @en Update observer position
|
||||||
|
*/
|
||||||
|
updateObserverPosition(netId: number, x: number, y: number): void {
|
||||||
|
const data = this._observers.get(netId)
|
||||||
|
if (!data) return
|
||||||
|
|
||||||
|
const newCellKey = this._getCellKey(x, y)
|
||||||
|
if (newCellKey !== data.cellKey) {
|
||||||
|
this._removeFromCell(data.cellKey, netId)
|
||||||
|
data.cellKey = newCellKey
|
||||||
|
this._addToCell(newCellKey, netId)
|
||||||
|
}
|
||||||
|
|
||||||
|
data.position.x = x
|
||||||
|
data.position.y = y
|
||||||
|
this._updateVisibility(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 更新观察者视野范围
|
||||||
|
* @en Update observer view range
|
||||||
|
*/
|
||||||
|
updateObserverViewRange(netId: number, viewRange: number): void {
|
||||||
|
const data = this._observers.get(netId)
|
||||||
|
if (!data) return
|
||||||
|
|
||||||
|
data.viewRange = viewRange
|
||||||
|
data.viewRangeSq = viewRange * viewRange
|
||||||
|
this._updateVisibility(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// 实体管理 | Entity Management
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 注册网络实体
|
||||||
|
* @en Register network entity
|
||||||
|
*/
|
||||||
|
registerEntity(entity: Entity, netId: number): void {
|
||||||
|
this._entityNetIdMap.set(entity, netId)
|
||||||
|
this._netIdEntityMap.set(netId, entity)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 注销网络实体
|
||||||
|
* @en Unregister network entity
|
||||||
|
*/
|
||||||
|
unregisterEntity(entity: Entity): void {
|
||||||
|
const netId = this._entityNetIdMap.get(entity)
|
||||||
|
if (netId !== undefined) {
|
||||||
|
// Remove from all observers' visible sets
|
||||||
|
for (const [, data] of this._observers) {
|
||||||
|
if (data.visibleEntities.has(netId)) {
|
||||||
|
data.visibleEntities.delete(netId)
|
||||||
|
this._emitEvent({
|
||||||
|
type: 'exit',
|
||||||
|
observerNetId: data.netId,
|
||||||
|
targetNetId: netId,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this._netIdEntityMap.delete(netId)
|
||||||
|
}
|
||||||
|
this._entityNetIdMap.delete(entity)
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// 查询接口 | Query Interface
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 获取观察者能看到的实体网络 ID 列表
|
||||||
|
* @en Get list of entity network IDs visible to observer
|
||||||
|
*/
|
||||||
|
getVisibleEntities(observerNetId: number): number[] {
|
||||||
|
const data = this._observers.get(observerNetId)
|
||||||
|
return data ? Array.from(data.visibleEntities) : []
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 获取能看到指定实体的观察者网络 ID 列表
|
||||||
|
* @en Get list of observer network IDs that can see the entity
|
||||||
|
*/
|
||||||
|
getObserversOf(entityNetId: number): number[] {
|
||||||
|
const observers: number[] = []
|
||||||
|
for (const [, data] of this._observers) {
|
||||||
|
if (data.visibleEntities.has(entityNetId)) {
|
||||||
|
observers.push(data.netId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return observers
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 检查观察者是否能看到目标
|
||||||
|
* @en Check if observer can see target
|
||||||
|
*/
|
||||||
|
canSee(observerNetId: number, targetNetId: number): boolean {
|
||||||
|
const data = this._observers.get(observerNetId)
|
||||||
|
return data?.visibleEntities.has(targetNetId) ?? false
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 过滤同步数据,只保留观察者能看到的实体
|
||||||
|
* @en Filter sync data to only include entities visible to observer
|
||||||
|
*/
|
||||||
|
filterSyncData(observerNetId: number, entities: EntitySyncState[]): EntitySyncState[] {
|
||||||
|
if (!this._config.enabled) {
|
||||||
|
return entities
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = this._observers.get(observerNetId)
|
||||||
|
if (!data) {
|
||||||
|
return entities
|
||||||
|
}
|
||||||
|
|
||||||
|
return entities.filter(entity => {
|
||||||
|
// Always include the observer's own entity
|
||||||
|
if (entity.netId === observerNetId) return true
|
||||||
|
// Include entities in view
|
||||||
|
return data.visibleEntities.has(entity.netId)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// 事件系统 | Event System
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 添加事件监听器
|
||||||
|
* @en Add event listener
|
||||||
|
*/
|
||||||
|
addListener(listener: NetworkAOIEventListener): void {
|
||||||
|
this._listeners.add(listener)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 移除事件监听器
|
||||||
|
* @en Remove event listener
|
||||||
|
*/
|
||||||
|
removeListener(listener: NetworkAOIEventListener): void {
|
||||||
|
this._listeners.delete(listener)
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// 系统生命周期 | System Lifecycle
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
protected override process(entities: readonly Entity[]): void {
|
||||||
|
if (!this._config.enabled) return
|
||||||
|
|
||||||
|
// Update entity positions for AOI calculations
|
||||||
|
for (const entity of entities) {
|
||||||
|
const identity = this.requireComponent(entity, NetworkIdentity)
|
||||||
|
const transform = this.requireComponent(entity, NetworkTransform)
|
||||||
|
|
||||||
|
// Register entity if not already registered
|
||||||
|
if (!this._entityNetIdMap.has(entity)) {
|
||||||
|
this.registerEntity(entity, identity.netId)
|
||||||
|
}
|
||||||
|
|
||||||
|
// If this entity is an observer (has authority), update its position
|
||||||
|
if (identity.bHasAuthority && this._observers.has(identity.netId)) {
|
||||||
|
this.updateObserverPosition(
|
||||||
|
identity.netId,
|
||||||
|
transform.currentX,
|
||||||
|
transform.currentY
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update all observers' visibility based on entity positions
|
||||||
|
this._updateAllObserversVisibility(entities)
|
||||||
|
}
|
||||||
|
|
||||||
|
private _updateAllObserversVisibility(entities: readonly Entity[]): void {
|
||||||
|
for (const [, data] of this._observers) {
|
||||||
|
const newVisible = new Set<number>()
|
||||||
|
|
||||||
|
// Check all entities
|
||||||
|
for (const entity of entities) {
|
||||||
|
const identity = this.requireComponent(entity, NetworkIdentity)
|
||||||
|
const transform = this.requireComponent(entity, NetworkTransform)
|
||||||
|
|
||||||
|
// Skip self
|
||||||
|
if (identity.netId === data.netId) continue
|
||||||
|
|
||||||
|
// Check distance
|
||||||
|
const dx = transform.currentX - data.position.x
|
||||||
|
const dy = transform.currentY - data.position.y
|
||||||
|
const distSq = dx * dx + dy * dy
|
||||||
|
|
||||||
|
if (distSq <= data.viewRangeSq) {
|
||||||
|
newVisible.add(identity.netId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find entities that entered view
|
||||||
|
for (const netId of newVisible) {
|
||||||
|
if (!data.visibleEntities.has(netId)) {
|
||||||
|
this._emitEvent({
|
||||||
|
type: 'enter',
|
||||||
|
observerNetId: data.netId,
|
||||||
|
targetNetId: netId,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find entities that exited view
|
||||||
|
for (const netId of data.visibleEntities) {
|
||||||
|
if (!newVisible.has(netId)) {
|
||||||
|
this._emitEvent({
|
||||||
|
type: 'exit',
|
||||||
|
observerNetId: data.netId,
|
||||||
|
targetNetId: netId,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data.visibleEntities = newVisible
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 清除所有数据
|
||||||
|
* @en Clear all data
|
||||||
|
*/
|
||||||
|
clear(): void {
|
||||||
|
this._observers.clear()
|
||||||
|
this._cells.clear()
|
||||||
|
this._entityNetIdMap.clear()
|
||||||
|
this._netIdEntityMap.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override onDestroy(): void {
|
||||||
|
this.clear()
|
||||||
|
this._listeners.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// 私有方法 | Private Methods
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
private _getCellKey(x: number, y: number): string {
|
||||||
|
const cellX = Math.floor(x / this._config.cellSize)
|
||||||
|
const cellY = Math.floor(y / this._config.cellSize)
|
||||||
|
return `${cellX},${cellY}`
|
||||||
|
}
|
||||||
|
|
||||||
|
private _addToCell(cellKey: string, netId: number): void {
|
||||||
|
let cell = this._cells.get(cellKey)
|
||||||
|
if (!cell) {
|
||||||
|
cell = new Set()
|
||||||
|
this._cells.set(cellKey, cell)
|
||||||
|
}
|
||||||
|
cell.add(netId)
|
||||||
|
}
|
||||||
|
|
||||||
|
private _removeFromCell(cellKey: string, netId: number): void {
|
||||||
|
const cell = this._cells.get(cellKey)
|
||||||
|
if (cell) {
|
||||||
|
cell.delete(netId)
|
||||||
|
if (cell.size === 0) {
|
||||||
|
this._cells.delete(cellKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private _updateVisibility(data: ObserverData): void {
|
||||||
|
// This is called when an observer moves
|
||||||
|
// The full visibility update happens in process() with all entities
|
||||||
|
}
|
||||||
|
|
||||||
|
private _emitEvent(event: NetworkAOIEvent): void {
|
||||||
|
for (const listener of this._listeners) {
|
||||||
|
try {
|
||||||
|
listener(event)
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[NetworkAOISystem] Listener error:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// 工厂函数 | Factory Functions
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 创建网络 AOI 系统
|
||||||
|
* @en Create network AOI system
|
||||||
|
*/
|
||||||
|
export function createNetworkAOISystem(
|
||||||
|
config?: Partial<NetworkAOIConfig>
|
||||||
|
): NetworkAOISystem {
|
||||||
|
return new NetworkAOISystem(config)
|
||||||
|
}
|
||||||
@@ -1,11 +1,63 @@
|
|||||||
/**
|
/**
|
||||||
* @zh 网络输入系统
|
* @zh 网络输入系统
|
||||||
* @en Network Input System
|
* @en Network Input System
|
||||||
|
*
|
||||||
|
* @zh 收集本地玩家输入并发送到服务器,支持与预测系统集成
|
||||||
|
* @en Collects local player input and sends to server, supports integration with prediction system
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { EntitySystem, Matcher } from '@esengine/ecs-framework'
|
import { EntitySystem, Matcher } from '@esengine/ecs-framework'
|
||||||
import type { PlayerInput } from '../protocol'
|
import type { PlayerInput } from '../protocol'
|
||||||
import type { NetworkService } from '../services/NetworkService'
|
import type { NetworkService } from '../services/NetworkService'
|
||||||
|
import type { NetworkPredictionSystem } from './NetworkPredictionSystem'
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Types | 类型定义
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 输入配置
|
||||||
|
* @en Input configuration
|
||||||
|
*/
|
||||||
|
export interface NetworkInputConfig {
|
||||||
|
/**
|
||||||
|
* @zh 发送输入的最小间隔(毫秒)
|
||||||
|
* @en Minimum interval between input sends (milliseconds)
|
||||||
|
*/
|
||||||
|
sendInterval: number
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 是否合并相同输入
|
||||||
|
* @en Whether to merge identical inputs
|
||||||
|
*/
|
||||||
|
mergeIdenticalInputs: boolean
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 最大输入队列长度
|
||||||
|
* @en Maximum input queue length
|
||||||
|
*/
|
||||||
|
maxQueueLength: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_CONFIG: NetworkInputConfig = {
|
||||||
|
sendInterval: 16, // ~60fps
|
||||||
|
mergeIdenticalInputs: true,
|
||||||
|
maxQueueLength: 10,
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 待发送输入
|
||||||
|
* @en Pending input
|
||||||
|
*/
|
||||||
|
interface PendingInput {
|
||||||
|
moveDir?: { x: number; y: number }
|
||||||
|
actions?: string[]
|
||||||
|
timestamp: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// NetworkInputSystem | 网络输入系统
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @zh 网络输入系统
|
* @zh 网络输入系统
|
||||||
@@ -15,13 +67,52 @@ import type { NetworkService } from '../services/NetworkService'
|
|||||||
* @en Collects local player input and sends to server
|
* @en Collects local player input and sends to server
|
||||||
*/
|
*/
|
||||||
export class NetworkInputSystem extends EntitySystem {
|
export class NetworkInputSystem extends EntitySystem {
|
||||||
private _networkService: NetworkService
|
private readonly _networkService: NetworkService
|
||||||
private _frame: number = 0
|
private readonly _config: NetworkInputConfig
|
||||||
private _inputQueue: PlayerInput[] = []
|
private _predictionSystem: NetworkPredictionSystem | null = null
|
||||||
|
|
||||||
constructor(networkService: NetworkService) {
|
private _frame: number = 0
|
||||||
|
private _inputSequence: number = 0
|
||||||
|
private _inputQueue: PendingInput[] = []
|
||||||
|
private _lastSendTime: number = 0
|
||||||
|
private _lastMoveDir: { x: number; y: number } = { x: 0, y: 0 }
|
||||||
|
|
||||||
|
constructor(networkService: NetworkService, config?: Partial<NetworkInputConfig>) {
|
||||||
super(Matcher.nothing())
|
super(Matcher.nothing())
|
||||||
this._networkService = networkService
|
this._networkService = networkService
|
||||||
|
this._config = { ...DEFAULT_CONFIG, ...config }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 获取配置
|
||||||
|
* @en Get configuration
|
||||||
|
*/
|
||||||
|
get config(): Readonly<NetworkInputConfig> {
|
||||||
|
return this._config
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 获取当前帧号
|
||||||
|
* @en Get current frame number
|
||||||
|
*/
|
||||||
|
get frame(): number {
|
||||||
|
return this._frame
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 获取当前输入序列号
|
||||||
|
* @en Get current input sequence
|
||||||
|
*/
|
||||||
|
get inputSequence(): number {
|
||||||
|
return this._inputSequence
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 设置预测系统引用
|
||||||
|
* @en Set prediction system reference
|
||||||
|
*/
|
||||||
|
setPredictionSystem(system: NetworkPredictionSystem): void {
|
||||||
|
this._predictionSystem = system
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -32,11 +123,64 @@ export class NetworkInputSystem extends EntitySystem {
|
|||||||
if (!this._networkService.isConnected) return
|
if (!this._networkService.isConnected) return
|
||||||
|
|
||||||
this._frame++
|
this._frame++
|
||||||
|
const now = Date.now()
|
||||||
|
|
||||||
while (this._inputQueue.length > 0) {
|
// Rate limiting
|
||||||
const input = this._inputQueue.shift()!
|
if (now - this._lastSendTime < this._config.sendInterval) return
|
||||||
input.frame = this._frame
|
|
||||||
this._networkService.sendInput(input)
|
// If using prediction system, get input from there
|
||||||
|
if (this._predictionSystem) {
|
||||||
|
const predictedInput = this._predictionSystem.getInputToSend()
|
||||||
|
if (predictedInput) {
|
||||||
|
this._networkService.sendInput(predictedInput)
|
||||||
|
this._lastSendTime = now
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise process queue
|
||||||
|
if (this._inputQueue.length === 0) return
|
||||||
|
|
||||||
|
// Merge inputs if configured
|
||||||
|
let mergedInput: PendingInput
|
||||||
|
if (this._config.mergeIdenticalInputs && this._inputQueue.length > 1) {
|
||||||
|
mergedInput = this._mergeInputs(this._inputQueue)
|
||||||
|
this._inputQueue.length = 0
|
||||||
|
} else {
|
||||||
|
mergedInput = this._inputQueue.shift()!
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build and send input
|
||||||
|
this._inputSequence++
|
||||||
|
const input: PlayerInput = {
|
||||||
|
seq: this._inputSequence,
|
||||||
|
frame: this._frame,
|
||||||
|
timestamp: mergedInput.timestamp,
|
||||||
|
moveDir: mergedInput.moveDir,
|
||||||
|
actions: mergedInput.actions,
|
||||||
|
}
|
||||||
|
|
||||||
|
this._networkService.sendInput(input)
|
||||||
|
this._lastSendTime = now
|
||||||
|
}
|
||||||
|
|
||||||
|
private _mergeInputs(inputs: PendingInput[]): PendingInput {
|
||||||
|
const allActions: string[] = []
|
||||||
|
let lastMoveDir: { x: number; y: number } | undefined
|
||||||
|
|
||||||
|
for (const input of inputs) {
|
||||||
|
if (input.moveDir) {
|
||||||
|
lastMoveDir = input.moveDir
|
||||||
|
}
|
||||||
|
if (input.actions) {
|
||||||
|
allActions.push(...input.actions)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
moveDir: lastMoveDir,
|
||||||
|
actions: allActions.length > 0 ? allActions : undefined,
|
||||||
|
timestamp: inputs[inputs.length - 1].timestamp,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,10 +189,24 @@ export class NetworkInputSystem extends EntitySystem {
|
|||||||
* @en Add move input
|
* @en Add move input
|
||||||
*/
|
*/
|
||||||
public addMoveInput(x: number, y: number): void {
|
public addMoveInput(x: number, y: number): void {
|
||||||
this._inputQueue.push({
|
// Skip if same as last input
|
||||||
frame: 0,
|
if (
|
||||||
moveDir: { x, y },
|
this._config.mergeIdenticalInputs &&
|
||||||
})
|
this._lastMoveDir.x === x &&
|
||||||
|
this._lastMoveDir.y === y &&
|
||||||
|
this._inputQueue.length > 0
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this._lastMoveDir = { x, y }
|
||||||
|
|
||||||
|
// Also set input on prediction system
|
||||||
|
if (this._predictionSystem) {
|
||||||
|
this._predictionSystem.setInput(x, y)
|
||||||
|
}
|
||||||
|
|
||||||
|
this._addToQueue({ moveDir: { x, y }, timestamp: Date.now() })
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -56,19 +214,70 @@ export class NetworkInputSystem extends EntitySystem {
|
|||||||
* @en Add action input
|
* @en Add action input
|
||||||
*/
|
*/
|
||||||
public addActionInput(action: string): void {
|
public addActionInput(action: string): void {
|
||||||
|
// Try to add to last input in queue
|
||||||
const lastInput = this._inputQueue[this._inputQueue.length - 1]
|
const lastInput = this._inputQueue[this._inputQueue.length - 1]
|
||||||
if (lastInput) {
|
if (lastInput) {
|
||||||
lastInput.actions = lastInput.actions || []
|
lastInput.actions = lastInput.actions || []
|
||||||
lastInput.actions.push(action)
|
lastInput.actions.push(action)
|
||||||
} else {
|
} else {
|
||||||
this._inputQueue.push({
|
this._addToQueue({ actions: [action], timestamp: Date.now() })
|
||||||
frame: 0,
|
|
||||||
actions: [action],
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Also set on prediction system
|
||||||
|
if (this._predictionSystem) {
|
||||||
|
this._predictionSystem.setInput(
|
||||||
|
this._lastMoveDir.x,
|
||||||
|
this._lastMoveDir.y,
|
||||||
|
[action]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private _addToQueue(input: PendingInput): void {
|
||||||
|
this._inputQueue.push(input)
|
||||||
|
|
||||||
|
// Limit queue size
|
||||||
|
while (this._inputQueue.length > this._config.maxQueueLength) {
|
||||||
|
this._inputQueue.shift()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 清空输入队列
|
||||||
|
* @en Clear input queue
|
||||||
|
*/
|
||||||
|
public clearQueue(): void {
|
||||||
|
this._inputQueue.length = 0
|
||||||
|
this._lastMoveDir = { x: 0, y: 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 重置状态
|
||||||
|
* @en Reset state
|
||||||
|
*/
|
||||||
|
public reset(): void {
|
||||||
|
this._frame = 0
|
||||||
|
this._inputSequence = 0
|
||||||
|
this.clearQueue()
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override onDestroy(): void {
|
protected override onDestroy(): void {
|
||||||
this._inputQueue.length = 0
|
this._inputQueue.length = 0
|
||||||
|
this._predictionSystem = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// 工厂函数 | Factory Functions
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 创建网络输入系统
|
||||||
|
* @en Create network input system
|
||||||
|
*/
|
||||||
|
export function createNetworkInputSystem(
|
||||||
|
networkService: NetworkService,
|
||||||
|
config?: Partial<NetworkInputConfig>
|
||||||
|
): NetworkInputSystem {
|
||||||
|
return new NetworkInputSystem(networkService, config)
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,309 @@
|
|||||||
|
/**
|
||||||
|
* @zh 网络预测系统
|
||||||
|
* @en Network Prediction System
|
||||||
|
*
|
||||||
|
* @zh 处理本地玩家的客户端预测和服务器校正
|
||||||
|
* @en Handles client-side prediction and server reconciliation for local player
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { EntitySystem, Matcher, Time, type Entity } from '@esengine/ecs-framework'
|
||||||
|
import { NetworkIdentity } from '../components/NetworkIdentity'
|
||||||
|
import { NetworkTransform } from '../components/NetworkTransform'
|
||||||
|
import type { SyncData, PlayerInput } from '../protocol'
|
||||||
|
import {
|
||||||
|
ClientPrediction,
|
||||||
|
createClientPrediction,
|
||||||
|
type IPredictor,
|
||||||
|
type ClientPredictionConfig,
|
||||||
|
type ITransformState,
|
||||||
|
} from '../sync'
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Types | 类型定义
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 移动输入
|
||||||
|
* @en Movement input
|
||||||
|
*/
|
||||||
|
export interface MovementInput {
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
actions?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 预测状态(位置 + 旋转)
|
||||||
|
* @en Predicted state (position + rotation)
|
||||||
|
*/
|
||||||
|
export interface PredictedTransform extends ITransformState {
|
||||||
|
velocityX: number
|
||||||
|
velocityY: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 预测系统配置
|
||||||
|
* @en Prediction system configuration
|
||||||
|
*/
|
||||||
|
export interface NetworkPredictionConfig extends Partial<ClientPredictionConfig> {
|
||||||
|
/**
|
||||||
|
* @zh 移动速度(单位/秒)
|
||||||
|
* @en Movement speed (units/second)
|
||||||
|
*/
|
||||||
|
moveSpeed: number
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 是否启用预测
|
||||||
|
* @en Whether prediction is enabled
|
||||||
|
*/
|
||||||
|
enabled: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_CONFIG: NetworkPredictionConfig = {
|
||||||
|
moveSpeed: 200,
|
||||||
|
enabled: true,
|
||||||
|
maxUnacknowledgedInputs: 60,
|
||||||
|
reconciliationThreshold: 0.5,
|
||||||
|
reconciliationSpeed: 10,
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// 默认预测器 | Default Predictor
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 简单移动预测器
|
||||||
|
* @en Simple movement predictor
|
||||||
|
*/
|
||||||
|
class SimpleMovementPredictor implements IPredictor<PredictedTransform, MovementInput> {
|
||||||
|
constructor(private readonly _moveSpeed: number) {}
|
||||||
|
|
||||||
|
predict(state: PredictedTransform, input: MovementInput, deltaTime: number): PredictedTransform {
|
||||||
|
const velocityX = input.x * this._moveSpeed
|
||||||
|
const velocityY = input.y * this._moveSpeed
|
||||||
|
|
||||||
|
return {
|
||||||
|
x: state.x + velocityX * deltaTime,
|
||||||
|
y: state.y + velocityY * deltaTime,
|
||||||
|
rotation: state.rotation,
|
||||||
|
velocityX,
|
||||||
|
velocityY,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// NetworkPredictionSystem | 网络预测系统
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 网络预测系统
|
||||||
|
* @en Network prediction system
|
||||||
|
*
|
||||||
|
* @zh 处理本地玩家的输入预测和服务器状态校正
|
||||||
|
* @en Handles local player input prediction and server state reconciliation
|
||||||
|
*/
|
||||||
|
export class NetworkPredictionSystem extends EntitySystem {
|
||||||
|
private readonly _config: NetworkPredictionConfig
|
||||||
|
private readonly _predictor: IPredictor<PredictedTransform, MovementInput>
|
||||||
|
private _prediction: ClientPrediction<PredictedTransform, MovementInput> | null = null
|
||||||
|
private _localPlayerNetId: number = -1
|
||||||
|
private _currentInput: MovementInput = { x: 0, y: 0 }
|
||||||
|
private _inputSequence: number = 0
|
||||||
|
|
||||||
|
constructor(config?: Partial<NetworkPredictionConfig>) {
|
||||||
|
super(Matcher.all(NetworkIdentity, NetworkTransform))
|
||||||
|
this._config = { ...DEFAULT_CONFIG, ...config }
|
||||||
|
this._predictor = new SimpleMovementPredictor(this._config.moveSpeed)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 获取配置
|
||||||
|
* @en Get configuration
|
||||||
|
*/
|
||||||
|
get config(): Readonly<NetworkPredictionConfig> {
|
||||||
|
return this._config
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 获取当前输入序列号
|
||||||
|
* @en Get current input sequence number
|
||||||
|
*/
|
||||||
|
get inputSequence(): number {
|
||||||
|
return this._inputSequence
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 获取待确认输入数量
|
||||||
|
* @en Get pending input count
|
||||||
|
*/
|
||||||
|
get pendingInputCount(): number {
|
||||||
|
return this._prediction?.pendingInputCount ?? 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 是否启用预测
|
||||||
|
* @en Whether prediction is enabled
|
||||||
|
*/
|
||||||
|
get enabled(): boolean {
|
||||||
|
return this._config.enabled
|
||||||
|
}
|
||||||
|
|
||||||
|
set enabled(value: boolean) {
|
||||||
|
this._config.enabled = value
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 设置本地玩家网络 ID
|
||||||
|
* @en Set local player network ID
|
||||||
|
*/
|
||||||
|
setLocalPlayerNetId(netId: number): void {
|
||||||
|
this._localPlayerNetId = netId
|
||||||
|
this._prediction = createClientPrediction<PredictedTransform, MovementInput>(
|
||||||
|
this._predictor,
|
||||||
|
{
|
||||||
|
maxUnacknowledgedInputs: this._config.maxUnacknowledgedInputs,
|
||||||
|
reconciliationThreshold: this._config.reconciliationThreshold,
|
||||||
|
reconciliationSpeed: this._config.reconciliationSpeed,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 设置移动输入
|
||||||
|
* @en Set movement input
|
||||||
|
*/
|
||||||
|
setInput(x: number, y: number, actions?: string[]): void {
|
||||||
|
this._currentInput = { x, y, actions }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 获取下一个要发送的输入(带序列号)
|
||||||
|
* @en Get next input to send (with sequence number)
|
||||||
|
*/
|
||||||
|
getInputToSend(): PlayerInput | null {
|
||||||
|
if (!this._prediction) return null
|
||||||
|
|
||||||
|
const input = this._prediction.getInputToSend()
|
||||||
|
if (!input) return null
|
||||||
|
|
||||||
|
return {
|
||||||
|
seq: input.sequence,
|
||||||
|
frame: 0,
|
||||||
|
timestamp: input.timestamp,
|
||||||
|
moveDir: { x: input.input.x, y: input.input.y },
|
||||||
|
actions: input.input.actions,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 处理服务器同步数据进行校正
|
||||||
|
* @en Process server sync data for reconciliation
|
||||||
|
*/
|
||||||
|
reconcileWithServer(data: SyncData): void {
|
||||||
|
if (!this._prediction || this._localPlayerNetId < 0) return
|
||||||
|
|
||||||
|
// Find local player state in sync data
|
||||||
|
const localState = data.entities.find(e => e.netId === this._localPlayerNetId)
|
||||||
|
if (!localState || !localState.pos) return
|
||||||
|
|
||||||
|
const serverState: PredictedTransform = {
|
||||||
|
x: localState.pos.x,
|
||||||
|
y: localState.pos.y,
|
||||||
|
rotation: localState.rot ?? 0,
|
||||||
|
velocityX: localState.vel?.x ?? 0,
|
||||||
|
velocityY: localState.vel?.y ?? 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reconcile prediction with server state
|
||||||
|
if (data.ackSeq !== undefined) {
|
||||||
|
this._prediction.reconcile(
|
||||||
|
serverState,
|
||||||
|
data.ackSeq,
|
||||||
|
(state) => ({ x: state.x, y: state.y }),
|
||||||
|
Time.deltaTime
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override process(entities: readonly Entity[]): void {
|
||||||
|
if (!this._config.enabled || !this._prediction) return
|
||||||
|
|
||||||
|
const deltaTime = Time.deltaTime
|
||||||
|
|
||||||
|
for (const entity of entities) {
|
||||||
|
const identity = this.requireComponent(entity, NetworkIdentity)
|
||||||
|
|
||||||
|
// Only process local player with authority
|
||||||
|
if (!identity.bHasAuthority || identity.netId !== this._localPlayerNetId) continue
|
||||||
|
|
||||||
|
const transform = this.requireComponent(entity, NetworkTransform)
|
||||||
|
|
||||||
|
// Get current state
|
||||||
|
const currentState: PredictedTransform = {
|
||||||
|
x: transform.currentX,
|
||||||
|
y: transform.currentY,
|
||||||
|
rotation: transform.currentRotation,
|
||||||
|
velocityX: 0,
|
||||||
|
velocityY: 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Record input and get predicted state
|
||||||
|
if (this._currentInput.x !== 0 || this._currentInput.y !== 0) {
|
||||||
|
const predicted = this._prediction.recordInput(
|
||||||
|
this._currentInput,
|
||||||
|
currentState,
|
||||||
|
deltaTime
|
||||||
|
)
|
||||||
|
|
||||||
|
// Apply predicted position
|
||||||
|
transform.currentX = predicted.x
|
||||||
|
transform.currentY = predicted.y
|
||||||
|
transform.currentRotation = predicted.rotation
|
||||||
|
|
||||||
|
// Update target to match (for rendering)
|
||||||
|
transform.targetX = predicted.x
|
||||||
|
transform.targetY = predicted.y
|
||||||
|
transform.targetRotation = predicted.rotation
|
||||||
|
|
||||||
|
this._inputSequence = this._prediction.currentSequence
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply correction offset smoothly
|
||||||
|
const offset = this._prediction.correctionOffset
|
||||||
|
if (Math.abs(offset.x) > 0.01 || Math.abs(offset.y) > 0.01) {
|
||||||
|
transform.currentX += offset.x * deltaTime * 5
|
||||||
|
transform.currentY += offset.y * deltaTime * 5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 重置预测状态
|
||||||
|
* @en Reset prediction state
|
||||||
|
*/
|
||||||
|
reset(): void {
|
||||||
|
this._prediction?.clear()
|
||||||
|
this._inputSequence = 0
|
||||||
|
this._currentInput = { x: 0, y: 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override onDestroy(): void {
|
||||||
|
this._prediction?.clear()
|
||||||
|
this._prediction = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// 工厂函数 | Factory Functions
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 创建网络预测系统
|
||||||
|
* @en Create network prediction system
|
||||||
|
*/
|
||||||
|
export function createNetworkPredictionSystem(
|
||||||
|
config?: Partial<NetworkPredictionConfig>
|
||||||
|
): NetworkPredictionSystem {
|
||||||
|
return new NetworkPredictionSystem(config)
|
||||||
|
}
|
||||||
@@ -1,10 +1,32 @@
|
|||||||
|
/**
|
||||||
|
* @zh 网络同步系统
|
||||||
|
* @en Network Sync System
|
||||||
|
*
|
||||||
|
* @zh 处理网络实体的状态同步、快照缓冲和插值
|
||||||
|
* @en Handles state synchronization, snapshot buffering, and interpolation for networked entities
|
||||||
|
*/
|
||||||
|
|
||||||
import { EntitySystem, Matcher, Time, type Entity } from '@esengine/ecs-framework'
|
import { EntitySystem, Matcher, Time, type Entity } from '@esengine/ecs-framework'
|
||||||
import { NetworkIdentity } from '../components/NetworkIdentity'
|
import { NetworkIdentity } from '../components/NetworkIdentity'
|
||||||
import { NetworkTransform } from '../components/NetworkTransform'
|
import { NetworkTransform } from '../components/NetworkTransform'
|
||||||
|
import type { SyncData, EntitySyncState } from '../protocol'
|
||||||
|
import {
|
||||||
|
SnapshotBuffer,
|
||||||
|
createSnapshotBuffer,
|
||||||
|
TransformInterpolator,
|
||||||
|
createTransformInterpolator,
|
||||||
|
type ITransformState,
|
||||||
|
type ITransformStateWithVelocity,
|
||||||
|
type IStateSnapshot,
|
||||||
|
} from '../sync'
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Types | 类型定义
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @zh 同步消息接口
|
* @zh 同步消息接口(兼容旧版)
|
||||||
* @en Sync message interface
|
* @en Sync message interface (for backwards compatibility)
|
||||||
*/
|
*/
|
||||||
export interface SyncMessage {
|
export interface SyncMessage {
|
||||||
entities: Array<{
|
entities: Array<{
|
||||||
@@ -14,25 +36,134 @@ export interface SyncMessage {
|
|||||||
}>
|
}>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 实体快照数据
|
||||||
|
* @en Entity snapshot data
|
||||||
|
*/
|
||||||
|
interface EntitySnapshotData {
|
||||||
|
buffer: SnapshotBuffer<ITransformStateWithVelocity>
|
||||||
|
lastServerTime: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 同步系统配置
|
||||||
|
* @en Sync system configuration
|
||||||
|
*/
|
||||||
|
export interface NetworkSyncConfig {
|
||||||
|
/**
|
||||||
|
* @zh 快照缓冲区大小
|
||||||
|
* @en Snapshot buffer size
|
||||||
|
*/
|
||||||
|
bufferSize: number
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 插值延迟(毫秒)
|
||||||
|
* @en Interpolation delay in milliseconds
|
||||||
|
*/
|
||||||
|
interpolationDelay: number
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 是否启用外推
|
||||||
|
* @en Whether to enable extrapolation
|
||||||
|
*/
|
||||||
|
enableExtrapolation: boolean
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 最大外推时间(毫秒)
|
||||||
|
* @en Maximum extrapolation time in milliseconds
|
||||||
|
*/
|
||||||
|
maxExtrapolationTime: number
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 使用赫尔米特插值(更平滑)
|
||||||
|
* @en Use Hermite interpolation (smoother)
|
||||||
|
*/
|
||||||
|
useHermiteInterpolation: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_CONFIG: NetworkSyncConfig = {
|
||||||
|
bufferSize: 30,
|
||||||
|
interpolationDelay: 100,
|
||||||
|
enableExtrapolation: true,
|
||||||
|
maxExtrapolationTime: 200,
|
||||||
|
useHermiteInterpolation: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// NetworkSyncSystem | 网络同步系统
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @zh 网络同步系统
|
* @zh 网络同步系统
|
||||||
* @en Network sync system
|
* @en Network sync system
|
||||||
*
|
*
|
||||||
* @zh 处理网络实体的状态同步和插值
|
* @zh 处理网络实体的状态同步和插值,支持快照缓冲、平滑插值和外推
|
||||||
* @en Handles state synchronization and interpolation for networked entities
|
* @en Handles state synchronization and interpolation for networked entities,
|
||||||
|
* supports snapshot buffering, smooth interpolation, and extrapolation
|
||||||
*/
|
*/
|
||||||
export class NetworkSyncSystem extends EntitySystem {
|
export class NetworkSyncSystem extends EntitySystem {
|
||||||
private _netIdToEntity: Map<number, number> = new Map()
|
private readonly _netIdToEntity: Map<number, number> = new Map()
|
||||||
|
private readonly _entitySnapshots: Map<number, EntitySnapshotData> = new Map()
|
||||||
|
private readonly _interpolator: TransformInterpolator
|
||||||
|
private readonly _config: NetworkSyncConfig
|
||||||
|
|
||||||
constructor() {
|
private _serverTimeOffset: number = 0
|
||||||
|
private _lastSyncTime: number = 0
|
||||||
|
private _renderTime: number = 0
|
||||||
|
|
||||||
|
constructor(config?: Partial<NetworkSyncConfig>) {
|
||||||
super(Matcher.all(NetworkIdentity, NetworkTransform))
|
super(Matcher.all(NetworkIdentity, NetworkTransform))
|
||||||
|
this._config = { ...DEFAULT_CONFIG, ...config }
|
||||||
|
this._interpolator = createTransformInterpolator()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @zh 处理同步消息
|
* @zh 获取配置
|
||||||
* @en Handle sync message
|
* @en Get configuration
|
||||||
|
*/
|
||||||
|
get config(): Readonly<NetworkSyncConfig> {
|
||||||
|
return this._config
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 获取服务器时间偏移
|
||||||
|
* @en Get server time offset
|
||||||
|
*/
|
||||||
|
get serverTimeOffset(): number {
|
||||||
|
return this._serverTimeOffset
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 获取当前渲染时间
|
||||||
|
* @en Get current render time
|
||||||
|
*/
|
||||||
|
get renderTime(): number {
|
||||||
|
return this._renderTime
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 处理同步消息(新版,带时间戳)
|
||||||
|
* @en Handle sync message (new version with timestamp)
|
||||||
|
*/
|
||||||
|
handleSyncData(data: SyncData): void {
|
||||||
|
const serverTime = data.timestamp
|
||||||
|
|
||||||
|
// Update server time offset
|
||||||
|
const clientTime = Date.now()
|
||||||
|
this._serverTimeOffset = serverTime - clientTime
|
||||||
|
this._lastSyncTime = clientTime
|
||||||
|
|
||||||
|
for (const state of data.entities) {
|
||||||
|
this._processEntityState(state, serverTime)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 处理同步消息(兼容旧版)
|
||||||
|
* @en Handle sync message (backwards compatible)
|
||||||
*/
|
*/
|
||||||
handleSync(msg: SyncMessage): void {
|
handleSync(msg: SyncMessage): void {
|
||||||
|
const now = Date.now()
|
||||||
for (const state of msg.entities) {
|
for (const state of msg.entities) {
|
||||||
const entityId = this._netIdToEntity.get(state.netId)
|
const entityId = this._netIdToEntity.get(state.netId)
|
||||||
if (entityId === undefined) continue
|
if (entityId === undefined) continue
|
||||||
@@ -44,22 +175,133 @@ export class NetworkSyncSystem extends EntitySystem {
|
|||||||
if (transform && state.pos) {
|
if (transform && state.pos) {
|
||||||
transform.setTarget(state.pos.x, state.pos.y, state.rot ?? 0)
|
transform.setTarget(state.pos.x, state.pos.y, state.rot ?? 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Also add to snapshot buffer for interpolation
|
||||||
|
this._processEntityState({
|
||||||
|
netId: state.netId,
|
||||||
|
pos: state.pos,
|
||||||
|
rot: state.rot,
|
||||||
|
}, now)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private _processEntityState(state: EntitySyncState, serverTime: number): void {
|
||||||
|
const entityId = this._netIdToEntity.get(state.netId)
|
||||||
|
if (entityId === undefined) return
|
||||||
|
|
||||||
|
// Get or create snapshot buffer
|
||||||
|
let snapshotData = this._entitySnapshots.get(state.netId)
|
||||||
|
if (!snapshotData) {
|
||||||
|
snapshotData = {
|
||||||
|
buffer: createSnapshotBuffer<ITransformStateWithVelocity>(
|
||||||
|
this._config.bufferSize,
|
||||||
|
this._config.interpolationDelay
|
||||||
|
),
|
||||||
|
lastServerTime: 0,
|
||||||
|
}
|
||||||
|
this._entitySnapshots.set(state.netId, snapshotData)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create snapshot
|
||||||
|
const transformState: ITransformStateWithVelocity = {
|
||||||
|
x: state.pos?.x ?? 0,
|
||||||
|
y: state.pos?.y ?? 0,
|
||||||
|
rotation: state.rot ?? 0,
|
||||||
|
velocityX: state.vel?.x ?? 0,
|
||||||
|
velocityY: state.vel?.y ?? 0,
|
||||||
|
angularVelocity: state.angVel ?? 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
const snapshot: IStateSnapshot<ITransformStateWithVelocity> = {
|
||||||
|
timestamp: serverTime,
|
||||||
|
state: transformState,
|
||||||
|
}
|
||||||
|
|
||||||
|
snapshotData.buffer.push(snapshot)
|
||||||
|
snapshotData.lastServerTime = serverTime
|
||||||
|
}
|
||||||
|
|
||||||
protected override process(entities: readonly Entity[]): void {
|
protected override process(entities: readonly Entity[]): void {
|
||||||
const deltaTime = Time.deltaTime
|
const deltaTime = Time.deltaTime
|
||||||
|
const clientTime = Date.now()
|
||||||
|
|
||||||
|
// Calculate render time (current time adjusted for server offset)
|
||||||
|
this._renderTime = clientTime + this._serverTimeOffset
|
||||||
|
|
||||||
for (const entity of entities) {
|
for (const entity of entities) {
|
||||||
const transform = this.requireComponent(entity, NetworkTransform)
|
const transform = this.requireComponent(entity, NetworkTransform)
|
||||||
const identity = this.requireComponent(entity, NetworkIdentity)
|
const identity = this.requireComponent(entity, NetworkIdentity)
|
||||||
|
|
||||||
if (!identity.bHasAuthority && transform.bInterpolate) {
|
// Skip entities with authority (local player handles their own movement)
|
||||||
this._interpolate(transform, deltaTime)
|
if (identity.bHasAuthority) continue
|
||||||
|
|
||||||
|
if (transform.bInterpolate) {
|
||||||
|
this._interpolateEntity(identity.netId, transform, deltaTime)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private _interpolateEntity(
|
||||||
|
netId: number,
|
||||||
|
transform: NetworkTransform,
|
||||||
|
deltaTime: number
|
||||||
|
): void {
|
||||||
|
const snapshotData = this._entitySnapshots.get(netId)
|
||||||
|
|
||||||
|
if (snapshotData && snapshotData.buffer.size >= 2) {
|
||||||
|
// Use snapshot buffer for interpolation
|
||||||
|
const result = snapshotData.buffer.getInterpolationSnapshots(this._renderTime)
|
||||||
|
|
||||||
|
if (result) {
|
||||||
|
const [prev, next, t] = result
|
||||||
|
const interpolated = this._interpolator.interpolate(prev.state, next.state, t)
|
||||||
|
|
||||||
|
transform.currentX = interpolated.x
|
||||||
|
transform.currentY = interpolated.y
|
||||||
|
transform.currentRotation = interpolated.rotation
|
||||||
|
|
||||||
|
// Update target for compatibility
|
||||||
|
transform.targetX = next.state.x
|
||||||
|
transform.targetY = next.state.y
|
||||||
|
transform.targetRotation = next.state.rotation
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extrapolation if enabled and we have velocity data
|
||||||
|
if (this._config.enableExtrapolation) {
|
||||||
|
const latest = snapshotData.buffer.getLatest()
|
||||||
|
if (latest) {
|
||||||
|
const timeSinceLastSnapshot = this._renderTime - latest.timestamp
|
||||||
|
if (timeSinceLastSnapshot > 0 && timeSinceLastSnapshot < this._config.maxExtrapolationTime) {
|
||||||
|
const extrapolated = this._interpolator.extrapolate(
|
||||||
|
latest.state,
|
||||||
|
timeSinceLastSnapshot / 1000
|
||||||
|
)
|
||||||
|
transform.currentX = extrapolated.x
|
||||||
|
transform.currentY = extrapolated.y
|
||||||
|
transform.currentRotation = extrapolated.rotation
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: simple lerp towards target
|
||||||
|
this._simpleLerp(transform, deltaTime)
|
||||||
|
}
|
||||||
|
|
||||||
|
private _simpleLerp(transform: NetworkTransform, deltaTime: number): void {
|
||||||
|
const t = Math.min(1, transform.lerpSpeed * deltaTime)
|
||||||
|
|
||||||
|
transform.currentX += (transform.targetX - transform.currentX) * t
|
||||||
|
transform.currentY += (transform.targetY - transform.currentY) * t
|
||||||
|
|
||||||
|
let angleDiff = transform.targetRotation - transform.currentRotation
|
||||||
|
while (angleDiff > Math.PI) angleDiff -= Math.PI * 2
|
||||||
|
while (angleDiff < -Math.PI) angleDiff += Math.PI * 2
|
||||||
|
transform.currentRotation += angleDiff * t
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @zh 注册网络实体
|
* @zh 注册网络实体
|
||||||
* @en Register network entity
|
* @en Register network entity
|
||||||
@@ -74,6 +316,7 @@ export class NetworkSyncSystem extends EntitySystem {
|
|||||||
*/
|
*/
|
||||||
unregisterEntity(netId: number): void {
|
unregisterEntity(netId: number): void {
|
||||||
this._netIdToEntity.delete(netId)
|
this._netIdToEntity.delete(netId)
|
||||||
|
this._entitySnapshots.delete(netId)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -84,19 +327,26 @@ export class NetworkSyncSystem extends EntitySystem {
|
|||||||
return this._netIdToEntity.get(netId)
|
return this._netIdToEntity.get(netId)
|
||||||
}
|
}
|
||||||
|
|
||||||
private _interpolate(transform: NetworkTransform, deltaTime: number): void {
|
/**
|
||||||
const t = Math.min(1, transform.lerpSpeed * deltaTime)
|
* @zh 获取实体的快照缓冲区
|
||||||
|
* @en Get entity's snapshot buffer
|
||||||
|
*/
|
||||||
|
getSnapshotBuffer(netId: number): SnapshotBuffer<ITransformStateWithVelocity> | undefined {
|
||||||
|
return this._entitySnapshots.get(netId)?.buffer
|
||||||
|
}
|
||||||
|
|
||||||
transform.currentX += (transform.targetX - transform.currentX) * t
|
/**
|
||||||
transform.currentY += (transform.targetY - transform.currentY) * t
|
* @zh 清空所有快照缓冲
|
||||||
|
* @en Clear all snapshot buffers
|
||||||
let angleDiff = transform.targetRotation - transform.currentRotation
|
*/
|
||||||
while (angleDiff > Math.PI) angleDiff -= Math.PI * 2
|
clearSnapshots(): void {
|
||||||
while (angleDiff < -Math.PI) angleDiff += Math.PI * 2
|
for (const data of this._entitySnapshots.values()) {
|
||||||
transform.currentRotation += angleDiff * t
|
data.buffer.clear()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override onDestroy(): void {
|
protected override onDestroy(): void {
|
||||||
this._netIdToEntity.clear()
|
this._netIdToEntity.clear()
|
||||||
|
this._entitySnapshots.clear()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import type { NetworkService } from './services/NetworkService';
|
|||||||
import type { NetworkSyncSystem } from './systems/NetworkSyncSystem';
|
import type { NetworkSyncSystem } from './systems/NetworkSyncSystem';
|
||||||
import type { NetworkSpawnSystem } from './systems/NetworkSpawnSystem';
|
import type { NetworkSpawnSystem } from './systems/NetworkSpawnSystem';
|
||||||
import type { NetworkInputSystem } from './systems/NetworkInputSystem';
|
import type { NetworkInputSystem } from './systems/NetworkInputSystem';
|
||||||
|
import type { NetworkPredictionSystem } from './systems/NetworkPredictionSystem';
|
||||||
|
import type { NetworkAOISystem } from './systems/NetworkAOISystem';
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Network 模块导出的令牌 | Tokens exported by Network module
|
// Network 模块导出的令牌 | Tokens exported by Network module
|
||||||
@@ -36,3 +38,15 @@ export const NetworkSpawnSystemToken = createServiceToken<NetworkSpawnSystem>('n
|
|||||||
* Network input system token
|
* Network input system token
|
||||||
*/
|
*/
|
||||||
export const NetworkInputSystemToken = createServiceToken<NetworkInputSystem>('networkInputSystem');
|
export const NetworkInputSystemToken = createServiceToken<NetworkInputSystem>('networkInputSystem');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 网络预测系统令牌
|
||||||
|
* Network prediction system token
|
||||||
|
*/
|
||||||
|
export const NetworkPredictionSystemToken = createServiceToken<NetworkPredictionSystem>('networkPredictionSystem');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 网络 AOI 系统令牌
|
||||||
|
* Network AOI system token
|
||||||
|
*/
|
||||||
|
export const NetworkAOISystemToken = createServiceToken<NetworkAOISystem>('networkAOISystem');
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
# @esengine/pathfinding
|
# @esengine/pathfinding
|
||||||
|
|
||||||
|
## 1.0.4
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- [#376](https://github.com/esengine/esengine/pull/376) [`0662b07`](https://github.com/esengine/esengine/commit/0662b074454906ad7c0264fe1d3a241f13730ba1) Thanks [@esengine](https://github.com/esengine)! - fix: update pathfinding package to resolve npm version conflict
|
||||||
|
|
||||||
## 1.0.3
|
## 1.0.3
|
||||||
|
|
||||||
### Patch Changes
|
### Patch Changes
|
||||||
|
|||||||
@@ -0,0 +1,247 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import { AStarPathfinder } from '../../src/core/AStarPathfinder';
|
||||||
|
import { GridMap } from '../../src/grid/GridMap';
|
||||||
|
|
||||||
|
describe('AStarPathfinder', () => {
|
||||||
|
let grid: GridMap;
|
||||||
|
let pathfinder: AStarPathfinder;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
grid = new GridMap(10, 10);
|
||||||
|
pathfinder = new AStarPathfinder(grid);
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Basic Pathfinding
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
describe('basic pathfinding', () => {
|
||||||
|
it('should find path between adjacent nodes', () => {
|
||||||
|
const result = pathfinder.findPath(0, 0, 1, 0);
|
||||||
|
expect(result.found).toBe(true);
|
||||||
|
expect(result.path.length).toBe(2);
|
||||||
|
expect(result.path[0]).toEqual({ x: 0, y: 0 });
|
||||||
|
expect(result.path[1]).toEqual({ x: 1, y: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return start position for same start and end', () => {
|
||||||
|
const result = pathfinder.findPath(5, 5, 5, 5);
|
||||||
|
expect(result.found).toBe(true);
|
||||||
|
expect(result.path.length).toBe(1);
|
||||||
|
expect(result.path[0]).toEqual({ x: 5, y: 5 });
|
||||||
|
expect(result.cost).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should find diagonal path', () => {
|
||||||
|
const result = pathfinder.findPath(0, 0, 5, 5);
|
||||||
|
expect(result.found).toBe(true);
|
||||||
|
expect(result.path.length).toBeGreaterThan(1);
|
||||||
|
expect(result.path[0]).toEqual({ x: 0, y: 0 });
|
||||||
|
expect(result.path[result.path.length - 1]).toEqual({ x: 5, y: 5 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should find path across grid', () => {
|
||||||
|
const result = pathfinder.findPath(0, 0, 9, 9);
|
||||||
|
expect(result.found).toBe(true);
|
||||||
|
expect(result.path[0]).toEqual({ x: 0, y: 0 });
|
||||||
|
expect(result.path[result.path.length - 1]).toEqual({ x: 9, y: 9 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Obstacles
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
describe('obstacles', () => {
|
||||||
|
it('should find path around single obstacle', () => {
|
||||||
|
grid.setWalkable(5, 5, false);
|
||||||
|
const result = pathfinder.findPath(4, 5, 6, 5);
|
||||||
|
expect(result.found).toBe(true);
|
||||||
|
expect(result.path.length).toBeGreaterThan(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should find path around wall', () => {
|
||||||
|
// Create vertical wall
|
||||||
|
for (let y = 2; y <= 7; y++) {
|
||||||
|
grid.setWalkable(5, y, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = pathfinder.findPath(3, 5, 7, 5);
|
||||||
|
expect(result.found).toBe(true);
|
||||||
|
// Path should go around the wall
|
||||||
|
expect(result.path.every(p => p.x !== 5 || p.y < 2 || p.y > 7)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return empty path when blocked', () => {
|
||||||
|
// Block completely around start
|
||||||
|
grid.setWalkable(1, 0, false);
|
||||||
|
grid.setWalkable(0, 1, false);
|
||||||
|
grid.setWalkable(1, 1, false);
|
||||||
|
|
||||||
|
const result = pathfinder.findPath(0, 0, 9, 9);
|
||||||
|
expect(result.found).toBe(false);
|
||||||
|
expect(result.path.length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return empty path when start is blocked', () => {
|
||||||
|
grid.setWalkable(0, 0, false);
|
||||||
|
const result = pathfinder.findPath(0, 0, 5, 5);
|
||||||
|
expect(result.found).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return empty path when end is blocked', () => {
|
||||||
|
grid.setWalkable(5, 5, false);
|
||||||
|
const result = pathfinder.findPath(0, 0, 5, 5);
|
||||||
|
expect(result.found).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Out of Bounds
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
describe('out of bounds', () => {
|
||||||
|
it('should return empty path for out of bounds start', () => {
|
||||||
|
const result = pathfinder.findPath(-1, 0, 5, 5);
|
||||||
|
expect(result.found).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return empty path for out of bounds end', () => {
|
||||||
|
const result = pathfinder.findPath(0, 0, 100, 100);
|
||||||
|
expect(result.found).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Cost Calculation
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
describe('cost calculation', () => {
|
||||||
|
it('should calculate correct cost for straight path', () => {
|
||||||
|
const grid4 = new GridMap(10, 10, { allowDiagonal: false });
|
||||||
|
const pathfinder4 = new AStarPathfinder(grid4);
|
||||||
|
|
||||||
|
const result = pathfinder4.findPath(0, 0, 5, 0);
|
||||||
|
expect(result.found).toBe(true);
|
||||||
|
expect(result.cost).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should prefer lower cost paths', () => {
|
||||||
|
// Create high cost area
|
||||||
|
for (let y = 0; y < 10; y++) {
|
||||||
|
grid.setCost(5, y, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = pathfinder.findPath(4, 5, 6, 5);
|
||||||
|
// Should go around the high cost column if possible
|
||||||
|
expect(result.found).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Options
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
describe('options', () => {
|
||||||
|
it('should respect maxNodes limit', () => {
|
||||||
|
// Large grid with path
|
||||||
|
const largeGrid = new GridMap(100, 100);
|
||||||
|
const largePF = new AStarPathfinder(largeGrid);
|
||||||
|
|
||||||
|
const result = largePF.findPath(0, 0, 99, 99, { maxNodes: 10 });
|
||||||
|
// Should fail due to node limit
|
||||||
|
expect(result.nodesSearched).toBeLessThanOrEqual(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use heuristic weight', () => {
|
||||||
|
const result1 = pathfinder.findPath(0, 0, 9, 9, { heuristicWeight: 1.0 });
|
||||||
|
const result2 = pathfinder.findPath(0, 0, 9, 9, { heuristicWeight: 2.0 });
|
||||||
|
|
||||||
|
expect(result1.found).toBe(true);
|
||||||
|
expect(result2.found).toBe(true);
|
||||||
|
// Higher weight may search fewer nodes but may not be optimal
|
||||||
|
expect(result2.nodesSearched).toBeLessThanOrEqual(result1.nodesSearched);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Clear
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
describe('clear', () => {
|
||||||
|
it('should allow reuse after clear', () => {
|
||||||
|
const result1 = pathfinder.findPath(0, 0, 5, 5);
|
||||||
|
expect(result1.found).toBe(true);
|
||||||
|
|
||||||
|
pathfinder.clear();
|
||||||
|
|
||||||
|
const result2 = pathfinder.findPath(0, 0, 9, 9);
|
||||||
|
expect(result2.found).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Maze
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
describe('maze solving', () => {
|
||||||
|
it('should solve simple maze', () => {
|
||||||
|
const mazeStr = `
|
||||||
|
..........
|
||||||
|
.########.
|
||||||
|
..........
|
||||||
|
.########.
|
||||||
|
..........
|
||||||
|
.########.
|
||||||
|
..........
|
||||||
|
.########.
|
||||||
|
..........
|
||||||
|
..........`.trim();
|
||||||
|
|
||||||
|
grid.loadFromString(mazeStr);
|
||||||
|
|
||||||
|
const result = pathfinder.findPath(0, 0, 9, 9);
|
||||||
|
expect(result.found).toBe(true);
|
||||||
|
// Path should not pass through walls
|
||||||
|
for (const point of result.path) {
|
||||||
|
expect(grid.isWalkable(point.x, point.y)).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Path Quality
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
describe('path quality', () => {
|
||||||
|
it('should find shortest path in open area', () => {
|
||||||
|
const result = pathfinder.findPath(0, 0, 3, 0);
|
||||||
|
expect(result.found).toBe(true);
|
||||||
|
// Straight line should be 4 points
|
||||||
|
expect(result.path.length).toBe(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should find optimal diagonal path', () => {
|
||||||
|
const result = pathfinder.findPath(0, 0, 3, 3);
|
||||||
|
expect(result.found).toBe(true);
|
||||||
|
// Pure diagonal should be 4 points
|
||||||
|
expect(result.path.length).toBe(4);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Nodes Searched
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
describe('nodesSearched', () => {
|
||||||
|
it('should track nodes searched', () => {
|
||||||
|
const result = pathfinder.findPath(0, 0, 9, 9);
|
||||||
|
expect(result.nodesSearched).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should search only 1 node for same position', () => {
|
||||||
|
const result = pathfinder.findPath(5, 5, 5, 5);
|
||||||
|
expect(result.nodesSearched).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
228
packages/framework/pathfinding/__tests__/core/BinaryHeap.test.ts
Normal file
228
packages/framework/pathfinding/__tests__/core/BinaryHeap.test.ts
Normal file
@@ -0,0 +1,228 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import { BinaryHeap } from '../../src/core/BinaryHeap';
|
||||||
|
|
||||||
|
describe('BinaryHeap', () => {
|
||||||
|
let heap: BinaryHeap<number>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
heap = new BinaryHeap<number>((a, b) => a - b);
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Basic Operations
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
describe('basic operations', () => {
|
||||||
|
it('should start empty', () => {
|
||||||
|
expect(heap.isEmpty).toBe(true);
|
||||||
|
expect(heap.size).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should push and pop single element', () => {
|
||||||
|
heap.push(5);
|
||||||
|
expect(heap.isEmpty).toBe(false);
|
||||||
|
expect(heap.size).toBe(1);
|
||||||
|
expect(heap.pop()).toBe(5);
|
||||||
|
expect(heap.isEmpty).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return undefined when popping empty heap', () => {
|
||||||
|
expect(heap.pop()).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should peek without removing', () => {
|
||||||
|
heap.push(5);
|
||||||
|
expect(heap.peek()).toBe(5);
|
||||||
|
expect(heap.size).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return undefined when peeking empty heap', () => {
|
||||||
|
expect(heap.peek()).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Min-Heap Property
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
describe('min-heap property', () => {
|
||||||
|
it('should always pop minimum element', () => {
|
||||||
|
heap.push(5);
|
||||||
|
heap.push(3);
|
||||||
|
heap.push(7);
|
||||||
|
heap.push(1);
|
||||||
|
heap.push(9);
|
||||||
|
|
||||||
|
expect(heap.pop()).toBe(1);
|
||||||
|
expect(heap.pop()).toBe(3);
|
||||||
|
expect(heap.pop()).toBe(5);
|
||||||
|
expect(heap.pop()).toBe(7);
|
||||||
|
expect(heap.pop()).toBe(9);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle duplicate values', () => {
|
||||||
|
heap.push(3);
|
||||||
|
heap.push(3);
|
||||||
|
heap.push(3);
|
||||||
|
|
||||||
|
expect(heap.pop()).toBe(3);
|
||||||
|
expect(heap.pop()).toBe(3);
|
||||||
|
expect(heap.pop()).toBe(3);
|
||||||
|
expect(heap.isEmpty).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle already sorted input', () => {
|
||||||
|
heap.push(1);
|
||||||
|
heap.push(2);
|
||||||
|
heap.push(3);
|
||||||
|
heap.push(4);
|
||||||
|
heap.push(5);
|
||||||
|
|
||||||
|
expect(heap.pop()).toBe(1);
|
||||||
|
expect(heap.pop()).toBe(2);
|
||||||
|
expect(heap.pop()).toBe(3);
|
||||||
|
expect(heap.pop()).toBe(4);
|
||||||
|
expect(heap.pop()).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle reverse sorted input', () => {
|
||||||
|
heap.push(5);
|
||||||
|
heap.push(4);
|
||||||
|
heap.push(3);
|
||||||
|
heap.push(2);
|
||||||
|
heap.push(1);
|
||||||
|
|
||||||
|
expect(heap.pop()).toBe(1);
|
||||||
|
expect(heap.pop()).toBe(2);
|
||||||
|
expect(heap.pop()).toBe(3);
|
||||||
|
expect(heap.pop()).toBe(4);
|
||||||
|
expect(heap.pop()).toBe(5);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Update Operation
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
describe('update operation', () => {
|
||||||
|
it('should update element position after value change', () => {
|
||||||
|
interface Item { value: number }
|
||||||
|
const itemHeap = new BinaryHeap<Item>((a, b) => a.value - b.value);
|
||||||
|
|
||||||
|
const item1 = { value: 5 };
|
||||||
|
const item2 = { value: 3 };
|
||||||
|
const item3 = { value: 7 };
|
||||||
|
|
||||||
|
itemHeap.push(item1);
|
||||||
|
itemHeap.push(item2);
|
||||||
|
itemHeap.push(item3);
|
||||||
|
|
||||||
|
// Change item1 to be smallest
|
||||||
|
item1.value = 1;
|
||||||
|
itemHeap.update(item1);
|
||||||
|
|
||||||
|
expect(itemHeap.pop()).toBe(item1);
|
||||||
|
expect(itemHeap.pop()).toBe(item2);
|
||||||
|
expect(itemHeap.pop()).toBe(item3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle update of non-existent element gracefully', () => {
|
||||||
|
heap.push(1);
|
||||||
|
heap.push(2);
|
||||||
|
heap.update(999); // Should not throw
|
||||||
|
expect(heap.size).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Contains Operation
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
describe('contains operation', () => {
|
||||||
|
it('should check if element exists', () => {
|
||||||
|
heap.push(1);
|
||||||
|
heap.push(2);
|
||||||
|
heap.push(3);
|
||||||
|
|
||||||
|
expect(heap.contains(2)).toBe(true);
|
||||||
|
expect(heap.contains(5)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false for empty heap', () => {
|
||||||
|
expect(heap.contains(1)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Clear Operation
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
describe('clear operation', () => {
|
||||||
|
it('should clear all elements', () => {
|
||||||
|
heap.push(1);
|
||||||
|
heap.push(2);
|
||||||
|
heap.push(3);
|
||||||
|
|
||||||
|
heap.clear();
|
||||||
|
|
||||||
|
expect(heap.isEmpty).toBe(true);
|
||||||
|
expect(heap.size).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Custom Comparator
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
describe('custom comparator', () => {
|
||||||
|
it('should work as max-heap with reversed comparator', () => {
|
||||||
|
const maxHeap = new BinaryHeap<number>((a, b) => b - a);
|
||||||
|
|
||||||
|
maxHeap.push(5);
|
||||||
|
maxHeap.push(3);
|
||||||
|
maxHeap.push(7);
|
||||||
|
maxHeap.push(1);
|
||||||
|
maxHeap.push(9);
|
||||||
|
|
||||||
|
expect(maxHeap.pop()).toBe(9);
|
||||||
|
expect(maxHeap.pop()).toBe(7);
|
||||||
|
expect(maxHeap.pop()).toBe(5);
|
||||||
|
expect(maxHeap.pop()).toBe(3);
|
||||||
|
expect(maxHeap.pop()).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should work with object comparator', () => {
|
||||||
|
interface Task { priority: number; name: string }
|
||||||
|
const taskHeap = new BinaryHeap<Task>((a, b) => a.priority - b.priority);
|
||||||
|
|
||||||
|
taskHeap.push({ priority: 3, name: 'C' });
|
||||||
|
taskHeap.push({ priority: 1, name: 'A' });
|
||||||
|
taskHeap.push({ priority: 2, name: 'B' });
|
||||||
|
|
||||||
|
expect(taskHeap.pop()?.name).toBe('A');
|
||||||
|
expect(taskHeap.pop()?.name).toBe('B');
|
||||||
|
expect(taskHeap.pop()?.name).toBe('C');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Large Dataset
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
describe('large dataset', () => {
|
||||||
|
it('should handle 1000 random elements', () => {
|
||||||
|
const elements: number[] = [];
|
||||||
|
for (let i = 0; i < 1000; i++) {
|
||||||
|
const value = Math.floor(Math.random() * 10000);
|
||||||
|
elements.push(value);
|
||||||
|
heap.push(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
elements.sort((a, b) => a - b);
|
||||||
|
|
||||||
|
for (const expected of elements) {
|
||||||
|
expect(heap.pop()).toBe(expected);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
219
packages/framework/pathfinding/__tests__/core/Heuristics.test.ts
Normal file
219
packages/framework/pathfinding/__tests__/core/Heuristics.test.ts
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import {
|
||||||
|
manhattanDistance,
|
||||||
|
euclideanDistance,
|
||||||
|
chebyshevDistance,
|
||||||
|
octileDistance,
|
||||||
|
createPoint
|
||||||
|
} from '../../src/core/IPathfinding';
|
||||||
|
|
||||||
|
describe('Heuristic Functions', () => {
|
||||||
|
// =========================================================================
|
||||||
|
// Manhattan Distance
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
describe('manhattanDistance', () => {
|
||||||
|
it('should return 0 for same point', () => {
|
||||||
|
const p = createPoint(5, 5);
|
||||||
|
expect(manhattanDistance(p, p)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should calculate horizontal distance', () => {
|
||||||
|
const a = createPoint(0, 0);
|
||||||
|
const b = createPoint(5, 0);
|
||||||
|
expect(manhattanDistance(a, b)).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should calculate vertical distance', () => {
|
||||||
|
const a = createPoint(0, 0);
|
||||||
|
const b = createPoint(0, 5);
|
||||||
|
expect(manhattanDistance(a, b)).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should calculate diagonal distance', () => {
|
||||||
|
const a = createPoint(0, 0);
|
||||||
|
const b = createPoint(3, 4);
|
||||||
|
expect(manhattanDistance(a, b)).toBe(7); // |3| + |4| = 7
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle negative coordinates', () => {
|
||||||
|
const a = createPoint(-2, -3);
|
||||||
|
const b = createPoint(2, 3);
|
||||||
|
expect(manhattanDistance(a, b)).toBe(10); // |4| + |6| = 10
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be symmetric', () => {
|
||||||
|
const a = createPoint(1, 2);
|
||||||
|
const b = createPoint(4, 6);
|
||||||
|
expect(manhattanDistance(a, b)).toBe(manhattanDistance(b, a));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Euclidean Distance
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
describe('euclideanDistance', () => {
|
||||||
|
it('should return 0 for same point', () => {
|
||||||
|
const p = createPoint(5, 5);
|
||||||
|
expect(euclideanDistance(p, p)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should calculate horizontal distance', () => {
|
||||||
|
const a = createPoint(0, 0);
|
||||||
|
const b = createPoint(5, 0);
|
||||||
|
expect(euclideanDistance(a, b)).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should calculate vertical distance', () => {
|
||||||
|
const a = createPoint(0, 0);
|
||||||
|
const b = createPoint(0, 5);
|
||||||
|
expect(euclideanDistance(a, b)).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should calculate 3-4-5 triangle', () => {
|
||||||
|
const a = createPoint(0, 0);
|
||||||
|
const b = createPoint(3, 4);
|
||||||
|
expect(euclideanDistance(a, b)).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should calculate diagonal distance', () => {
|
||||||
|
const a = createPoint(0, 0);
|
||||||
|
const b = createPoint(1, 1);
|
||||||
|
expect(euclideanDistance(a, b)).toBeCloseTo(Math.SQRT2, 10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle negative coordinates', () => {
|
||||||
|
const a = createPoint(-3, -4);
|
||||||
|
const b = createPoint(0, 0);
|
||||||
|
expect(euclideanDistance(a, b)).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be symmetric', () => {
|
||||||
|
const a = createPoint(1, 2);
|
||||||
|
const b = createPoint(4, 6);
|
||||||
|
expect(euclideanDistance(a, b)).toBeCloseTo(euclideanDistance(b, a), 10);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Chebyshev Distance
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
describe('chebyshevDistance', () => {
|
||||||
|
it('should return 0 for same point', () => {
|
||||||
|
const p = createPoint(5, 5);
|
||||||
|
expect(chebyshevDistance(p, p)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should calculate horizontal distance', () => {
|
||||||
|
const a = createPoint(0, 0);
|
||||||
|
const b = createPoint(5, 0);
|
||||||
|
expect(chebyshevDistance(a, b)).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should calculate vertical distance', () => {
|
||||||
|
const a = createPoint(0, 0);
|
||||||
|
const b = createPoint(0, 5);
|
||||||
|
expect(chebyshevDistance(a, b)).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should calculate diagonal as max of dx, dy', () => {
|
||||||
|
const a = createPoint(0, 0);
|
||||||
|
const b = createPoint(3, 4);
|
||||||
|
expect(chebyshevDistance(a, b)).toBe(4); // max(3, 4) = 4
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return same value for equal dx and dy', () => {
|
||||||
|
const a = createPoint(0, 0);
|
||||||
|
const b = createPoint(5, 5);
|
||||||
|
expect(chebyshevDistance(a, b)).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be symmetric', () => {
|
||||||
|
const a = createPoint(1, 2);
|
||||||
|
const b = createPoint(4, 6);
|
||||||
|
expect(chebyshevDistance(a, b)).toBe(chebyshevDistance(b, a));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Octile Distance
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
describe('octileDistance', () => {
|
||||||
|
it('should return 0 for same point', () => {
|
||||||
|
const p = createPoint(5, 5);
|
||||||
|
expect(octileDistance(p, p)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should calculate horizontal distance', () => {
|
||||||
|
const a = createPoint(0, 0);
|
||||||
|
const b = createPoint(5, 0);
|
||||||
|
expect(octileDistance(a, b)).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should calculate vertical distance', () => {
|
||||||
|
const a = createPoint(0, 0);
|
||||||
|
const b = createPoint(0, 5);
|
||||||
|
expect(octileDistance(a, b)).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should calculate pure diagonal distance', () => {
|
||||||
|
const a = createPoint(0, 0);
|
||||||
|
const b = createPoint(5, 5);
|
||||||
|
// 5 diagonal moves = 5 * sqrt(2)
|
||||||
|
expect(octileDistance(a, b)).toBeCloseTo(5 * Math.SQRT2, 10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should calculate mixed diagonal and straight', () => {
|
||||||
|
const a = createPoint(0, 0);
|
||||||
|
const b = createPoint(3, 5);
|
||||||
|
// 3 diagonal + 2 straight = 3*sqrt(2) + 2
|
||||||
|
const expected = 3 * Math.SQRT2 + 2;
|
||||||
|
expect(octileDistance(a, b)).toBeCloseTo(expected, 10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be symmetric', () => {
|
||||||
|
const a = createPoint(1, 2);
|
||||||
|
const b = createPoint(4, 6);
|
||||||
|
expect(octileDistance(a, b)).toBeCloseTo(octileDistance(b, a), 10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be between Manhattan and Euclidean for diagonal', () => {
|
||||||
|
const a = createPoint(0, 0);
|
||||||
|
const b = createPoint(3, 4);
|
||||||
|
|
||||||
|
const manhattan = manhattanDistance(a, b);
|
||||||
|
const euclidean = euclideanDistance(a, b);
|
||||||
|
const octile = octileDistance(a, b);
|
||||||
|
|
||||||
|
expect(octile).toBeLessThan(manhattan);
|
||||||
|
expect(octile).toBeGreaterThan(euclidean);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// createPoint
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
describe('createPoint', () => {
|
||||||
|
it('should create point with correct coordinates', () => {
|
||||||
|
const p = createPoint(3, 4);
|
||||||
|
expect(p.x).toBe(3);
|
||||||
|
expect(p.y).toBe(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle negative coordinates', () => {
|
||||||
|
const p = createPoint(-5, -10);
|
||||||
|
expect(p.x).toBe(-5);
|
||||||
|
expect(p.y).toBe(-10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle decimal coordinates', () => {
|
||||||
|
const p = createPoint(3.5, 4.7);
|
||||||
|
expect(p.x).toBe(3.5);
|
||||||
|
expect(p.y).toBe(4.7);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
346
packages/framework/pathfinding/__tests__/grid/GridMap.test.ts
Normal file
346
packages/framework/pathfinding/__tests__/grid/GridMap.test.ts
Normal file
@@ -0,0 +1,346 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import { GridMap, GridNode, DIRECTIONS_4, DIRECTIONS_8 } from '../../src/grid/GridMap';
|
||||||
|
|
||||||
|
describe('GridMap', () => {
|
||||||
|
let grid: GridMap;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
grid = new GridMap(10, 10);
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Construction
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
describe('construction', () => {
|
||||||
|
it('should create grid with correct dimensions', () => {
|
||||||
|
expect(grid.width).toBe(10);
|
||||||
|
expect(grid.height).toBe(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have all nodes walkable by default', () => {
|
||||||
|
for (let y = 0; y < 10; y++) {
|
||||||
|
for (let x = 0; x < 10; x++) {
|
||||||
|
expect(grid.isWalkable(x, y)).toBe(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create small grid', () => {
|
||||||
|
const small = new GridMap(1, 1);
|
||||||
|
expect(small.width).toBe(1);
|
||||||
|
expect(small.height).toBe(1);
|
||||||
|
expect(small.getNodeAt(0, 0)).not.toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Node Access
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
describe('getNodeAt', () => {
|
||||||
|
it('should return node at valid position', () => {
|
||||||
|
const node = grid.getNodeAt(5, 5);
|
||||||
|
expect(node).not.toBeNull();
|
||||||
|
expect(node?.position.x).toBe(5);
|
||||||
|
expect(node?.position.y).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return null for out of bounds', () => {
|
||||||
|
expect(grid.getNodeAt(-1, 0)).toBeNull();
|
||||||
|
expect(grid.getNodeAt(0, -1)).toBeNull();
|
||||||
|
expect(grid.getNodeAt(10, 0)).toBeNull();
|
||||||
|
expect(grid.getNodeAt(0, 10)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return node with correct id', () => {
|
||||||
|
const node = grid.getNodeAt(3, 4);
|
||||||
|
expect(node?.id).toBe('3,4');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Walkability
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
describe('walkability', () => {
|
||||||
|
it('should set and get walkability', () => {
|
||||||
|
grid.setWalkable(5, 5, false);
|
||||||
|
expect(grid.isWalkable(5, 5)).toBe(false);
|
||||||
|
|
||||||
|
grid.setWalkable(5, 5, true);
|
||||||
|
expect(grid.isWalkable(5, 5)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false for out of bounds', () => {
|
||||||
|
expect(grid.isWalkable(-1, 0)).toBe(false);
|
||||||
|
expect(grid.isWalkable(100, 100)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle setWalkable on invalid position gracefully', () => {
|
||||||
|
grid.setWalkable(-1, -1, false); // Should not throw
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Cost
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
describe('cost', () => {
|
||||||
|
it('should set and get cost', () => {
|
||||||
|
grid.setCost(5, 5, 2.5);
|
||||||
|
const node = grid.getNodeAt(5, 5);
|
||||||
|
expect(node?.cost).toBe(2.5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should default cost to 1', () => {
|
||||||
|
const node = grid.getNodeAt(5, 5);
|
||||||
|
expect(node?.cost).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Neighbors (8-direction)
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
describe('getNeighbors (8-direction)', () => {
|
||||||
|
it('should return 8 neighbors for center node', () => {
|
||||||
|
const node = grid.getNodeAt(5, 5)!;
|
||||||
|
const neighbors = grid.getNeighbors(node);
|
||||||
|
expect(neighbors.length).toBe(8);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 3 neighbors for corner', () => {
|
||||||
|
const node = grid.getNodeAt(0, 0)!;
|
||||||
|
const neighbors = grid.getNeighbors(node);
|
||||||
|
expect(neighbors.length).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 5 neighbors for edge', () => {
|
||||||
|
const node = grid.getNodeAt(5, 0)!;
|
||||||
|
const neighbors = grid.getNeighbors(node);
|
||||||
|
expect(neighbors.length).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not include blocked neighbors', () => {
|
||||||
|
grid.setWalkable(6, 5, false);
|
||||||
|
const node = grid.getNodeAt(5, 5)!;
|
||||||
|
const neighbors = grid.getNeighbors(node);
|
||||||
|
// 8 - 1 blocked - 2 diagonals (corner cutting) = 5
|
||||||
|
expect(neighbors.length).toBe(5);
|
||||||
|
expect(neighbors.find(n => n.x === 6 && n.y === 5)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should avoid corner cutting by default', () => {
|
||||||
|
// Block horizontal neighbor
|
||||||
|
grid.setWalkable(6, 5, false);
|
||||||
|
const node = grid.getNodeAt(5, 5)!;
|
||||||
|
const neighbors = grid.getNeighbors(node);
|
||||||
|
|
||||||
|
// Should not include diagonal (6,4) and (6,6) due to corner cutting
|
||||||
|
expect(neighbors.find(n => n.x === 6 && n.y === 4)).toBeUndefined();
|
||||||
|
expect(neighbors.find(n => n.x === 6 && n.y === 6)).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Neighbors (4-direction)
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
describe('getNeighbors (4-direction)', () => {
|
||||||
|
let grid4: GridMap;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
grid4 = new GridMap(10, 10, { allowDiagonal: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 4 neighbors for center node', () => {
|
||||||
|
const node = grid4.getNodeAt(5, 5)!;
|
||||||
|
const neighbors = grid4.getNeighbors(node);
|
||||||
|
expect(neighbors.length).toBe(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 2 neighbors for corner', () => {
|
||||||
|
const node = grid4.getNodeAt(0, 0)!;
|
||||||
|
const neighbors = grid4.getNeighbors(node);
|
||||||
|
expect(neighbors.length).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 3 neighbors for edge', () => {
|
||||||
|
const node = grid4.getNodeAt(5, 0)!;
|
||||||
|
const neighbors = grid4.getNeighbors(node);
|
||||||
|
expect(neighbors.length).toBe(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Movement Cost
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
describe('getMovementCost', () => {
|
||||||
|
it('should return 1 for cardinal movement', () => {
|
||||||
|
const from = grid.getNodeAt(5, 5)!;
|
||||||
|
const to = grid.getNodeAt(6, 5)!;
|
||||||
|
expect(grid.getMovementCost(from, to)).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return sqrt(2) for diagonal movement', () => {
|
||||||
|
const from = grid.getNodeAt(5, 5)!;
|
||||||
|
const to = grid.getNodeAt(6, 6)!;
|
||||||
|
expect(grid.getMovementCost(from, to)).toBeCloseTo(Math.SQRT2, 10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should factor in destination cost', () => {
|
||||||
|
grid.setCost(6, 5, 2);
|
||||||
|
const from = grid.getNodeAt(5, 5)!;
|
||||||
|
const to = grid.getNodeAt(6, 5)!;
|
||||||
|
expect(grid.getMovementCost(from, to)).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Load from Array
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
describe('loadFromArray', () => {
|
||||||
|
it('should load walkability from 2D array', () => {
|
||||||
|
const data = [
|
||||||
|
[0, 0, 1],
|
||||||
|
[0, 1, 0],
|
||||||
|
[1, 0, 0]
|
||||||
|
];
|
||||||
|
const small = new GridMap(3, 3);
|
||||||
|
small.loadFromArray(data);
|
||||||
|
|
||||||
|
expect(small.isWalkable(0, 0)).toBe(true);
|
||||||
|
expect(small.isWalkable(2, 0)).toBe(false);
|
||||||
|
expect(small.isWalkable(1, 1)).toBe(false);
|
||||||
|
expect(small.isWalkable(0, 2)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle partial data', () => {
|
||||||
|
const data = [[0, 1]];
|
||||||
|
grid.loadFromArray(data);
|
||||||
|
expect(grid.isWalkable(0, 0)).toBe(true);
|
||||||
|
expect(grid.isWalkable(1, 0)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Load from String
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
describe('loadFromString', () => {
|
||||||
|
it('should load walkability from string', () => {
|
||||||
|
const mapStr = `
|
||||||
|
..#
|
||||||
|
.#.
|
||||||
|
#..`.trim();
|
||||||
|
|
||||||
|
const small = new GridMap(3, 3);
|
||||||
|
small.loadFromString(mapStr);
|
||||||
|
|
||||||
|
expect(small.isWalkable(0, 0)).toBe(true);
|
||||||
|
expect(small.isWalkable(2, 0)).toBe(false);
|
||||||
|
expect(small.isWalkable(1, 1)).toBe(false);
|
||||||
|
expect(small.isWalkable(0, 2)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// toString
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
describe('toString', () => {
|
||||||
|
it('should export grid as string', () => {
|
||||||
|
const small = new GridMap(3, 2);
|
||||||
|
small.setWalkable(1, 0, false);
|
||||||
|
|
||||||
|
const expected = '.#.\n...\n';
|
||||||
|
expect(small.toString()).toBe(expected);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Reset
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
describe('reset', () => {
|
||||||
|
it('should reset all nodes to walkable', () => {
|
||||||
|
grid.setWalkable(5, 5, false);
|
||||||
|
grid.setCost(3, 3, 5);
|
||||||
|
grid.reset();
|
||||||
|
|
||||||
|
expect(grid.isWalkable(5, 5)).toBe(true);
|
||||||
|
expect(grid.getNodeAt(3, 3)?.cost).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// setRectWalkable
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
describe('setRectWalkable', () => {
|
||||||
|
it('should set rectangle region walkability', () => {
|
||||||
|
grid.setRectWalkable(2, 2, 3, 3, false);
|
||||||
|
|
||||||
|
for (let y = 2; y < 5; y++) {
|
||||||
|
for (let x = 2; x < 5; x++) {
|
||||||
|
expect(grid.isWalkable(x, y)).toBe(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Outside should still be walkable
|
||||||
|
expect(grid.isWalkable(1, 2)).toBe(true);
|
||||||
|
expect(grid.isWalkable(5, 2)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Bounds Checking
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
describe('isInBounds', () => {
|
||||||
|
it('should return true for valid coordinates', () => {
|
||||||
|
expect(grid.isInBounds(0, 0)).toBe(true);
|
||||||
|
expect(grid.isInBounds(9, 9)).toBe(true);
|
||||||
|
expect(grid.isInBounds(5, 5)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false for invalid coordinates', () => {
|
||||||
|
expect(grid.isInBounds(-1, 0)).toBe(false);
|
||||||
|
expect(grid.isInBounds(0, -1)).toBe(false);
|
||||||
|
expect(grid.isInBounds(10, 0)).toBe(false);
|
||||||
|
expect(grid.isInBounds(0, 10)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GridNode', () => {
|
||||||
|
it('should create node with correct properties', () => {
|
||||||
|
const node = new GridNode(3, 4, true, 2);
|
||||||
|
expect(node.x).toBe(3);
|
||||||
|
expect(node.y).toBe(4);
|
||||||
|
expect(node.walkable).toBe(true);
|
||||||
|
expect(node.cost).toBe(2);
|
||||||
|
expect(node.id).toBe('3,4');
|
||||||
|
expect(node.position.x).toBe(3);
|
||||||
|
expect(node.position.y).toBe(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should default to walkable with cost 1', () => {
|
||||||
|
const node = new GridNode(0, 0);
|
||||||
|
expect(node.walkable).toBe(true);
|
||||||
|
expect(node.cost).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Direction Constants', () => {
|
||||||
|
it('DIRECTIONS_4 should have 4 cardinal directions', () => {
|
||||||
|
expect(DIRECTIONS_4.length).toBe(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('DIRECTIONS_8 should have 8 directions', () => {
|
||||||
|
expect(DIRECTIONS_8.length).toBe(8);
|
||||||
|
});
|
||||||
|
});
|
||||||
386
packages/framework/pathfinding/__tests__/navmesh/NavMesh.test.ts
Normal file
386
packages/framework/pathfinding/__tests__/navmesh/NavMesh.test.ts
Normal file
@@ -0,0 +1,386 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import { NavMesh, createNavMesh } from '../../src/navmesh/NavMesh';
|
||||||
|
import { createPoint } from '../../src/core/IPathfinding';
|
||||||
|
|
||||||
|
describe('NavMesh', () => {
|
||||||
|
let navmesh: NavMesh;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
navmesh = new NavMesh();
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Polygon Management
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
describe('polygon management', () => {
|
||||||
|
it('should add polygon and return id', () => {
|
||||||
|
const id = navmesh.addPolygon([
|
||||||
|
createPoint(0, 0),
|
||||||
|
createPoint(10, 0),
|
||||||
|
createPoint(10, 10),
|
||||||
|
createPoint(0, 10)
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(id).toBe(0);
|
||||||
|
expect(navmesh.polygonCount).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should add multiple polygons with incremental ids', () => {
|
||||||
|
const id1 = navmesh.addPolygon([
|
||||||
|
createPoint(0, 0),
|
||||||
|
createPoint(10, 0),
|
||||||
|
createPoint(5, 10)
|
||||||
|
]);
|
||||||
|
|
||||||
|
const id2 = navmesh.addPolygon([
|
||||||
|
createPoint(10, 0),
|
||||||
|
createPoint(20, 0),
|
||||||
|
createPoint(15, 10)
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(id1).toBe(0);
|
||||||
|
expect(id2).toBe(1);
|
||||||
|
expect(navmesh.polygonCount).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should get all polygons', () => {
|
||||||
|
navmesh.addPolygon([
|
||||||
|
createPoint(0, 0),
|
||||||
|
createPoint(10, 0),
|
||||||
|
createPoint(5, 10)
|
||||||
|
]);
|
||||||
|
|
||||||
|
navmesh.addPolygon([
|
||||||
|
createPoint(10, 0),
|
||||||
|
createPoint(20, 0),
|
||||||
|
createPoint(15, 10)
|
||||||
|
]);
|
||||||
|
|
||||||
|
const polygons = navmesh.getPolygons();
|
||||||
|
expect(polygons.length).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should clear all polygons', () => {
|
||||||
|
navmesh.addPolygon([
|
||||||
|
createPoint(0, 0),
|
||||||
|
createPoint(10, 0),
|
||||||
|
createPoint(5, 10)
|
||||||
|
]);
|
||||||
|
|
||||||
|
navmesh.clear();
|
||||||
|
expect(navmesh.polygonCount).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Point in Polygon
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
describe('findPolygonAt', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
// Square from (0,0) to (10,10)
|
||||||
|
navmesh.addPolygon([
|
||||||
|
createPoint(0, 0),
|
||||||
|
createPoint(10, 0),
|
||||||
|
createPoint(10, 10),
|
||||||
|
createPoint(0, 10)
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should find polygon containing point', () => {
|
||||||
|
const polygon = navmesh.findPolygonAt(5, 5);
|
||||||
|
expect(polygon).not.toBeNull();
|
||||||
|
expect(polygon?.id).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return null for point outside', () => {
|
||||||
|
expect(navmesh.findPolygonAt(-1, 5)).toBeNull();
|
||||||
|
expect(navmesh.findPolygonAt(15, 5)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle point on edge', () => {
|
||||||
|
const polygon = navmesh.findPolygonAt(0, 5);
|
||||||
|
// Edge behavior may vary, but should not crash
|
||||||
|
expect(polygon === null || polygon.id === 0).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Walkability
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
describe('isWalkable', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
navmesh.addPolygon([
|
||||||
|
createPoint(0, 0),
|
||||||
|
createPoint(10, 0),
|
||||||
|
createPoint(10, 10),
|
||||||
|
createPoint(0, 10)
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return true for point in polygon', () => {
|
||||||
|
expect(navmesh.isWalkable(5, 5)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false for point outside', () => {
|
||||||
|
expect(navmesh.isWalkable(15, 5)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Connections
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
describe('connections', () => {
|
||||||
|
it('should manually set connection between polygons', () => {
|
||||||
|
// Two adjacent squares
|
||||||
|
const id1 = navmesh.addPolygon([
|
||||||
|
createPoint(0, 0),
|
||||||
|
createPoint(10, 0),
|
||||||
|
createPoint(10, 10),
|
||||||
|
createPoint(0, 10)
|
||||||
|
]);
|
||||||
|
|
||||||
|
const id2 = navmesh.addPolygon([
|
||||||
|
createPoint(10, 0),
|
||||||
|
createPoint(20, 0),
|
||||||
|
createPoint(20, 10),
|
||||||
|
createPoint(10, 10)
|
||||||
|
]);
|
||||||
|
|
||||||
|
navmesh.setConnection(id1, id2, {
|
||||||
|
left: createPoint(10, 0),
|
||||||
|
right: createPoint(10, 10)
|
||||||
|
});
|
||||||
|
|
||||||
|
const polygons = navmesh.getPolygons();
|
||||||
|
const poly1 = polygons.find(p => p.id === id1);
|
||||||
|
|
||||||
|
expect(poly1?.neighbors).toContain(id2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should auto-detect shared edges with build()', () => {
|
||||||
|
// Two adjacent squares sharing edge at x=10
|
||||||
|
navmesh.addPolygon([
|
||||||
|
createPoint(0, 0),
|
||||||
|
createPoint(10, 0),
|
||||||
|
createPoint(10, 10),
|
||||||
|
createPoint(0, 10)
|
||||||
|
]);
|
||||||
|
|
||||||
|
navmesh.addPolygon([
|
||||||
|
createPoint(10, 0),
|
||||||
|
createPoint(20, 0),
|
||||||
|
createPoint(20, 10),
|
||||||
|
createPoint(10, 10)
|
||||||
|
]);
|
||||||
|
|
||||||
|
navmesh.build();
|
||||||
|
|
||||||
|
const polygons = navmesh.getPolygons();
|
||||||
|
expect(polygons[0].neighbors).toContain(1);
|
||||||
|
expect(polygons[1].neighbors).toContain(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Pathfinding
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
describe('findPath', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
// Create 3 connected squares
|
||||||
|
navmesh.addPolygon([
|
||||||
|
createPoint(0, 0),
|
||||||
|
createPoint(10, 0),
|
||||||
|
createPoint(10, 10),
|
||||||
|
createPoint(0, 10)
|
||||||
|
]);
|
||||||
|
|
||||||
|
navmesh.addPolygon([
|
||||||
|
createPoint(10, 0),
|
||||||
|
createPoint(20, 0),
|
||||||
|
createPoint(20, 10),
|
||||||
|
createPoint(10, 10)
|
||||||
|
]);
|
||||||
|
|
||||||
|
navmesh.addPolygon([
|
||||||
|
createPoint(20, 0),
|
||||||
|
createPoint(30, 0),
|
||||||
|
createPoint(30, 10),
|
||||||
|
createPoint(20, 10)
|
||||||
|
]);
|
||||||
|
|
||||||
|
navmesh.build();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should find path within same polygon', () => {
|
||||||
|
const result = navmesh.findPath(1, 1, 8, 8);
|
||||||
|
expect(result.found).toBe(true);
|
||||||
|
expect(result.path.length).toBe(2);
|
||||||
|
expect(result.path[0]).toEqual(createPoint(1, 1));
|
||||||
|
expect(result.path[1]).toEqual(createPoint(8, 8));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should find path across polygons', () => {
|
||||||
|
const result = navmesh.findPath(5, 5, 25, 5);
|
||||||
|
expect(result.found).toBe(true);
|
||||||
|
expect(result.path.length).toBeGreaterThanOrEqual(2);
|
||||||
|
expect(result.path[0]).toEqual(createPoint(5, 5));
|
||||||
|
expect(result.path[result.path.length - 1]).toEqual(createPoint(25, 5));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return empty path when start is outside', () => {
|
||||||
|
const result = navmesh.findPath(-5, 5, 15, 5);
|
||||||
|
expect(result.found).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return empty path when end is outside', () => {
|
||||||
|
const result = navmesh.findPath(5, 5, 50, 5);
|
||||||
|
expect(result.found).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should calculate path cost', () => {
|
||||||
|
const result = navmesh.findPath(5, 5, 25, 5);
|
||||||
|
expect(result.found).toBe(true);
|
||||||
|
expect(result.cost).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should track nodes searched', () => {
|
||||||
|
const result = navmesh.findPath(5, 5, 25, 5);
|
||||||
|
expect(result.nodesSearched).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// IPathfindingMap Interface
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
describe('IPathfindingMap interface', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
// Two adjacent squares with shared edge at x=10
|
||||||
|
const id1 = navmesh.addPolygon([
|
||||||
|
createPoint(0, 0),
|
||||||
|
createPoint(10, 0),
|
||||||
|
createPoint(10, 10),
|
||||||
|
createPoint(0, 10)
|
||||||
|
]);
|
||||||
|
|
||||||
|
const id2 = navmesh.addPolygon([
|
||||||
|
createPoint(10, 0),
|
||||||
|
createPoint(20, 0),
|
||||||
|
createPoint(20, 10),
|
||||||
|
createPoint(10, 10)
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Manual connection to ensure proper setup
|
||||||
|
navmesh.setConnection(id1, id2, {
|
||||||
|
left: createPoint(10, 0),
|
||||||
|
right: createPoint(10, 10)
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return node at position', () => {
|
||||||
|
const node = navmesh.getNodeAt(5, 5);
|
||||||
|
expect(node).not.toBeNull();
|
||||||
|
expect(node?.id).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return null for position outside', () => {
|
||||||
|
const node = navmesh.getNodeAt(50, 50);
|
||||||
|
expect(node).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should get neighbors from polygon directly', () => {
|
||||||
|
// NavMeshNode holds a reference to the original polygon,
|
||||||
|
// so we check via the polygons map which is updated by setConnection
|
||||||
|
const polygons = navmesh.getPolygons();
|
||||||
|
const poly0 = polygons.find(p => p.id === 0);
|
||||||
|
expect(poly0).toBeDefined();
|
||||||
|
expect(poly0!.neighbors).toContain(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should calculate heuristic', () => {
|
||||||
|
const a = createPoint(0, 0);
|
||||||
|
const b = createPoint(3, 4);
|
||||||
|
expect(navmesh.heuristic(a, b)).toBe(5); // Euclidean
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should calculate movement cost', () => {
|
||||||
|
const node1 = navmesh.getNodeAt(5, 5)!;
|
||||||
|
const node2 = navmesh.getNodeAt(15, 5)!;
|
||||||
|
const cost = navmesh.getMovementCost(node1, node2);
|
||||||
|
expect(cost).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Complex Scenarios
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
describe('complex scenarios', () => {
|
||||||
|
it('should handle L-shaped navmesh with manual connections', () => {
|
||||||
|
// Horizontal part
|
||||||
|
const id1 = navmesh.addPolygon([
|
||||||
|
createPoint(0, 0),
|
||||||
|
createPoint(30, 0),
|
||||||
|
createPoint(30, 10),
|
||||||
|
createPoint(0, 10)
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Vertical part (shares partial edge, needs manual connection)
|
||||||
|
const id2 = navmesh.addPolygon([
|
||||||
|
createPoint(0, 10),
|
||||||
|
createPoint(10, 10),
|
||||||
|
createPoint(10, 30),
|
||||||
|
createPoint(0, 30)
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Manual connection since edges don't match exactly
|
||||||
|
navmesh.setConnection(id1, id2, {
|
||||||
|
left: createPoint(0, 10),
|
||||||
|
right: createPoint(10, 10)
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = navmesh.findPath(25, 5, 5, 25);
|
||||||
|
expect(result.found).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle disconnected areas', () => {
|
||||||
|
// Area 1
|
||||||
|
navmesh.addPolygon([
|
||||||
|
createPoint(0, 0),
|
||||||
|
createPoint(10, 0),
|
||||||
|
createPoint(10, 10),
|
||||||
|
createPoint(0, 10)
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Area 2 (disconnected)
|
||||||
|
navmesh.addPolygon([
|
||||||
|
createPoint(50, 50),
|
||||||
|
createPoint(60, 50),
|
||||||
|
createPoint(60, 60),
|
||||||
|
createPoint(50, 60)
|
||||||
|
]);
|
||||||
|
|
||||||
|
navmesh.build();
|
||||||
|
|
||||||
|
const result = navmesh.findPath(5, 5, 55, 55);
|
||||||
|
expect(result.found).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Factory Function
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
describe('createNavMesh', () => {
|
||||||
|
it('should create empty navmesh', () => {
|
||||||
|
const nm = createNavMesh();
|
||||||
|
expect(nm).toBeInstanceOf(NavMesh);
|
||||||
|
expect(nm.polygonCount).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,288 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import {
|
||||||
|
bresenhamLineOfSight,
|
||||||
|
raycastLineOfSight,
|
||||||
|
LineOfSightSmoother,
|
||||||
|
CatmullRomSmoother,
|
||||||
|
CombinedSmoother
|
||||||
|
} from '../../src/smoothing/PathSmoother';
|
||||||
|
import { GridMap } from '../../src/grid/GridMap';
|
||||||
|
import { createPoint, type IPoint } from '../../src/core/IPathfinding';
|
||||||
|
|
||||||
|
describe('Line of Sight Functions', () => {
|
||||||
|
let grid: GridMap;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
grid = new GridMap(10, 10);
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// bresenhamLineOfSight
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
describe('bresenhamLineOfSight', () => {
|
||||||
|
it('should return true for clear line', () => {
|
||||||
|
expect(bresenhamLineOfSight(0, 0, 5, 5, grid)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return true for same point', () => {
|
||||||
|
expect(bresenhamLineOfSight(5, 5, 5, 5, grid)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return true for horizontal line', () => {
|
||||||
|
expect(bresenhamLineOfSight(0, 5, 9, 5, grid)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return true for vertical line', () => {
|
||||||
|
expect(bresenhamLineOfSight(5, 0, 5, 9, grid)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false when blocked', () => {
|
||||||
|
grid.setWalkable(5, 5, false);
|
||||||
|
expect(bresenhamLineOfSight(0, 0, 9, 9, grid)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false when start is blocked', () => {
|
||||||
|
grid.setWalkable(0, 0, false);
|
||||||
|
expect(bresenhamLineOfSight(0, 0, 5, 5, grid)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false when end is blocked', () => {
|
||||||
|
grid.setWalkable(5, 5, false);
|
||||||
|
expect(bresenhamLineOfSight(0, 0, 5, 5, grid)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should detect obstacle in middle', () => {
|
||||||
|
grid.setWalkable(3, 3, false);
|
||||||
|
expect(bresenhamLineOfSight(0, 0, 6, 6, grid)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// raycastLineOfSight
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
describe('raycastLineOfSight', () => {
|
||||||
|
it('should return true for clear line', () => {
|
||||||
|
expect(raycastLineOfSight(0, 0, 5, 5, grid)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return true for same point', () => {
|
||||||
|
expect(raycastLineOfSight(5, 5, 5, 5, grid)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false when blocked', () => {
|
||||||
|
grid.setWalkable(5, 5, false);
|
||||||
|
expect(raycastLineOfSight(0, 0, 9, 9, grid)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should work with custom step size', () => {
|
||||||
|
expect(raycastLineOfSight(0, 0, 5, 5, grid, 0.1)).toBe(true);
|
||||||
|
grid.setWalkable(2, 2, false);
|
||||||
|
expect(raycastLineOfSight(0, 0, 5, 5, grid, 0.1)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('LineOfSightSmoother', () => {
|
||||||
|
let grid: GridMap;
|
||||||
|
let smoother: LineOfSightSmoother;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
grid = new GridMap(20, 20);
|
||||||
|
smoother = new LineOfSightSmoother();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return same path for 2 or fewer points', () => {
|
||||||
|
const path1: IPoint[] = [createPoint(0, 0)];
|
||||||
|
expect(smoother.smooth(path1, grid)).toEqual(path1);
|
||||||
|
|
||||||
|
const path2: IPoint[] = [createPoint(0, 0), createPoint(5, 5)];
|
||||||
|
expect(smoother.smooth(path2, grid)).toEqual(path2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should remove unnecessary waypoints on straight line', () => {
|
||||||
|
const path: IPoint[] = [
|
||||||
|
createPoint(0, 0),
|
||||||
|
createPoint(1, 0),
|
||||||
|
createPoint(2, 0),
|
||||||
|
createPoint(3, 0),
|
||||||
|
createPoint(4, 0),
|
||||||
|
createPoint(5, 0)
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = smoother.smooth(path, grid);
|
||||||
|
expect(result.length).toBe(2);
|
||||||
|
expect(result[0]).toEqual(createPoint(0, 0));
|
||||||
|
expect(result[1]).toEqual(createPoint(5, 0));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should remove unnecessary waypoints on diagonal', () => {
|
||||||
|
const path: IPoint[] = [
|
||||||
|
createPoint(0, 0),
|
||||||
|
createPoint(1, 1),
|
||||||
|
createPoint(2, 2),
|
||||||
|
createPoint(3, 3),
|
||||||
|
createPoint(4, 4),
|
||||||
|
createPoint(5, 5)
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = smoother.smooth(path, grid);
|
||||||
|
expect(result.length).toBe(2);
|
||||||
|
expect(result[0]).toEqual(createPoint(0, 0));
|
||||||
|
expect(result[1]).toEqual(createPoint(5, 5));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should keep waypoints around obstacles', () => {
|
||||||
|
// Create obstacle
|
||||||
|
grid.setWalkable(5, 5, false);
|
||||||
|
|
||||||
|
const path: IPoint[] = [
|
||||||
|
createPoint(0, 0),
|
||||||
|
createPoint(4, 5),
|
||||||
|
createPoint(6, 5),
|
||||||
|
createPoint(10, 10)
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = smoother.smooth(path, grid);
|
||||||
|
// Should keep at least start, one waypoint near obstacle, and end
|
||||||
|
expect(result.length).toBeGreaterThanOrEqual(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use custom line of sight function', () => {
|
||||||
|
const customLOS = (x1: number, y1: number, x2: number, y2: number) => {
|
||||||
|
// Always blocked
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const customSmoother = new LineOfSightSmoother(customLOS);
|
||||||
|
const path: IPoint[] = [
|
||||||
|
createPoint(0, 0),
|
||||||
|
createPoint(1, 1),
|
||||||
|
createPoint(2, 2)
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = customSmoother.smooth(path, grid);
|
||||||
|
// Should not simplify because LOS always fails
|
||||||
|
expect(result).toEqual(path);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('CatmullRomSmoother', () => {
|
||||||
|
let grid: GridMap;
|
||||||
|
let smoother: CatmullRomSmoother;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
grid = new GridMap(20, 20);
|
||||||
|
smoother = new CatmullRomSmoother(5, 0.5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return same path for 2 or fewer points', () => {
|
||||||
|
const path1: IPoint[] = [createPoint(0, 0)];
|
||||||
|
expect(smoother.smooth(path1, grid)).toEqual(path1);
|
||||||
|
|
||||||
|
const path2: IPoint[] = [createPoint(0, 0), createPoint(5, 5)];
|
||||||
|
expect(smoother.smooth(path2, grid)).toEqual(path2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should add interpolation points', () => {
|
||||||
|
const path: IPoint[] = [
|
||||||
|
createPoint(0, 0),
|
||||||
|
createPoint(5, 0),
|
||||||
|
createPoint(10, 0)
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = smoother.smooth(path, grid);
|
||||||
|
// Should have more points due to interpolation
|
||||||
|
expect(result.length).toBeGreaterThan(path.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should preserve start and end points', () => {
|
||||||
|
const path: IPoint[] = [
|
||||||
|
createPoint(0, 0),
|
||||||
|
createPoint(5, 5),
|
||||||
|
createPoint(10, 0)
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = smoother.smooth(path, grid);
|
||||||
|
expect(result[0].x).toBeCloseTo(0, 1);
|
||||||
|
expect(result[0].y).toBeCloseTo(0, 1);
|
||||||
|
expect(result[result.length - 1]).toEqual(createPoint(10, 0));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create smooth curve', () => {
|
||||||
|
const path: IPoint[] = [
|
||||||
|
createPoint(0, 0),
|
||||||
|
createPoint(5, 5),
|
||||||
|
createPoint(10, 0)
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = smoother.smooth(path, grid);
|
||||||
|
|
||||||
|
// Check that middle points are near the original waypoint
|
||||||
|
const middlePoints = result.filter(p =>
|
||||||
|
Math.abs(p.x - 5) < 2 && Math.abs(p.y - 5) < 2
|
||||||
|
);
|
||||||
|
expect(middlePoints.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should work with different segment counts', () => {
|
||||||
|
const smootherLow = new CatmullRomSmoother(2);
|
||||||
|
const smootherHigh = new CatmullRomSmoother(10);
|
||||||
|
|
||||||
|
const path: IPoint[] = [
|
||||||
|
createPoint(0, 0),
|
||||||
|
createPoint(5, 5),
|
||||||
|
createPoint(10, 0)
|
||||||
|
];
|
||||||
|
|
||||||
|
const resultLow = smootherLow.smooth(path, grid);
|
||||||
|
const resultHigh = smootherHigh.smooth(path, grid);
|
||||||
|
|
||||||
|
expect(resultHigh.length).toBeGreaterThan(resultLow.length);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('CombinedSmoother', () => {
|
||||||
|
let grid: GridMap;
|
||||||
|
let smoother: CombinedSmoother;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
grid = new GridMap(20, 20);
|
||||||
|
smoother = new CombinedSmoother(5, 0.5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should first simplify then curve smooth', () => {
|
||||||
|
// Path with redundant points
|
||||||
|
const path: IPoint[] = [
|
||||||
|
createPoint(0, 0),
|
||||||
|
createPoint(1, 0),
|
||||||
|
createPoint(2, 0),
|
||||||
|
createPoint(3, 0),
|
||||||
|
createPoint(4, 0),
|
||||||
|
createPoint(5, 0),
|
||||||
|
createPoint(6, 3),
|
||||||
|
createPoint(7, 6),
|
||||||
|
createPoint(8, 6),
|
||||||
|
createPoint(9, 6),
|
||||||
|
createPoint(10, 6)
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = smoother.smooth(path, grid);
|
||||||
|
|
||||||
|
// Should have smoothed the path
|
||||||
|
expect(result.length).toBeGreaterThan(0);
|
||||||
|
expect(result[0].x).toBeCloseTo(0, 1);
|
||||||
|
expect(result[result.length - 1]).toEqual(createPoint(10, 6));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle simple path', () => {
|
||||||
|
const path: IPoint[] = [
|
||||||
|
createPoint(0, 0),
|
||||||
|
createPoint(10, 10)
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = smoother.smooth(path, grid);
|
||||||
|
expect(result.length).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@esengine/pathfinding",
|
"name": "@esengine/pathfinding",
|
||||||
"version": "1.0.3",
|
"version": "1.1.0",
|
||||||
"description": "寻路系统 | Pathfinding System - A*, Grid, NavMesh",
|
"description": "寻路系统 | Pathfinding System - A*, Grid, NavMesh",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "./dist/index.js",
|
"main": "./dist/index.js",
|
||||||
@@ -20,6 +20,8 @@
|
|||||||
"build": "tsup",
|
"build": "tsup",
|
||||||
"build:watch": "tsup --watch",
|
"build:watch": "tsup --watch",
|
||||||
"type-check": "tsc --noEmit",
|
"type-check": "tsc --noEmit",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest",
|
||||||
"clean": "rimraf dist"
|
"clean": "rimraf dist"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -27,7 +29,8 @@
|
|||||||
"@esengine/ecs-framework-math": "workspace:*",
|
"@esengine/ecs-framework-math": "workspace:*",
|
||||||
"@esengine/blueprint": "workspace:*",
|
"@esengine/blueprint": "workspace:*",
|
||||||
"tsup": "^8.0.0",
|
"tsup": "^8.0.0",
|
||||||
"typescript": "^5.8.0"
|
"typescript": "^5.8.0",
|
||||||
|
"vitest": "^2.1.9"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@esengine/ecs-framework": "workspace:*",
|
"@esengine/ecs-framework": "workspace:*",
|
||||||
|
|||||||
7
packages/framework/pathfinding/vitest.config.ts
Normal file
7
packages/framework/pathfinding/vitest.config.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
include: ['__tests__/**/*.test.ts'],
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -1,5 +1,21 @@
|
|||||||
# @esengine/rpc
|
# @esengine/rpc
|
||||||
|
|
||||||
|
## 1.1.1
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- [#374](https://github.com/esengine/esengine/pull/374) [`a000cc0`](https://github.com/esengine/esengine/commit/a000cc07d7cebe8ccbfa983fde610296bfba2f1b) Thanks [@esengine](https://github.com/esengine)! - feat: export RpcClient and connect from main entry point
|
||||||
|
|
||||||
|
Re-export `RpcClient`, `connect`, and related types from the main entry point for better compatibility with bundlers (Cocos Creator, Vite, etc.) that may have issues with subpath exports.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Now works in all environments:
|
||||||
|
import { rpc, RpcClient, connect } from '@esengine/rpc';
|
||||||
|
|
||||||
|
// Subpath import still supported:
|
||||||
|
import { RpcClient } from '@esengine/rpc/client';
|
||||||
|
```
|
||||||
|
|
||||||
## 1.1.0
|
## 1.1.0
|
||||||
|
|
||||||
### Minor Changes
|
### Minor Changes
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@esengine/rpc",
|
"name": "@esengine/rpc",
|
||||||
"version": "1.1.0",
|
"version": "1.1.1",
|
||||||
"description": "Elegant type-safe RPC library for ESEngine",
|
"description": "Elegant type-safe RPC library for ESEngine",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "./dist/index.js",
|
"main": "./dist/index.js",
|
||||||
|
|||||||
@@ -40,3 +40,7 @@
|
|||||||
|
|
||||||
export { rpc } from './define'
|
export { rpc } from './define'
|
||||||
export * from './types'
|
export * from './types'
|
||||||
|
|
||||||
|
// Re-export client for browser/bundler compatibility
|
||||||
|
export { RpcClient, connect } from './client/index'
|
||||||
|
export type { RpcClientOptions, WebSocketAdapter, WebSocketFactory } from './client/index'
|
||||||
|
|||||||
82
packages/framework/server/CHANGELOG.md
Normal file
82
packages/framework/server/CHANGELOG.md
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
# @esengine/server
|
||||||
|
|
||||||
|
## 1.3.0
|
||||||
|
|
||||||
|
### Minor Changes
|
||||||
|
|
||||||
|
- [#388](https://github.com/esengine/esengine/pull/388) [`afdeb00`](https://github.com/esengine/esengine/commit/afdeb00b4df9427e7f03b91558bf95804a837b70) Thanks [@esengine](https://github.com/esengine)! - feat(server): 添加可插拔速率限制系统 | add pluggable rate limiting system
|
||||||
|
- 新增令牌桶策略 (`TokenBucketStrategy`) - 推荐用于一般场景
|
||||||
|
- 新增滑动窗口策略 (`SlidingWindowStrategy`) - 精确跟踪
|
||||||
|
- 新增固定窗口策略 (`FixedWindowStrategy`) - 简单高效
|
||||||
|
- 新增房间速率限制 mixin (`withRateLimit`)
|
||||||
|
- 新增速率限制装饰器 (`@rateLimit`, `@noRateLimit`)
|
||||||
|
- 新增按消息类型限流装饰器 (`@rateLimitMessage`, `@noRateLimitMessage`)
|
||||||
|
- 支持与认证系统组合使用
|
||||||
|
- 导出路径: `@esengine/server/ratelimit`
|
||||||
|
|
||||||
|
## 1.2.0
|
||||||
|
|
||||||
|
### Minor Changes
|
||||||
|
|
||||||
|
- [#386](https://github.com/esengine/esengine/pull/386) [`61a13ba`](https://github.com/esengine/esengine/commit/61a13baca2e1e8fba14e23d439521ec0e6b7ca6e) Thanks [@esengine](https://github.com/esengine)! - feat(server): 添加可插拔认证系统 | add pluggable authentication system
|
||||||
|
- 新增 JWT 认证提供者 (`createJwtAuthProvider`)
|
||||||
|
- 新增 Session 认证提供者 (`createSessionAuthProvider`)
|
||||||
|
- 新增服务器认证 mixin (`withAuth`)
|
||||||
|
- 新增房间认证 mixin (`withRoomAuth`)
|
||||||
|
- 新增认证装饰器 (`@requireAuth`, `@requireRole`)
|
||||||
|
- 新增测试工具 (`MockAuthProvider`)
|
||||||
|
- 导出路径: `@esengine/server/auth`, `@esengine/server/auth/testing`
|
||||||
|
|
||||||
|
## 1.1.4
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- Updated dependencies [[`a000cc0`](https://github.com/esengine/esengine/commit/a000cc07d7cebe8ccbfa983fde610296bfba2f1b)]:
|
||||||
|
- @esengine/rpc@1.1.1
|
||||||
|
|
||||||
|
## 1.1.3
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- [#372](https://github.com/esengine/esengine/pull/372) [`9c41181`](https://github.com/esengine/esengine/commit/9c4118187539e39ead48ef2fa7af3ff45285fde5) Thanks [@esengine](https://github.com/esengine)! - fix: expose `id` property on ServerConnection type
|
||||||
|
|
||||||
|
TypeScript was not properly resolving the inherited `id` property from the base `Connection` interface in some module resolution scenarios. This fix explicitly declares the `id` property on `ServerConnection` to ensure it's always visible to consumers.
|
||||||
|
|
||||||
|
## 1.1.2
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- [#370](https://github.com/esengine/esengine/pull/370) [`18df9d1`](https://github.com/esengine/esengine/commit/18df9d1cda4d4cf3095841d93125f9d41ce214f1) Thanks [@esengine](https://github.com/esengine)! - fix: allow define() to be called before start()
|
||||||
|
|
||||||
|
Previously, calling `server.define()` before `server.start()` would throw an error because `roomManager` was initialized inside `start()`. This fix moves the `roomManager` initialization to `createServer()`, allowing the expected usage pattern:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const server = await createServer({ port: 3000 });
|
||||||
|
server.define('world', WorldRoom); // Now works correctly
|
||||||
|
await server.start();
|
||||||
|
```
|
||||||
|
|
||||||
|
## 1.1.1
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- [#368](https://github.com/esengine/esengine/pull/368) [`66d5dc2`](https://github.com/esengine/esengine/commit/66d5dc27f740cc81b0645bde61dabf665743a5a0) Thanks [@esengine](https://github.com/esengine)! - fix: 修复发布缺少 dist 目录 | fix missing dist in published packages
|
||||||
|
|
||||||
|
## 1.1.0
|
||||||
|
|
||||||
|
### Minor Changes
|
||||||
|
|
||||||
|
- [#366](https://github.com/esengine/esengine/pull/366) [`b6f1235`](https://github.com/esengine/esengine/commit/b6f1235239c049abc62b6827554eb941e73dae65) Thanks [@esengine](https://github.com/esengine)! - feat(server): 添加游戏服务器框架与房间系统 | add game server framework with Room system
|
||||||
|
|
||||||
|
**@esengine/server** - 游戏服务器框架 | Game server framework
|
||||||
|
- 文件路由系统 | File-based routing
|
||||||
|
- Room 生命周期管理 (onCreate, onJoin, onLeave, onTick, onDispose) | Room lifecycle management
|
||||||
|
- `@onMessage` 装饰器处理消息 | Message handler decorator
|
||||||
|
- 玩家管理与断线处理 | Player management with auto-disconnect
|
||||||
|
- 内置 JoinRoom/LeaveRoom API | Built-in room APIs
|
||||||
|
- defineApi/defineMsg 类型安全辅助函数 | Type-safe helper functions
|
||||||
|
|
||||||
|
**create-esengine-server** - CLI 脚手架工具 | CLI scaffolding tool
|
||||||
|
- 生成 shared/server/client 项目结构 | Creates project structure
|
||||||
|
- 类型安全的协议定义 | Type-safe protocol definitions
|
||||||
|
- 包含 GameRoom 示例实现 | Includes example implementation
|
||||||
21
packages/framework/server/module.json
Normal file
21
packages/framework/server/module.json
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"id": "server",
|
||||||
|
"name": "@esengine/server",
|
||||||
|
"globalKey": "server",
|
||||||
|
"displayName": "Game Server",
|
||||||
|
"description": "Game server framework with file-based routing | 基于文件路由的游戏服务器框架",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"category": "Network",
|
||||||
|
"icon": "Server",
|
||||||
|
"tags": ["server", "rpc", "websocket", "network"],
|
||||||
|
"isCore": false,
|
||||||
|
"defaultEnabled": false,
|
||||||
|
"isEngineModule": true,
|
||||||
|
"canContainContent": false,
|
||||||
|
"platforms": ["nodejs"],
|
||||||
|
"dependencies": ["rpc"],
|
||||||
|
"exports": {
|
||||||
|
"functions": ["createServer", "defineApi", "defineMsg"]
|
||||||
|
},
|
||||||
|
"outputPath": "dist/index.js"
|
||||||
|
}
|
||||||
77
packages/framework/server/package.json
Normal file
77
packages/framework/server/package.json
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
{
|
||||||
|
"name": "@esengine/server",
|
||||||
|
"version": "1.3.0",
|
||||||
|
"description": "Game server framework for ESEngine with file-based routing",
|
||||||
|
"type": "module",
|
||||||
|
"main": "./dist/index.js",
|
||||||
|
"module": "./dist/index.js",
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"import": "./dist/index.js",
|
||||||
|
"types": "./dist/index.d.ts"
|
||||||
|
},
|
||||||
|
"./auth": {
|
||||||
|
"import": "./dist/auth/index.js",
|
||||||
|
"types": "./dist/auth/index.d.ts"
|
||||||
|
},
|
||||||
|
"./auth/testing": {
|
||||||
|
"import": "./dist/auth/testing/index.js",
|
||||||
|
"types": "./dist/auth/testing/index.d.ts"
|
||||||
|
},
|
||||||
|
"./ratelimit": {
|
||||||
|
"import": "./dist/ratelimit/index.js",
|
||||||
|
"types": "./dist/ratelimit/index.d.ts"
|
||||||
|
},
|
||||||
|
"./testing": {
|
||||||
|
"import": "./dist/testing/index.js",
|
||||||
|
"types": "./dist/testing/index.d.ts"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"dist",
|
||||||
|
"module.json"
|
||||||
|
],
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsup",
|
||||||
|
"build:watch": "tsup --watch",
|
||||||
|
"dev": "tsup --watch",
|
||||||
|
"type-check": "tsc --noEmit",
|
||||||
|
"clean": "rimraf dist",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@esengine/rpc": "workspace:*"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"ws": ">=8.0.0",
|
||||||
|
"jsonwebtoken": ">=9.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"jsonwebtoken": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/jsonwebtoken": "^9.0.0",
|
||||||
|
"@types/node": "^20.0.0",
|
||||||
|
"@types/ws": "^8.5.13",
|
||||||
|
"jsonwebtoken": "^9.0.0",
|
||||||
|
"rimraf": "^5.0.0",
|
||||||
|
"tsup": "^8.0.0",
|
||||||
|
"typescript": "^5.7.0",
|
||||||
|
"vitest": "^2.0.0",
|
||||||
|
"ws": "^8.18.0"
|
||||||
|
},
|
||||||
|
"publishConfig": {
|
||||||
|
"access": "public"
|
||||||
|
},
|
||||||
|
"keywords": [
|
||||||
|
"esengine",
|
||||||
|
"game-server",
|
||||||
|
"rpc",
|
||||||
|
"websocket",
|
||||||
|
"ecs"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import { MockAuthProvider, createMockAuthProvider, type MockUser } from '../testing/MockAuthProvider';
|
||||||
|
|
||||||
|
describe('MockAuthProvider', () => {
|
||||||
|
const testUsers: MockUser[] = [
|
||||||
|
{ id: '1', name: 'Alice', roles: ['player'] },
|
||||||
|
{ id: '2', name: 'Bob', roles: ['admin', 'player'] },
|
||||||
|
{ id: '3', name: 'Charlie', roles: ['guest'] }
|
||||||
|
];
|
||||||
|
|
||||||
|
let provider: MockAuthProvider;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
provider = createMockAuthProvider({
|
||||||
|
users: testUsers
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('basic properties', () => {
|
||||||
|
it('should have name "mock"', () => {
|
||||||
|
expect(provider.name).toBe('mock');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('verify', () => {
|
||||||
|
it('should verify existing user by id (token)', async () => {
|
||||||
|
const result = await provider.verify('1');
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.user?.id).toBe('1');
|
||||||
|
expect(result.user?.name).toBe('Alice');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return user roles', async () => {
|
||||||
|
const result = await provider.verify('2');
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.user?.roles).toEqual(['admin', 'player']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fail for unknown user', async () => {
|
||||||
|
const result = await provider.verify('unknown');
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.errorCode).toBe('USER_NOT_FOUND');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fail for empty token', async () => {
|
||||||
|
const result = await provider.verify('');
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.errorCode).toBe('INVALID_TOKEN');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return expiresAt', async () => {
|
||||||
|
const result = await provider.verify('1');
|
||||||
|
expect(result.expiresAt).toBeTypeOf('number');
|
||||||
|
expect(result.expiresAt).toBeGreaterThan(Date.now());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('with defaultUser', () => {
|
||||||
|
it('should return default user for empty token', async () => {
|
||||||
|
const providerWithDefault = createMockAuthProvider({
|
||||||
|
defaultUser: { id: 'default', name: 'Guest' }
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await providerWithDefault.verify('');
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.user?.id).toBe('default');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('with autoCreate', () => {
|
||||||
|
it('should auto create user for unknown token', async () => {
|
||||||
|
const autoProvider = createMockAuthProvider({
|
||||||
|
autoCreate: true
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await autoProvider.verify('new-user-123');
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.user?.id).toBe('new-user-123');
|
||||||
|
expect(result.user?.name).toBe('User_new-user-123');
|
||||||
|
expect(result.user?.roles).toEqual(['guest']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should persist auto-created users', async () => {
|
||||||
|
const autoProvider = createMockAuthProvider({
|
||||||
|
autoCreate: true
|
||||||
|
});
|
||||||
|
|
||||||
|
await autoProvider.verify('auto-1');
|
||||||
|
const user = autoProvider.getUser('auto-1');
|
||||||
|
expect(user).toBeDefined();
|
||||||
|
expect(user?.id).toBe('auto-1');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('with validateToken', () => {
|
||||||
|
it('should validate token format', async () => {
|
||||||
|
const validatingProvider = createMockAuthProvider({
|
||||||
|
users: testUsers,
|
||||||
|
validateToken: (token) => token.length >= 1 && !token.includes('invalid')
|
||||||
|
});
|
||||||
|
|
||||||
|
const validResult = await validatingProvider.verify('1');
|
||||||
|
expect(validResult.success).toBe(true);
|
||||||
|
|
||||||
|
const invalidResult = await validatingProvider.verify('invalid-token');
|
||||||
|
expect(invalidResult.success).toBe(false);
|
||||||
|
expect(invalidResult.errorCode).toBe('INVALID_TOKEN');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('with delay', () => {
|
||||||
|
it('should add artificial delay', async () => {
|
||||||
|
const delayProvider = createMockAuthProvider({
|
||||||
|
users: testUsers,
|
||||||
|
delay: 50
|
||||||
|
});
|
||||||
|
|
||||||
|
const start = Date.now();
|
||||||
|
await delayProvider.verify('1');
|
||||||
|
const elapsed = Date.now() - start;
|
||||||
|
|
||||||
|
expect(elapsed).toBeGreaterThanOrEqual(45);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('refresh', () => {
|
||||||
|
it('should refresh token (returns same result as verify)', async () => {
|
||||||
|
const result = await provider.refresh('1');
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.user?.id).toBe('1');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('revoke', () => {
|
||||||
|
it('should revoke token', async () => {
|
||||||
|
const result1 = await provider.verify('1');
|
||||||
|
expect(result1.success).toBe(true);
|
||||||
|
|
||||||
|
const revoked = await provider.revoke('1');
|
||||||
|
expect(revoked).toBe(true);
|
||||||
|
|
||||||
|
const result2 = await provider.verify('1');
|
||||||
|
expect(result2.success).toBe(false);
|
||||||
|
expect(result2.errorCode).toBe('INVALID_TOKEN');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('user management', () => {
|
||||||
|
it('should add user', () => {
|
||||||
|
provider.addUser({ id: '4', name: 'Dave', roles: ['tester'] });
|
||||||
|
const user = provider.getUser('4');
|
||||||
|
expect(user?.name).toBe('Dave');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should remove user', () => {
|
||||||
|
const removed = provider.removeUser('1');
|
||||||
|
expect(removed).toBe(true);
|
||||||
|
|
||||||
|
const user = provider.getUser('1');
|
||||||
|
expect(user).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false when removing non-existent user', () => {
|
||||||
|
const removed = provider.removeUser('non-existent');
|
||||||
|
expect(removed).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should get all users', () => {
|
||||||
|
const users = provider.getUsers();
|
||||||
|
expect(users).toHaveLength(3);
|
||||||
|
expect(users.map(u => u.id)).toContain('1');
|
||||||
|
expect(users.map(u => u.id)).toContain('2');
|
||||||
|
expect(users.map(u => u.id)).toContain('3');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('clear', () => {
|
||||||
|
it('should reset to initial state', async () => {
|
||||||
|
provider.addUser({ id: '4', name: 'Dave' });
|
||||||
|
await provider.revoke('1');
|
||||||
|
|
||||||
|
provider.clear();
|
||||||
|
|
||||||
|
const users = provider.getUsers();
|
||||||
|
expect(users).toHaveLength(3);
|
||||||
|
|
||||||
|
const result = await provider.verify('1');
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('generateToken', () => {
|
||||||
|
it('should return user id as token', () => {
|
||||||
|
const token = provider.generateToken('user-123');
|
||||||
|
expect(token).toBe('user-123');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('createMockAuthProvider', () => {
|
||||||
|
it('should create provider with empty config', () => {
|
||||||
|
const provider = createMockAuthProvider();
|
||||||
|
expect(provider.name).toBe('mock');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create provider with custom users', () => {
|
||||||
|
const provider = createMockAuthProvider({
|
||||||
|
users: [{ id: 'test', name: 'Test User' }]
|
||||||
|
});
|
||||||
|
|
||||||
|
const user = provider.getUser('test');
|
||||||
|
expect(user?.name).toBe('Test User');
|
||||||
|
});
|
||||||
|
});
|
||||||
242
packages/framework/server/src/auth/__tests__/context.test.ts
Normal file
242
packages/framework/server/src/auth/__tests__/context.test.ts
Normal file
@@ -0,0 +1,242 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import { AuthContext, createGuestContext, createAuthContext, defaultUserExtractor } from '../context';
|
||||||
|
import type { AuthResult } from '../types';
|
||||||
|
|
||||||
|
describe('AuthContext', () => {
|
||||||
|
let context: AuthContext<{ id: string; name: string; roles: string[] }>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
context = new AuthContext();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('initial state', () => {
|
||||||
|
it('should not be authenticated initially', () => {
|
||||||
|
expect(context.isAuthenticated).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have null user initially', () => {
|
||||||
|
expect(context.user).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have null userId initially', () => {
|
||||||
|
expect(context.userId).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have empty roles initially', () => {
|
||||||
|
expect(context.roles).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have null authenticatedAt initially', () => {
|
||||||
|
expect(context.authenticatedAt).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have null expiresAt initially', () => {
|
||||||
|
expect(context.expiresAt).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('setAuthenticated', () => {
|
||||||
|
it('should set authenticated state on success', () => {
|
||||||
|
const result: AuthResult<{ id: string; name: string; roles: string[] }> = {
|
||||||
|
success: true,
|
||||||
|
user: { id: '123', name: 'Alice', roles: ['player'] }
|
||||||
|
};
|
||||||
|
|
||||||
|
context.setAuthenticated(result);
|
||||||
|
|
||||||
|
expect(context.isAuthenticated).toBe(true);
|
||||||
|
expect(context.user).toEqual({ id: '123', name: 'Alice', roles: ['player'] });
|
||||||
|
expect(context.userId).toBe('123');
|
||||||
|
expect(context.roles).toEqual(['player']);
|
||||||
|
expect(context.authenticatedAt).toBeTypeOf('number');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set expiresAt when provided', () => {
|
||||||
|
const expiresAt = Date.now() + 3600000;
|
||||||
|
const result: AuthResult<{ id: string; name: string; roles: string[] }> = {
|
||||||
|
success: true,
|
||||||
|
user: { id: '123', name: 'Alice', roles: [] },
|
||||||
|
expiresAt
|
||||||
|
};
|
||||||
|
|
||||||
|
context.setAuthenticated(result);
|
||||||
|
|
||||||
|
expect(context.expiresAt).toBe(expiresAt);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should clear state on failed result', () => {
|
||||||
|
context.setAuthenticated({
|
||||||
|
success: true,
|
||||||
|
user: { id: '123', name: 'Alice', roles: ['player'] }
|
||||||
|
});
|
||||||
|
|
||||||
|
context.setAuthenticated({
|
||||||
|
success: false,
|
||||||
|
error: 'Token expired'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(context.isAuthenticated).toBe(false);
|
||||||
|
expect(context.user).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should clear state when success but no user', () => {
|
||||||
|
context.setAuthenticated({
|
||||||
|
success: true
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(context.isAuthenticated).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isAuthenticated with expiry', () => {
|
||||||
|
it('should return false when token is expired', () => {
|
||||||
|
context.setAuthenticated({
|
||||||
|
success: true,
|
||||||
|
user: { id: '123', name: 'Alice', roles: [] },
|
||||||
|
expiresAt: Date.now() - 1000
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(context.isAuthenticated).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return true when token is not expired', () => {
|
||||||
|
context.setAuthenticated({
|
||||||
|
success: true,
|
||||||
|
user: { id: '123', name: 'Alice', roles: [] },
|
||||||
|
expiresAt: Date.now() + 3600000
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(context.isAuthenticated).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('role checking', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
context.setAuthenticated({
|
||||||
|
success: true,
|
||||||
|
user: { id: '123', name: 'Alice', roles: ['player', 'premium'] }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hasRole should return true for existing role', () => {
|
||||||
|
expect(context.hasRole('player')).toBe(true);
|
||||||
|
expect(context.hasRole('premium')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hasRole should return false for non-existing role', () => {
|
||||||
|
expect(context.hasRole('admin')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hasAnyRole should return true if any role matches', () => {
|
||||||
|
expect(context.hasAnyRole(['admin', 'player'])).toBe(true);
|
||||||
|
expect(context.hasAnyRole(['guest', 'premium'])).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hasAnyRole should return false if no role matches', () => {
|
||||||
|
expect(context.hasAnyRole(['admin', 'moderator'])).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hasAllRoles should return true if all roles match', () => {
|
||||||
|
expect(context.hasAllRoles(['player', 'premium'])).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hasAllRoles should return false if any role is missing', () => {
|
||||||
|
expect(context.hasAllRoles(['player', 'admin'])).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('clear', () => {
|
||||||
|
it('should reset all state', () => {
|
||||||
|
context.setAuthenticated({
|
||||||
|
success: true,
|
||||||
|
user: { id: '123', name: 'Alice', roles: ['player'] },
|
||||||
|
expiresAt: Date.now() + 3600000
|
||||||
|
});
|
||||||
|
|
||||||
|
context.clear();
|
||||||
|
|
||||||
|
expect(context.isAuthenticated).toBe(false);
|
||||||
|
expect(context.user).toBeNull();
|
||||||
|
expect(context.userId).toBeNull();
|
||||||
|
expect(context.roles).toEqual([]);
|
||||||
|
expect(context.authenticatedAt).toBeNull();
|
||||||
|
expect(context.expiresAt).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('defaultUserExtractor', () => {
|
||||||
|
describe('getId', () => {
|
||||||
|
it('should extract id from user object', () => {
|
||||||
|
expect(defaultUserExtractor.getId({ id: '123' })).toBe('123');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should extract numeric id as string', () => {
|
||||||
|
expect(defaultUserExtractor.getId({ id: 456 })).toBe('456');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should extract userId', () => {
|
||||||
|
expect(defaultUserExtractor.getId({ userId: 'abc' })).toBe('abc');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should extract sub (JWT standard)', () => {
|
||||||
|
expect(defaultUserExtractor.getId({ sub: 'jwt-sub' })).toBe('jwt-sub');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return empty string for invalid user', () => {
|
||||||
|
expect(defaultUserExtractor.getId(null)).toBe('');
|
||||||
|
expect(defaultUserExtractor.getId(undefined)).toBe('');
|
||||||
|
expect(defaultUserExtractor.getId({})).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getRoles', () => {
|
||||||
|
it('should extract roles array', () => {
|
||||||
|
expect(defaultUserExtractor.getRoles({ roles: ['a', 'b'] })).toEqual(['a', 'b']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should extract single role', () => {
|
||||||
|
expect(defaultUserExtractor.getRoles({ role: 'admin' })).toEqual(['admin']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should filter non-string roles', () => {
|
||||||
|
expect(defaultUserExtractor.getRoles({ roles: ['a', 123, 'b'] })).toEqual(['a', 'b']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return empty array for invalid user', () => {
|
||||||
|
expect(defaultUserExtractor.getRoles(null)).toEqual([]);
|
||||||
|
expect(defaultUserExtractor.getRoles({})).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('createGuestContext', () => {
|
||||||
|
it('should create unauthenticated context', () => {
|
||||||
|
const guest = createGuestContext();
|
||||||
|
expect(guest.isAuthenticated).toBe(false);
|
||||||
|
expect(guest.user).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('createAuthContext', () => {
|
||||||
|
it('should create authenticated context from result', () => {
|
||||||
|
const result: AuthResult<{ id: string }> = {
|
||||||
|
success: true,
|
||||||
|
user: { id: '123' }
|
||||||
|
};
|
||||||
|
|
||||||
|
const ctx = createAuthContext(result);
|
||||||
|
expect(ctx.isAuthenticated).toBe(true);
|
||||||
|
expect(ctx.userId).toBe('123');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create unauthenticated context from failed result', () => {
|
||||||
|
const result: AuthResult<{ id: string }> = {
|
||||||
|
success: false,
|
||||||
|
error: 'Failed'
|
||||||
|
};
|
||||||
|
|
||||||
|
const ctx = createAuthContext(result);
|
||||||
|
expect(ctx.isAuthenticated).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
165
packages/framework/server/src/auth/__tests__/decorators.test.ts
Normal file
165
packages/framework/server/src/auth/__tests__/decorators.test.ts
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import { requireAuth, AUTH_METADATA_KEY, getAuthMetadata, type AuthMetadata } from '../decorators/requireAuth';
|
||||||
|
import { requireRole } from '../decorators/requireRole';
|
||||||
|
|
||||||
|
describe('requireAuth decorator', () => {
|
||||||
|
class TestClass {
|
||||||
|
@requireAuth()
|
||||||
|
basicMethod() {
|
||||||
|
return 'basic';
|
||||||
|
}
|
||||||
|
|
||||||
|
@requireAuth({ allowGuest: true })
|
||||||
|
guestAllowedMethod() {
|
||||||
|
return 'guest';
|
||||||
|
}
|
||||||
|
|
||||||
|
@requireAuth({ errorMessage: 'Custom error' })
|
||||||
|
customErrorMethod() {
|
||||||
|
return 'custom';
|
||||||
|
}
|
||||||
|
|
||||||
|
undecorated() {
|
||||||
|
return 'undecorated';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let instance: TestClass;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
instance = new TestClass();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('metadata storage', () => {
|
||||||
|
it('should store auth metadata on target', () => {
|
||||||
|
const metadata = getAuthMetadata(TestClass.prototype, 'basicMethod');
|
||||||
|
expect(metadata).toBeDefined();
|
||||||
|
expect(metadata?.requireAuth).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should store options in metadata', () => {
|
||||||
|
const metadata = getAuthMetadata(TestClass.prototype, 'guestAllowedMethod');
|
||||||
|
expect(metadata?.options?.allowGuest).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should store custom error message', () => {
|
||||||
|
const metadata = getAuthMetadata(TestClass.prototype, 'customErrorMethod');
|
||||||
|
expect(metadata?.options?.errorMessage).toBe('Custom error');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return undefined for undecorated methods', () => {
|
||||||
|
const metadata = getAuthMetadata(TestClass.prototype, 'undecorated');
|
||||||
|
expect(metadata).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return undefined for non-existent methods', () => {
|
||||||
|
const metadata = getAuthMetadata(TestClass.prototype, 'nonExistent');
|
||||||
|
expect(metadata).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('method behavior', () => {
|
||||||
|
it('should not alter method behavior', () => {
|
||||||
|
expect(instance.basicMethod()).toBe('basic');
|
||||||
|
expect(instance.guestAllowedMethod()).toBe('guest');
|
||||||
|
expect(instance.customErrorMethod()).toBe('custom');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('metadata key', () => {
|
||||||
|
it('should use symbol for metadata storage', () => {
|
||||||
|
expect(typeof AUTH_METADATA_KEY).toBe('symbol');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should store metadata in a Map', () => {
|
||||||
|
const metadataMap = (TestClass.prototype as any)[AUTH_METADATA_KEY];
|
||||||
|
expect(metadataMap).toBeInstanceOf(Map);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('requireRole decorator', () => {
|
||||||
|
class RoleTestClass {
|
||||||
|
@requireRole('admin')
|
||||||
|
adminOnly() {
|
||||||
|
return 'admin';
|
||||||
|
}
|
||||||
|
|
||||||
|
@requireRole(['moderator', 'admin'])
|
||||||
|
modOrAdmin() {
|
||||||
|
return 'mod';
|
||||||
|
}
|
||||||
|
|
||||||
|
@requireRole(['verified', 'premium'], { mode: 'all' })
|
||||||
|
verifiedPremium() {
|
||||||
|
return 'vip';
|
||||||
|
}
|
||||||
|
|
||||||
|
@requireRole('player', { mode: 'any' })
|
||||||
|
playerExplicit() {
|
||||||
|
return 'player';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('single role', () => {
|
||||||
|
it('should store single role as array', () => {
|
||||||
|
const metadata = getAuthMetadata(RoleTestClass.prototype, 'adminOnly');
|
||||||
|
expect(metadata?.roles).toEqual(['admin']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set requireAuth to true', () => {
|
||||||
|
const metadata = getAuthMetadata(RoleTestClass.prototype, 'adminOnly');
|
||||||
|
expect(metadata?.requireAuth).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should default to any mode', () => {
|
||||||
|
const metadata = getAuthMetadata(RoleTestClass.prototype, 'adminOnly');
|
||||||
|
expect(metadata?.roleMode).toBe('any');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('multiple roles', () => {
|
||||||
|
it('should store multiple roles', () => {
|
||||||
|
const metadata = getAuthMetadata(RoleTestClass.prototype, 'modOrAdmin');
|
||||||
|
expect(metadata?.roles).toEqual(['moderator', 'admin']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('role mode', () => {
|
||||||
|
it('should support all mode', () => {
|
||||||
|
const metadata = getAuthMetadata(RoleTestClass.prototype, 'verifiedPremium');
|
||||||
|
expect(metadata?.roleMode).toBe('all');
|
||||||
|
expect(metadata?.roles).toEqual(['verified', 'premium']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should support explicit any mode', () => {
|
||||||
|
const metadata = getAuthMetadata(RoleTestClass.prototype, 'playerExplicit');
|
||||||
|
expect(metadata?.roleMode).toBe('any');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('method behavior', () => {
|
||||||
|
it('should not alter method behavior', () => {
|
||||||
|
const instance = new RoleTestClass();
|
||||||
|
expect(instance.adminOnly()).toBe('admin');
|
||||||
|
expect(instance.modOrAdmin()).toBe('mod');
|
||||||
|
expect(instance.verifiedPremium()).toBe('vip');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('combined decorators', () => {
|
||||||
|
class CombinedTestClass {
|
||||||
|
@requireAuth({ allowGuest: false })
|
||||||
|
@requireRole('admin')
|
||||||
|
combinedMethod() {
|
||||||
|
return 'combined';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
it('should merge metadata from both decorators', () => {
|
||||||
|
const metadata = getAuthMetadata(CombinedTestClass.prototype, 'combinedMethod');
|
||||||
|
expect(metadata?.requireAuth).toBe(true);
|
||||||
|
expect(metadata?.roles).toEqual(['admin']);
|
||||||
|
});
|
||||||
|
});
|
||||||
330
packages/framework/server/src/auth/__tests__/providers.test.ts
Normal file
330
packages/framework/server/src/auth/__tests__/providers.test.ts
Normal file
@@ -0,0 +1,330 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
|
import { JwtAuthProvider, createJwtAuthProvider } from '../providers/JwtAuthProvider';
|
||||||
|
import { SessionAuthProvider, createSessionAuthProvider, type ISessionStorage } from '../providers/SessionAuthProvider';
|
||||||
|
|
||||||
|
describe('JwtAuthProvider', () => {
|
||||||
|
const secret = 'test-secret-key-for-testing';
|
||||||
|
let provider: JwtAuthProvider<{ id: string; name: string; roles: string[] }>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
provider = createJwtAuthProvider({
|
||||||
|
secret,
|
||||||
|
expiresIn: 3600
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('sign and verify', () => {
|
||||||
|
it('should sign and verify a token', async () => {
|
||||||
|
const payload = { sub: '123', name: 'Alice', roles: ['player'] };
|
||||||
|
const token = provider.sign(payload);
|
||||||
|
|
||||||
|
expect(token).toBeTypeOf('string');
|
||||||
|
expect(token.split('.')).toHaveLength(3);
|
||||||
|
|
||||||
|
const result = await provider.verify(token);
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.user).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should extract user from payload', async () => {
|
||||||
|
const payload = { sub: '123', name: 'Alice' };
|
||||||
|
const token = provider.sign(payload);
|
||||||
|
|
||||||
|
const result = await provider.verify(token);
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect((result.user as any).sub).toBe('123');
|
||||||
|
expect((result.user as any).name).toBe('Alice');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return expiration time', async () => {
|
||||||
|
const token = provider.sign({ sub: '123' });
|
||||||
|
const result = await provider.verify(token);
|
||||||
|
|
||||||
|
expect(result.expiresAt).toBeTypeOf('number');
|
||||||
|
expect(result.expiresAt).toBeGreaterThan(Date.now());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('verify errors', () => {
|
||||||
|
it('should fail for empty token', async () => {
|
||||||
|
const result = await provider.verify('');
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.errorCode).toBe('INVALID_TOKEN');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fail for invalid token', async () => {
|
||||||
|
const result = await provider.verify('invalid.token.here');
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.errorCode).toBe('INVALID_TOKEN');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fail for expired token', async () => {
|
||||||
|
const shortLivedProvider = createJwtAuthProvider({
|
||||||
|
secret,
|
||||||
|
expiresIn: -1
|
||||||
|
});
|
||||||
|
|
||||||
|
const token = shortLivedProvider.sign({ sub: '123' });
|
||||||
|
const result = await shortLivedProvider.verify(token);
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.errorCode).toBe('EXPIRED_TOKEN');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fail for wrong secret', async () => {
|
||||||
|
const token = provider.sign({ sub: '123' });
|
||||||
|
|
||||||
|
const wrongSecretProvider = createJwtAuthProvider({
|
||||||
|
secret: 'wrong-secret'
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await wrongSecretProvider.verify(token);
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.errorCode).toBe('INVALID_TOKEN');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('with getUser callback', () => {
|
||||||
|
it('should use getUser to transform payload', async () => {
|
||||||
|
const customProvider = createJwtAuthProvider({
|
||||||
|
secret,
|
||||||
|
getUser: async (payload) => ({
|
||||||
|
id: payload.sub as string,
|
||||||
|
name: payload.name as string,
|
||||||
|
roles: (payload.roles as string[]) || []
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
const token = customProvider.sign({ sub: '123', name: 'Bob', roles: ['admin'] });
|
||||||
|
const result = await customProvider.verify(token);
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.user).toEqual({
|
||||||
|
id: '123',
|
||||||
|
name: 'Bob',
|
||||||
|
roles: ['admin']
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fail when getUser returns null', async () => {
|
||||||
|
const customProvider = createJwtAuthProvider({
|
||||||
|
secret,
|
||||||
|
getUser: async () => null
|
||||||
|
});
|
||||||
|
|
||||||
|
const token = customProvider.sign({ sub: '123' });
|
||||||
|
const result = await customProvider.verify(token);
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.errorCode).toBe('USER_NOT_FOUND');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('refresh', () => {
|
||||||
|
it('should refresh a valid token', async () => {
|
||||||
|
const token = provider.sign({ sub: '123', name: 'Alice' });
|
||||||
|
|
||||||
|
// Wait a bit so iat changes
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 1100));
|
||||||
|
|
||||||
|
const result = await provider.refresh(token);
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.token).toBeDefined();
|
||||||
|
expect(result.token).not.toBe(token);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return new expiration time', async () => {
|
||||||
|
const token = provider.sign({ sub: '123' });
|
||||||
|
const result = await provider.refresh(token);
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.expiresAt).toBeTypeOf('number');
|
||||||
|
expect(result.expiresAt).toBeGreaterThan(Date.now());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fail to refresh invalid token', async () => {
|
||||||
|
const result = await provider.refresh('invalid');
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('decode', () => {
|
||||||
|
it('should decode token without verification', () => {
|
||||||
|
const token = provider.sign({ sub: '123', name: 'Alice' });
|
||||||
|
const payload = provider.decode(token);
|
||||||
|
|
||||||
|
expect(payload).toBeDefined();
|
||||||
|
expect(payload?.sub).toBe('123');
|
||||||
|
expect(payload?.name).toBe('Alice');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return null for invalid token', () => {
|
||||||
|
const payload = provider.decode('not-a-token');
|
||||||
|
expect(payload).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('SessionAuthProvider', () => {
|
||||||
|
let storage: ISessionStorage;
|
||||||
|
let provider: SessionAuthProvider<{ id: string; name: string }>;
|
||||||
|
let storageData: Map<string, unknown>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
storageData = new Map();
|
||||||
|
|
||||||
|
storage = {
|
||||||
|
async get<T>(key: string): Promise<T | null> {
|
||||||
|
return (storageData.get(key) as T) ?? null;
|
||||||
|
},
|
||||||
|
async set<T>(key: string, value: T): Promise<void> {
|
||||||
|
storageData.set(key, value);
|
||||||
|
},
|
||||||
|
async delete(key: string): Promise<boolean> {
|
||||||
|
return storageData.delete(key);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
provider = createSessionAuthProvider({
|
||||||
|
storage,
|
||||||
|
sessionTTL: 3600000
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('createSession and verify', () => {
|
||||||
|
it('should create and verify a session', async () => {
|
||||||
|
const user = { id: '123', name: 'Alice' };
|
||||||
|
const sessionId = await provider.createSession(user);
|
||||||
|
|
||||||
|
expect(sessionId).toBeTypeOf('string');
|
||||||
|
expect(sessionId.length).toBeGreaterThan(10);
|
||||||
|
|
||||||
|
const result = await provider.verify(sessionId);
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.user).toEqual(user);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should store session data', async () => {
|
||||||
|
const user = { id: '123', name: 'Alice' };
|
||||||
|
const sessionId = await provider.createSession(user, { customField: 'value' });
|
||||||
|
|
||||||
|
const session = await provider.getSession(sessionId);
|
||||||
|
|
||||||
|
expect(session).toBeDefined();
|
||||||
|
expect(session?.user).toEqual(user);
|
||||||
|
expect(session?.data?.customField).toBe('value');
|
||||||
|
expect(session?.createdAt).toBeTypeOf('number');
|
||||||
|
expect(session?.lastActiveAt).toBeTypeOf('number');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('verify errors', () => {
|
||||||
|
it('should fail for empty session id', async () => {
|
||||||
|
const result = await provider.verify('');
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.errorCode).toBe('INVALID_TOKEN');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fail for non-existent session', async () => {
|
||||||
|
const result = await provider.verify('non-existent-session');
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.errorCode).toBe('EXPIRED_TOKEN');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('with validateUser', () => {
|
||||||
|
it('should validate user on verify', async () => {
|
||||||
|
const validatingProvider = createSessionAuthProvider({
|
||||||
|
storage,
|
||||||
|
validateUser: (user) => user.id !== 'banned'
|
||||||
|
});
|
||||||
|
|
||||||
|
const sessionId = await validatingProvider.createSession({ id: 'banned', name: 'Bad User' });
|
||||||
|
const result = await validatingProvider.verify(sessionId);
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.errorCode).toBe('ACCOUNT_DISABLED');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should pass validation for valid user', async () => {
|
||||||
|
const validatingProvider = createSessionAuthProvider({
|
||||||
|
storage,
|
||||||
|
validateUser: (user) => user.id !== 'banned'
|
||||||
|
});
|
||||||
|
|
||||||
|
const sessionId = await validatingProvider.createSession({ id: '123', name: 'Good User' });
|
||||||
|
const result = await validatingProvider.verify(sessionId);
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('refresh', () => {
|
||||||
|
it('should refresh session and update lastActiveAt', async () => {
|
||||||
|
const sessionId = await provider.createSession({ id: '123', name: 'Alice' });
|
||||||
|
|
||||||
|
const session1 = await provider.getSession(sessionId);
|
||||||
|
const lastActive1 = session1?.lastActiveAt;
|
||||||
|
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 10));
|
||||||
|
|
||||||
|
const result = await provider.refresh(sessionId);
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
|
||||||
|
const session2 = await provider.getSession(sessionId);
|
||||||
|
expect(session2?.lastActiveAt).toBeGreaterThanOrEqual(lastActive1!);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('revoke', () => {
|
||||||
|
it('should revoke session', async () => {
|
||||||
|
const sessionId = await provider.createSession({ id: '123', name: 'Alice' });
|
||||||
|
|
||||||
|
const revoked = await provider.revoke(sessionId);
|
||||||
|
expect(revoked).toBe(true);
|
||||||
|
|
||||||
|
const result = await provider.verify(sessionId);
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('updateSession', () => {
|
||||||
|
it('should update session data', async () => {
|
||||||
|
const sessionId = await provider.createSession({ id: '123', name: 'Alice' });
|
||||||
|
|
||||||
|
const updated = await provider.updateSession(sessionId, { newField: 'newValue' });
|
||||||
|
expect(updated).toBe(true);
|
||||||
|
|
||||||
|
const session = await provider.getSession(sessionId);
|
||||||
|
expect(session?.data?.newField).toBe('newValue');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false for non-existent session', async () => {
|
||||||
|
const updated = await provider.updateSession('non-existent', { field: 'value' });
|
||||||
|
expect(updated).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('createJwtAuthProvider', () => {
|
||||||
|
it('should create provider with default options', () => {
|
||||||
|
const provider = createJwtAuthProvider({ secret: 'test' });
|
||||||
|
expect(provider.name).toBe('jwt');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('createSessionAuthProvider', () => {
|
||||||
|
it('should create provider with default options', () => {
|
||||||
|
const storage: ISessionStorage = {
|
||||||
|
get: async () => null,
|
||||||
|
set: async () => {},
|
||||||
|
delete: async () => true
|
||||||
|
};
|
||||||
|
|
||||||
|
const provider = createSessionAuthProvider({ storage });
|
||||||
|
expect(provider.name).toBe('session');
|
||||||
|
});
|
||||||
|
});
|
||||||
202
packages/framework/server/src/auth/context.ts
Normal file
202
packages/framework/server/src/auth/context.ts
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
/**
|
||||||
|
* @zh 认证上下文实现
|
||||||
|
* @en Authentication context implementation
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { IAuthContext, AuthResult } from './types.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 用户信息提取器
|
||||||
|
* @en User info extractor
|
||||||
|
*/
|
||||||
|
export interface UserInfoExtractor<TUser> {
|
||||||
|
/**
|
||||||
|
* @zh 提取用户 ID
|
||||||
|
* @en Extract user ID
|
||||||
|
*/
|
||||||
|
getId(user: TUser): string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 提取用户角色
|
||||||
|
* @en Extract user roles
|
||||||
|
*/
|
||||||
|
getRoles(user: TUser): string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 默认用户信息提取器
|
||||||
|
* @en Default user info extractor
|
||||||
|
*/
|
||||||
|
export const defaultUserExtractor: UserInfoExtractor<unknown> = {
|
||||||
|
getId(user: unknown): string {
|
||||||
|
if (user && typeof user === 'object') {
|
||||||
|
const u = user as Record<string, unknown>;
|
||||||
|
if (typeof u.id === 'string') return u.id;
|
||||||
|
if (typeof u.id === 'number') return String(u.id);
|
||||||
|
if (typeof u.userId === 'string') return u.userId;
|
||||||
|
if (typeof u.userId === 'number') return String(u.userId);
|
||||||
|
if (typeof u.sub === 'string') return u.sub;
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
},
|
||||||
|
|
||||||
|
getRoles(user: unknown): string[] {
|
||||||
|
if (user && typeof user === 'object') {
|
||||||
|
const u = user as Record<string, unknown>;
|
||||||
|
if (Array.isArray(u.roles)) {
|
||||||
|
return u.roles.filter((r): r is string => typeof r === 'string');
|
||||||
|
}
|
||||||
|
if (typeof u.role === 'string') {
|
||||||
|
return [u.role];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 认证上下文
|
||||||
|
* @en Authentication context
|
||||||
|
*
|
||||||
|
* @zh 存储连接的认证状态
|
||||||
|
* @en Stores authentication state for a connection
|
||||||
|
*/
|
||||||
|
export class AuthContext<TUser = unknown> implements IAuthContext<TUser> {
|
||||||
|
private _isAuthenticated: boolean = false;
|
||||||
|
private _user: TUser | null = null;
|
||||||
|
private _userId: string | null = null;
|
||||||
|
private _roles: string[] = [];
|
||||||
|
private _authenticatedAt: number | null = null;
|
||||||
|
private _expiresAt: number | null = null;
|
||||||
|
private _extractor: UserInfoExtractor<TUser>;
|
||||||
|
|
||||||
|
constructor(extractor?: UserInfoExtractor<TUser>) {
|
||||||
|
this._extractor = (extractor ?? defaultUserExtractor) as UserInfoExtractor<TUser>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 是否已认证
|
||||||
|
* @en Whether authenticated
|
||||||
|
*/
|
||||||
|
get isAuthenticated(): boolean {
|
||||||
|
if (this._expiresAt && Date.now() > this._expiresAt) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return this._isAuthenticated;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 用户信息
|
||||||
|
* @en User information
|
||||||
|
*/
|
||||||
|
get user(): TUser | null {
|
||||||
|
return this._user;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 用户 ID
|
||||||
|
* @en User ID
|
||||||
|
*/
|
||||||
|
get userId(): string | null {
|
||||||
|
return this._userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 用户角色
|
||||||
|
* @en User roles
|
||||||
|
*/
|
||||||
|
get roles(): ReadonlyArray<string> {
|
||||||
|
return this._roles;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 认证时间
|
||||||
|
* @en Authentication timestamp
|
||||||
|
*/
|
||||||
|
get authenticatedAt(): number | null {
|
||||||
|
return this._authenticatedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 令牌过期时间
|
||||||
|
* @en Token expiration time
|
||||||
|
*/
|
||||||
|
get expiresAt(): number | null {
|
||||||
|
return this._expiresAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 检查是否有指定角色
|
||||||
|
* @en Check if has specified role
|
||||||
|
*/
|
||||||
|
hasRole(role: string): boolean {
|
||||||
|
return this._roles.includes(role);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 检查是否有任一指定角色
|
||||||
|
* @en Check if has any of specified roles
|
||||||
|
*/
|
||||||
|
hasAnyRole(roles: string[]): boolean {
|
||||||
|
return roles.some(role => this._roles.includes(role));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 检查是否有所有指定角色
|
||||||
|
* @en Check if has all specified roles
|
||||||
|
*/
|
||||||
|
hasAllRoles(roles: string[]): boolean {
|
||||||
|
return roles.every(role => this._roles.includes(role));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 设置认证结果
|
||||||
|
* @en Set authentication result
|
||||||
|
*/
|
||||||
|
setAuthenticated(result: AuthResult<TUser>): void {
|
||||||
|
if (result.success && result.user) {
|
||||||
|
this._isAuthenticated = true;
|
||||||
|
this._user = result.user;
|
||||||
|
this._userId = this._extractor.getId(result.user);
|
||||||
|
this._roles = this._extractor.getRoles(result.user);
|
||||||
|
this._authenticatedAt = Date.now();
|
||||||
|
this._expiresAt = result.expiresAt ?? null;
|
||||||
|
} else {
|
||||||
|
this.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 清除认证状态
|
||||||
|
* @en Clear authentication state
|
||||||
|
*/
|
||||||
|
clear(): void {
|
||||||
|
this._isAuthenticated = false;
|
||||||
|
this._user = null;
|
||||||
|
this._userId = null;
|
||||||
|
this._roles = [];
|
||||||
|
this._authenticatedAt = null;
|
||||||
|
this._expiresAt = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 创建访客认证上下文
|
||||||
|
* @en Create guest auth context
|
||||||
|
*/
|
||||||
|
export function createGuestContext<TUser = unknown>(): IAuthContext<TUser> {
|
||||||
|
return new AuthContext<TUser>();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 从认证结果创建认证上下文
|
||||||
|
* @en Create auth context from auth result
|
||||||
|
*/
|
||||||
|
export function createAuthContext<TUser = unknown>(
|
||||||
|
result: AuthResult<TUser>,
|
||||||
|
extractor?: UserInfoExtractor<TUser>
|
||||||
|
): AuthContext<TUser> {
|
||||||
|
const context = new AuthContext<TUser>(extractor);
|
||||||
|
context.setAuthenticated(result);
|
||||||
|
return context;
|
||||||
|
}
|
||||||
13
packages/framework/server/src/auth/decorators/index.ts
Normal file
13
packages/framework/server/src/auth/decorators/index.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
/**
|
||||||
|
* @zh 认证装饰器
|
||||||
|
* @en Authentication decorators
|
||||||
|
*/
|
||||||
|
|
||||||
|
export {
|
||||||
|
requireAuth,
|
||||||
|
getAuthMetadata,
|
||||||
|
AUTH_METADATA_KEY,
|
||||||
|
type AuthMetadata
|
||||||
|
} from './requireAuth.js';
|
||||||
|
|
||||||
|
export { requireRole } from './requireRole.js';
|
||||||
86
packages/framework/server/src/auth/decorators/requireAuth.ts
Normal file
86
packages/framework/server/src/auth/decorators/requireAuth.ts
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
/**
|
||||||
|
* @zh requireAuth 装饰器
|
||||||
|
* @en requireAuth decorator
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { RequireAuthOptions } from '../types.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 认证元数据键
|
||||||
|
* @en Auth metadata key
|
||||||
|
*/
|
||||||
|
export const AUTH_METADATA_KEY = Symbol('authMetadata');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 认证元数据
|
||||||
|
* @en Auth metadata
|
||||||
|
*/
|
||||||
|
export interface AuthMetadata {
|
||||||
|
requireAuth: boolean;
|
||||||
|
options?: RequireAuthOptions;
|
||||||
|
roles?: string[];
|
||||||
|
roleMode?: 'any' | 'all';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 获取方法的认证元数据
|
||||||
|
* @en Get auth metadata for method
|
||||||
|
*/
|
||||||
|
export function getAuthMetadata(target: any, propertyKey: string): AuthMetadata | undefined {
|
||||||
|
const metadata = target[AUTH_METADATA_KEY] as Map<string, AuthMetadata> | undefined;
|
||||||
|
return metadata?.get(propertyKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 设置方法的认证元数据
|
||||||
|
* @en Set auth metadata for method
|
||||||
|
*/
|
||||||
|
function setAuthMetadata(target: any, propertyKey: string, metadata: AuthMetadata): void {
|
||||||
|
if (!target[AUTH_METADATA_KEY]) {
|
||||||
|
target[AUTH_METADATA_KEY] = new Map<string, AuthMetadata>();
|
||||||
|
}
|
||||||
|
(target[AUTH_METADATA_KEY] as Map<string, AuthMetadata>).set(propertyKey, metadata);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 要求认证装饰器
|
||||||
|
* @en Require authentication decorator
|
||||||
|
*
|
||||||
|
* @zh 标记方法需要认证才能访问,用于消息处理器
|
||||||
|
* @en Marks method as requiring authentication, used for message handlers
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* class GameRoom extends withRoomAuth(Room) {
|
||||||
|
* @requireAuth()
|
||||||
|
* @onMessage('Trade')
|
||||||
|
* handleTrade(data: TradeData, player: AuthPlayer) {
|
||||||
|
* // Only authenticated players can trade
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* @requireAuth({ allowGuest: true })
|
||||||
|
* @onMessage('Chat')
|
||||||
|
* handleChat(data: ChatData, player: AuthPlayer) {
|
||||||
|
* // Guests can also chat
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function requireAuth(options?: RequireAuthOptions): MethodDecorator {
|
||||||
|
return function (
|
||||||
|
target: any,
|
||||||
|
propertyKey: string | symbol,
|
||||||
|
descriptor: PropertyDescriptor
|
||||||
|
) {
|
||||||
|
const key = String(propertyKey);
|
||||||
|
const existing = getAuthMetadata(target, key);
|
||||||
|
|
||||||
|
setAuthMetadata(target, key, {
|
||||||
|
...existing,
|
||||||
|
requireAuth: true,
|
||||||
|
options
|
||||||
|
});
|
||||||
|
|
||||||
|
return descriptor;
|
||||||
|
};
|
||||||
|
}
|
||||||
73
packages/framework/server/src/auth/decorators/requireRole.ts
Normal file
73
packages/framework/server/src/auth/decorators/requireRole.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
/**
|
||||||
|
* @zh requireRole 装饰器
|
||||||
|
* @en requireRole decorator
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { RequireRoleOptions } from '../types.js';
|
||||||
|
import { AUTH_METADATA_KEY, getAuthMetadata, type AuthMetadata } from './requireAuth.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 设置方法的认证元数据
|
||||||
|
* @en Set auth metadata for method
|
||||||
|
*/
|
||||||
|
function setAuthMetadata(target: any, propertyKey: string, metadata: AuthMetadata): void {
|
||||||
|
if (!target[AUTH_METADATA_KEY]) {
|
||||||
|
target[AUTH_METADATA_KEY] = new Map<string, AuthMetadata>();
|
||||||
|
}
|
||||||
|
(target[AUTH_METADATA_KEY] as Map<string, AuthMetadata>).set(propertyKey, metadata);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 要求角色装饰器
|
||||||
|
* @en Require role decorator
|
||||||
|
*
|
||||||
|
* @zh 标记方法需要特定角色才能访问
|
||||||
|
* @en Marks method as requiring specific role(s)
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* class AdminRoom extends withRoomAuth(Room) {
|
||||||
|
* @requireRole('admin')
|
||||||
|
* @onMessage('Ban')
|
||||||
|
* handleBan(data: BanData, player: AuthPlayer) {
|
||||||
|
* // Only admins can ban
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* @requireRole(['moderator', 'admin'])
|
||||||
|
* @onMessage('Mute')
|
||||||
|
* handleMute(data: MuteData, player: AuthPlayer) {
|
||||||
|
* // Moderators or admins can mute
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* @requireRole(['verified', 'premium'], { mode: 'all' })
|
||||||
|
* @onMessage('SpecialFeature')
|
||||||
|
* handleSpecial(data: any, player: AuthPlayer) {
|
||||||
|
* // Requires both verified AND premium roles
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function requireRole(
|
||||||
|
roles: string | string[],
|
||||||
|
options?: RequireRoleOptions
|
||||||
|
): MethodDecorator {
|
||||||
|
return function (
|
||||||
|
target: any,
|
||||||
|
propertyKey: string | symbol,
|
||||||
|
descriptor: PropertyDescriptor
|
||||||
|
) {
|
||||||
|
const key = String(propertyKey);
|
||||||
|
const existing = getAuthMetadata(target, key);
|
||||||
|
const roleArray = Array.isArray(roles) ? roles : [roles];
|
||||||
|
|
||||||
|
setAuthMetadata(target, key, {
|
||||||
|
...existing,
|
||||||
|
requireAuth: true,
|
||||||
|
roles: roleArray,
|
||||||
|
roleMode: options?.mode ?? 'any',
|
||||||
|
options
|
||||||
|
});
|
||||||
|
|
||||||
|
return descriptor;
|
||||||
|
};
|
||||||
|
}
|
||||||
129
packages/framework/server/src/auth/index.ts
Normal file
129
packages/framework/server/src/auth/index.ts
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
/**
|
||||||
|
* @zh 认证模块
|
||||||
|
* @en Authentication module
|
||||||
|
*
|
||||||
|
* @zh 为 @esengine/server 提供可插拔的认证系统
|
||||||
|
* @en Provides pluggable authentication system for @esengine/server
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* import { createServer, Room, onMessage } from '@esengine/server';
|
||||||
|
* import {
|
||||||
|
* withAuth,
|
||||||
|
* withRoomAuth,
|
||||||
|
* createJwtAuthProvider,
|
||||||
|
* requireAuth,
|
||||||
|
* requireRole,
|
||||||
|
* type AuthPlayer
|
||||||
|
* } from '@esengine/server/auth';
|
||||||
|
*
|
||||||
|
* // 1. Create auth provider
|
||||||
|
* const jwtProvider = createJwtAuthProvider({
|
||||||
|
* secret: process.env.JWT_SECRET!,
|
||||||
|
* expiresIn: 3600,
|
||||||
|
* });
|
||||||
|
*
|
||||||
|
* // 2. Wrap server with auth
|
||||||
|
* const server = withAuth(await createServer({ port: 3000 }), {
|
||||||
|
* provider: jwtProvider,
|
||||||
|
* extractCredentials: (req) => {
|
||||||
|
* const url = new URL(req.url, 'http://localhost');
|
||||||
|
* return url.searchParams.get('token');
|
||||||
|
* },
|
||||||
|
* });
|
||||||
|
*
|
||||||
|
* // 3. Create auth-enabled room
|
||||||
|
* class GameRoom extends withRoomAuth<User>(Room, {
|
||||||
|
* requireAuth: true,
|
||||||
|
* allowedRoles: ['player'],
|
||||||
|
* }) {
|
||||||
|
* onJoin(player: AuthPlayer<User>) {
|
||||||
|
* console.log(`${player.user?.name} joined`);
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* @requireAuth()
|
||||||
|
* @onMessage('Chat')
|
||||||
|
* handleChat(data: { text: string }, player: AuthPlayer<User>) {
|
||||||
|
* this.broadcast('Chat', { from: player.user?.name, text: data.text });
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* @requireRole('admin')
|
||||||
|
* @onMessage('Kick')
|
||||||
|
* handleKick(data: { playerId: string }, player: AuthPlayer<User>) {
|
||||||
|
* this.kick(data.playerId);
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* server.define('game', GameRoom);
|
||||||
|
* await server.start();
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Types
|
||||||
|
export type {
|
||||||
|
AuthResult,
|
||||||
|
AuthErrorCode,
|
||||||
|
IAuthProvider,
|
||||||
|
IAuthContext,
|
||||||
|
AuthConnectionData,
|
||||||
|
AuthConnection,
|
||||||
|
AuthApiContext,
|
||||||
|
AuthMsgContext,
|
||||||
|
ConnectionRequest,
|
||||||
|
AuthServerConfig,
|
||||||
|
AuthGameServer,
|
||||||
|
AuthRoomConfig,
|
||||||
|
RequireAuthOptions,
|
||||||
|
RequireRoleOptions
|
||||||
|
} from './types.js';
|
||||||
|
|
||||||
|
// Context
|
||||||
|
export {
|
||||||
|
AuthContext,
|
||||||
|
createGuestContext,
|
||||||
|
createAuthContext,
|
||||||
|
defaultUserExtractor,
|
||||||
|
type UserInfoExtractor
|
||||||
|
} from './context.js';
|
||||||
|
|
||||||
|
// Providers
|
||||||
|
export {
|
||||||
|
JwtAuthProvider,
|
||||||
|
createJwtAuthProvider,
|
||||||
|
type JwtAuthConfig,
|
||||||
|
type JwtPayload
|
||||||
|
} from './providers/JwtAuthProvider.js';
|
||||||
|
|
||||||
|
export {
|
||||||
|
SessionAuthProvider,
|
||||||
|
createSessionAuthProvider,
|
||||||
|
type SessionAuthConfig,
|
||||||
|
type SessionData,
|
||||||
|
type ISessionStorage
|
||||||
|
} from './providers/SessionAuthProvider.js';
|
||||||
|
|
||||||
|
// Mixins
|
||||||
|
export {
|
||||||
|
withAuth,
|
||||||
|
getAuthContext,
|
||||||
|
setAuthContext,
|
||||||
|
requireAuthentication,
|
||||||
|
requireRole as requireRoleCheck
|
||||||
|
} from './mixin/withAuth.js';
|
||||||
|
|
||||||
|
export {
|
||||||
|
withRoomAuth,
|
||||||
|
AuthRoomBase,
|
||||||
|
type AuthPlayer,
|
||||||
|
type IAuthRoom,
|
||||||
|
type AuthRoomClass
|
||||||
|
} from './mixin/withRoomAuth.js';
|
||||||
|
|
||||||
|
// Decorators
|
||||||
|
export {
|
||||||
|
requireAuth,
|
||||||
|
requireRole,
|
||||||
|
getAuthMetadata,
|
||||||
|
AUTH_METADATA_KEY,
|
||||||
|
type AuthMetadata
|
||||||
|
} from './decorators/index.js';
|
||||||
20
packages/framework/server/src/auth/mixin/index.ts
Normal file
20
packages/framework/server/src/auth/mixin/index.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
/**
|
||||||
|
* @zh 认证 Mixin
|
||||||
|
* @en Authentication mixins
|
||||||
|
*/
|
||||||
|
|
||||||
|
export {
|
||||||
|
withAuth,
|
||||||
|
getAuthContext,
|
||||||
|
setAuthContext,
|
||||||
|
requireAuthentication,
|
||||||
|
requireRole
|
||||||
|
} from './withAuth.js';
|
||||||
|
|
||||||
|
export {
|
||||||
|
withRoomAuth,
|
||||||
|
AuthRoomBase,
|
||||||
|
type AuthPlayer,
|
||||||
|
type IAuthRoom,
|
||||||
|
type AuthRoomClass
|
||||||
|
} from './withRoomAuth.js';
|
||||||
221
packages/framework/server/src/auth/mixin/withAuth.ts
Normal file
221
packages/framework/server/src/auth/mixin/withAuth.ts
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
/**
|
||||||
|
* @zh 服务器认证 Mixin
|
||||||
|
* @en Server authentication mixin
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { ServerConnection, GameServer } from '../../types/index.js';
|
||||||
|
import type {
|
||||||
|
IAuthProvider,
|
||||||
|
AuthResult,
|
||||||
|
AuthServerConfig,
|
||||||
|
AuthGameServer,
|
||||||
|
IAuthContext,
|
||||||
|
ConnectionRequest
|
||||||
|
} from '../types.js';
|
||||||
|
import { AuthContext } from '../context.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 认证数据键
|
||||||
|
* @en Auth data key
|
||||||
|
*/
|
||||||
|
const AUTH_CONTEXT_KEY = Symbol('authContext');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 获取连接的认证上下文
|
||||||
|
* @en Get auth context for connection
|
||||||
|
*/
|
||||||
|
export function getAuthContext<TUser = unknown>(conn: ServerConnection): IAuthContext<TUser> | null {
|
||||||
|
const data = conn.data as Record<symbol, unknown>;
|
||||||
|
return (data[AUTH_CONTEXT_KEY] as IAuthContext<TUser>) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 设置连接的认证上下文
|
||||||
|
* @en Set auth context for connection
|
||||||
|
*/
|
||||||
|
export function setAuthContext<TUser = unknown>(conn: ServerConnection, context: IAuthContext<TUser>): void {
|
||||||
|
const data = conn.data as Record<symbol, unknown>;
|
||||||
|
data[AUTH_CONTEXT_KEY] = context;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 包装服务器添加认证功能
|
||||||
|
* @en Wrap server with authentication functionality
|
||||||
|
*
|
||||||
|
* @zh 使用 mixin 模式为服务器添加认证能力,不修改原始服务器
|
||||||
|
* @en Uses mixin pattern to add authentication to server without modifying original
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* import { createServer } from '@esengine/server';
|
||||||
|
* import { withAuth, createJwtAuthProvider } from '@esengine/server/auth';
|
||||||
|
*
|
||||||
|
* const jwtProvider = createJwtAuthProvider({
|
||||||
|
* secret: 'your-secret-key',
|
||||||
|
* expiresIn: 3600,
|
||||||
|
* });
|
||||||
|
*
|
||||||
|
* const server = withAuth(await createServer({ port: 3000 }), {
|
||||||
|
* provider: jwtProvider,
|
||||||
|
* extractCredentials: (req) => {
|
||||||
|
* const url = new URL(req.url, 'http://localhost');
|
||||||
|
* return url.searchParams.get('token');
|
||||||
|
* },
|
||||||
|
* onAuthSuccess: (conn, user) => {
|
||||||
|
* console.log(`User ${user.name} authenticated`);
|
||||||
|
* },
|
||||||
|
* onAuthFailure: (conn, error) => {
|
||||||
|
* console.log(`Auth failed: ${error.error}`);
|
||||||
|
* }
|
||||||
|
* });
|
||||||
|
*
|
||||||
|
* await server.start();
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function withAuth<TUser = unknown>(
|
||||||
|
server: GameServer,
|
||||||
|
config: AuthServerConfig<TUser>
|
||||||
|
): AuthGameServer<TUser> {
|
||||||
|
const {
|
||||||
|
provider,
|
||||||
|
extractCredentials,
|
||||||
|
autoAuthOnConnect = true,
|
||||||
|
disconnectOnAuthFailure = false,
|
||||||
|
onAuthSuccess,
|
||||||
|
onAuthFailure
|
||||||
|
} = config;
|
||||||
|
|
||||||
|
const originalConnections = server.connections;
|
||||||
|
const connectionAuthMap = new WeakMap<ServerConnection, AuthContext<TUser>>();
|
||||||
|
|
||||||
|
const authServer: AuthGameServer<TUser> = {
|
||||||
|
...server,
|
||||||
|
|
||||||
|
get authProvider(): IAuthProvider<TUser> {
|
||||||
|
return provider;
|
||||||
|
},
|
||||||
|
|
||||||
|
async authenticate(
|
||||||
|
conn: ServerConnection,
|
||||||
|
credentials: unknown
|
||||||
|
): Promise<AuthResult<TUser>> {
|
||||||
|
const result = await provider.verify(credentials as never);
|
||||||
|
|
||||||
|
let authContext = connectionAuthMap.get(conn);
|
||||||
|
if (!authContext) {
|
||||||
|
authContext = new AuthContext<TUser>();
|
||||||
|
connectionAuthMap.set(conn, authContext);
|
||||||
|
setAuthContext(conn, authContext);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
authContext.setAuthenticated(result);
|
||||||
|
await onAuthSuccess?.(conn, result.user!);
|
||||||
|
} else {
|
||||||
|
authContext.clear();
|
||||||
|
await onAuthFailure?.(conn, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
|
||||||
|
getAuthContext(conn: ServerConnection): IAuthContext<TUser> | null {
|
||||||
|
return connectionAuthMap.get(conn) ?? null;
|
||||||
|
},
|
||||||
|
|
||||||
|
get connections(): ReadonlyArray<ServerConnection> {
|
||||||
|
return originalConnections;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const originalOnConnect = (server as any)._onConnect;
|
||||||
|
(server as any)._onConnect = async (conn: ServerConnection, req?: unknown) => {
|
||||||
|
const authContext = new AuthContext<TUser>();
|
||||||
|
connectionAuthMap.set(conn, authContext);
|
||||||
|
setAuthContext(conn, authContext);
|
||||||
|
|
||||||
|
if (autoAuthOnConnect && extractCredentials && req) {
|
||||||
|
try {
|
||||||
|
const connReq = req as ConnectionRequest;
|
||||||
|
const credentials = await extractCredentials(connReq);
|
||||||
|
|
||||||
|
if (credentials) {
|
||||||
|
const result = await provider.verify(credentials as never);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
authContext.setAuthenticated(result);
|
||||||
|
await onAuthSuccess?.(conn, result.user!);
|
||||||
|
} else {
|
||||||
|
await onAuthFailure?.(conn, result);
|
||||||
|
|
||||||
|
if (disconnectOnAuthFailure) {
|
||||||
|
(conn as any).close?.();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Auth] Error during auto-authentication:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (originalOnConnect) {
|
||||||
|
await originalOnConnect(conn, req);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const originalOnDisconnect = (server as any)._onDisconnect;
|
||||||
|
(server as any)._onDisconnect = async (conn: ServerConnection) => {
|
||||||
|
connectionAuthMap.delete(conn);
|
||||||
|
|
||||||
|
if (originalOnDisconnect) {
|
||||||
|
await originalOnDisconnect(conn);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return authServer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 创建认证中间件
|
||||||
|
* @en Create authentication middleware
|
||||||
|
*
|
||||||
|
* @zh 用于在 API 处理器中检查认证状态
|
||||||
|
* @en Used to check authentication status in API handlers
|
||||||
|
*/
|
||||||
|
export function requireAuthentication<TUser = unknown>(
|
||||||
|
conn: ServerConnection,
|
||||||
|
options?: { errorMessage?: string }
|
||||||
|
): IAuthContext<TUser> {
|
||||||
|
const auth = getAuthContext<TUser>(conn);
|
||||||
|
|
||||||
|
if (!auth || !auth.isAuthenticated) {
|
||||||
|
throw new Error(options?.errorMessage ?? 'Authentication required');
|
||||||
|
}
|
||||||
|
|
||||||
|
return auth;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 创建角色检查中间件
|
||||||
|
* @en Create role check middleware
|
||||||
|
*/
|
||||||
|
export function requireRole<TUser = unknown>(
|
||||||
|
conn: ServerConnection,
|
||||||
|
roles: string | string[],
|
||||||
|
options?: { mode?: 'any' | 'all'; errorMessage?: string }
|
||||||
|
): IAuthContext<TUser> {
|
||||||
|
const auth = requireAuthentication<TUser>(conn);
|
||||||
|
const roleArray = Array.isArray(roles) ? roles : [roles];
|
||||||
|
const mode = options?.mode ?? 'any';
|
||||||
|
|
||||||
|
const hasRole = mode === 'any'
|
||||||
|
? auth.hasAnyRole(roleArray)
|
||||||
|
: auth.hasAllRoles(roleArray);
|
||||||
|
|
||||||
|
if (!hasRole) {
|
||||||
|
throw new Error(options?.errorMessage ?? 'Insufficient permissions');
|
||||||
|
}
|
||||||
|
|
||||||
|
return auth;
|
||||||
|
}
|
||||||
317
packages/framework/server/src/auth/mixin/withRoomAuth.ts
Normal file
317
packages/framework/server/src/auth/mixin/withRoomAuth.ts
Normal file
@@ -0,0 +1,317 @@
|
|||||||
|
/**
|
||||||
|
* @zh 房间认证 Mixin
|
||||||
|
* @en Room authentication mixin
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Room, Player } from '../../room/index.js';
|
||||||
|
import type { IAuthContext, AuthRoomConfig } from '../types.js';
|
||||||
|
import { getAuthContext } from './withAuth.js';
|
||||||
|
import { createGuestContext } from '../context.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 带认证的玩家
|
||||||
|
* @en Player with authentication
|
||||||
|
*/
|
||||||
|
export interface AuthPlayer<TUser = unknown, TData = Record<string, unknown>> extends Player<TData> {
|
||||||
|
/**
|
||||||
|
* @zh 认证上下文
|
||||||
|
* @en Authentication context
|
||||||
|
*/
|
||||||
|
readonly auth: IAuthContext<TUser>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 用户信息(快捷访问)
|
||||||
|
* @en User info (shortcut)
|
||||||
|
*/
|
||||||
|
readonly user: TUser | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 带认证的房间接口
|
||||||
|
* @en Room with authentication interface
|
||||||
|
*/
|
||||||
|
export interface IAuthRoom<TUser = unknown> {
|
||||||
|
/**
|
||||||
|
* @zh 认证钩子(在 onJoin 之前调用)
|
||||||
|
* @en Auth hook (called before onJoin)
|
||||||
|
*/
|
||||||
|
onAuth?(player: AuthPlayer<TUser>): boolean | Promise<boolean>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 获取带认证信息的玩家
|
||||||
|
* @en Get player with auth info
|
||||||
|
*/
|
||||||
|
getAuthPlayer(id: string): AuthPlayer<TUser> | undefined;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 获取所有带认证信息的玩家
|
||||||
|
* @en Get all players with auth info
|
||||||
|
*/
|
||||||
|
getAuthPlayers(): AuthPlayer<TUser>[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 按角色获取玩家
|
||||||
|
* @en Get players by role
|
||||||
|
*/
|
||||||
|
getPlayersByRole(role: string): AuthPlayer<TUser>[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 按用户 ID 获取玩家
|
||||||
|
* @en Get player by user ID
|
||||||
|
*/
|
||||||
|
getPlayerByUserId(userId: string): AuthPlayer<TUser> | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 认证房间构造器类型
|
||||||
|
* @en Auth room constructor type
|
||||||
|
*/
|
||||||
|
export type AuthRoomClass<TUser = unknown> = new (...args: any[]) => Room & IAuthRoom<TUser>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 玩家认证上下文存储
|
||||||
|
* @en Player auth context storage
|
||||||
|
*/
|
||||||
|
const playerAuthContexts = new WeakMap<Player, IAuthContext<unknown>>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 包装玩家对象添加认证信息
|
||||||
|
* @en Wrap player object with auth info
|
||||||
|
*/
|
||||||
|
function wrapPlayerWithAuth<TUser>(player: Player, authContext: IAuthContext<TUser>): AuthPlayer<TUser> {
|
||||||
|
playerAuthContexts.set(player, authContext);
|
||||||
|
|
||||||
|
Object.defineProperty(player, 'auth', {
|
||||||
|
get: () => playerAuthContexts.get(player) ?? createGuestContext<TUser>(),
|
||||||
|
enumerable: true,
|
||||||
|
configurable: false
|
||||||
|
});
|
||||||
|
|
||||||
|
Object.defineProperty(player, 'user', {
|
||||||
|
get: () => (playerAuthContexts.get(player) as IAuthContext<TUser> | undefined)?.user ?? null,
|
||||||
|
enumerable: true,
|
||||||
|
configurable: false
|
||||||
|
});
|
||||||
|
|
||||||
|
return player as AuthPlayer<TUser>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 包装房间类添加认证功能
|
||||||
|
* @en Wrap room class with authentication functionality
|
||||||
|
*
|
||||||
|
* @zh 使用 mixin 模式为房间添加认证检查,在玩家加入前验证认证状态
|
||||||
|
* @en Uses mixin pattern to add auth checks to room, validates auth before player joins
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* import { Room, onMessage } from '@esengine/server';
|
||||||
|
* import { withRoomAuth, type AuthPlayer } from '@esengine/server/auth';
|
||||||
|
*
|
||||||
|
* interface User {
|
||||||
|
* id: string;
|
||||||
|
* name: string;
|
||||||
|
* roles: string[];
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* class GameRoom extends withRoomAuth<User>(Room, {
|
||||||
|
* requireAuth: true,
|
||||||
|
* allowedRoles: ['player', 'premium'],
|
||||||
|
* }) {
|
||||||
|
* onJoin(player: AuthPlayer<User>) {
|
||||||
|
* console.log(`${player.user?.name} joined the game`);
|
||||||
|
* this.broadcast('PlayerJoined', {
|
||||||
|
* id: player.id,
|
||||||
|
* name: player.user?.name
|
||||||
|
* });
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* // Optional: custom auth validation
|
||||||
|
* async onAuth(player: AuthPlayer<User>): Promise<boolean> {
|
||||||
|
* // Additional validation logic
|
||||||
|
* if (player.auth.hasRole('banned')) {
|
||||||
|
* return false;
|
||||||
|
* }
|
||||||
|
* return true;
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* @onMessage('Chat')
|
||||||
|
* handleChat(data: { text: string }, player: AuthPlayer<User>) {
|
||||||
|
* this.broadcast('Chat', {
|
||||||
|
* from: player.user?.name ?? 'Guest',
|
||||||
|
* text: data.text
|
||||||
|
* });
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function withRoomAuth<TUser = unknown, TBase extends new (...args: any[]) => Room = new (...args: any[]) => Room>(
|
||||||
|
Base: TBase,
|
||||||
|
config: AuthRoomConfig = {}
|
||||||
|
): TBase & (new (...args: any[]) => IAuthRoom<TUser>) {
|
||||||
|
const {
|
||||||
|
requireAuth = true,
|
||||||
|
allowedRoles = [],
|
||||||
|
roleCheckMode = 'any'
|
||||||
|
} = config;
|
||||||
|
|
||||||
|
abstract class AuthRoom extends (Base as new (...args: any[]) => Room) implements IAuthRoom<TUser> {
|
||||||
|
private _originalOnJoin: ((player: Player) => void | Promise<void>) | undefined;
|
||||||
|
|
||||||
|
constructor(...args: any[]) {
|
||||||
|
super(...args);
|
||||||
|
this._originalOnJoin = this.onJoin?.bind(this);
|
||||||
|
this.onJoin = this._authOnJoin.bind(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 认证钩子(可覆盖)
|
||||||
|
* @en Auth hook (can be overridden)
|
||||||
|
*/
|
||||||
|
onAuth?(player: AuthPlayer<TUser>): boolean | Promise<boolean>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 包装的 onJoin 方法
|
||||||
|
* @en Wrapped onJoin method
|
||||||
|
*/
|
||||||
|
private async _authOnJoin(player: Player): Promise<void> {
|
||||||
|
const conn = (player as any).connection ?? (player as any)._conn;
|
||||||
|
const authContext = conn
|
||||||
|
? (getAuthContext<TUser>(conn) ?? createGuestContext<TUser>())
|
||||||
|
: createGuestContext<TUser>();
|
||||||
|
|
||||||
|
if (requireAuth && !authContext.isAuthenticated) {
|
||||||
|
console.warn(`[AuthRoom] Rejected unauthenticated player: ${player.id}`);
|
||||||
|
this.kick(player as any, 'Authentication required');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (allowedRoles.length > 0) {
|
||||||
|
const hasRole = roleCheckMode === 'any'
|
||||||
|
? authContext.hasAnyRole(allowedRoles)
|
||||||
|
: authContext.hasAllRoles(allowedRoles);
|
||||||
|
|
||||||
|
if (!hasRole) {
|
||||||
|
console.warn(`[AuthRoom] Rejected player ${player.id}: insufficient roles`);
|
||||||
|
this.kick(player as any, 'Insufficient permissions');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const authPlayer = wrapPlayerWithAuth<TUser>(player, authContext);
|
||||||
|
|
||||||
|
if (typeof this.onAuth === 'function') {
|
||||||
|
try {
|
||||||
|
const allowed = await this.onAuth(authPlayer);
|
||||||
|
if (!allowed) {
|
||||||
|
console.warn(`[AuthRoom] Rejected player ${player.id}: onAuth returned false`);
|
||||||
|
this.kick(player as any, 'Authentication rejected');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[AuthRoom] Error in onAuth for player ${player.id}:`, error);
|
||||||
|
this.kick(player as any, 'Authentication error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this._originalOnJoin) {
|
||||||
|
await this._originalOnJoin(authPlayer as unknown as Player);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 获取带认证信息的玩家
|
||||||
|
* @en Get player with auth info
|
||||||
|
*/
|
||||||
|
getAuthPlayer(id: string): AuthPlayer<TUser> | undefined {
|
||||||
|
const player = this.getPlayer(id);
|
||||||
|
return player as AuthPlayer<TUser> | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 获取所有带认证信息的玩家
|
||||||
|
* @en Get all players with auth info
|
||||||
|
*/
|
||||||
|
getAuthPlayers(): AuthPlayer<TUser>[] {
|
||||||
|
return this.players as AuthPlayer<TUser>[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 按角色获取玩家
|
||||||
|
* @en Get players by role
|
||||||
|
*/
|
||||||
|
getPlayersByRole(role: string): AuthPlayer<TUser>[] {
|
||||||
|
return this.getAuthPlayers().filter(p => p.auth?.hasRole(role));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 按用户 ID 获取玩家
|
||||||
|
* @en Get player by user ID
|
||||||
|
*/
|
||||||
|
getPlayerByUserId(userId: string): AuthPlayer<TUser> | undefined {
|
||||||
|
return this.getAuthPlayers().find(p => p.auth?.userId === userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return AuthRoom as unknown as TBase & (new (...args: any[]) => IAuthRoom<TUser>);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 抽象认证房间基类
|
||||||
|
* @en Abstract auth room base class
|
||||||
|
*
|
||||||
|
* @zh 如果不想使用 mixin,可以直接继承此类
|
||||||
|
* @en If you don't want to use mixin, you can extend this class directly
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* import { AuthRoomBase } from '@esengine/server/auth';
|
||||||
|
*
|
||||||
|
* class GameRoom extends AuthRoomBase<User> {
|
||||||
|
* protected readonly authConfig = {
|
||||||
|
* requireAuth: true,
|
||||||
|
* allowedRoles: ['player']
|
||||||
|
* };
|
||||||
|
*
|
||||||
|
* onJoin(player: AuthPlayer<User>) {
|
||||||
|
* // player has .auth and .user properties
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export abstract class AuthRoomBase<TUser = unknown, TState = any, TPlayerData = Record<string, unknown>>
|
||||||
|
implements IAuthRoom<TUser> {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 认证配置(子类可覆盖)
|
||||||
|
* @en Auth config (can be overridden by subclass)
|
||||||
|
*/
|
||||||
|
protected readonly authConfig: AuthRoomConfig = {
|
||||||
|
requireAuth: true,
|
||||||
|
allowedRoles: [],
|
||||||
|
roleCheckMode: 'any'
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 认证钩子
|
||||||
|
* @en Auth hook
|
||||||
|
*/
|
||||||
|
onAuth?(player: AuthPlayer<TUser>): boolean | Promise<boolean>;
|
||||||
|
|
||||||
|
getAuthPlayer(id: string): AuthPlayer<TUser> | undefined {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
getAuthPlayers(): AuthPlayer<TUser>[] {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
getPlayersByRole(role: string): AuthPlayer<TUser>[] {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
getPlayerByUserId(userId: string): AuthPlayer<TUser> | undefined {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
/**
|
||||||
|
* @zh 认证提供者接口
|
||||||
|
* @en Authentication provider interface
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type { IAuthProvider, AuthResult, AuthErrorCode } from '../types.js';
|
||||||
253
packages/framework/server/src/auth/providers/JwtAuthProvider.ts
Normal file
253
packages/framework/server/src/auth/providers/JwtAuthProvider.ts
Normal file
@@ -0,0 +1,253 @@
|
|||||||
|
/**
|
||||||
|
* @zh JWT 认证提供者
|
||||||
|
* @en JWT authentication provider
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { IAuthProvider, AuthResult, AuthErrorCode } from '../types.js';
|
||||||
|
import * as jwt from 'jsonwebtoken';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh JWT 载荷
|
||||||
|
* @en JWT payload
|
||||||
|
*/
|
||||||
|
export interface JwtPayload {
|
||||||
|
/**
|
||||||
|
* @zh 主题(用户 ID)
|
||||||
|
* @en Subject (user ID)
|
||||||
|
*/
|
||||||
|
sub?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 签发时间
|
||||||
|
* @en Issued at
|
||||||
|
*/
|
||||||
|
iat?: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 过期时间
|
||||||
|
* @en Expiration time
|
||||||
|
*/
|
||||||
|
exp?: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 自定义字段
|
||||||
|
* @en Custom fields
|
||||||
|
*/
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh JWT 认证配置
|
||||||
|
* @en JWT authentication configuration
|
||||||
|
*/
|
||||||
|
export interface JwtAuthConfig<TUser = unknown> {
|
||||||
|
/**
|
||||||
|
* @zh 密钥
|
||||||
|
* @en Secret key
|
||||||
|
*/
|
||||||
|
secret: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 算法
|
||||||
|
* @en Algorithm
|
||||||
|
* @defaultValue 'HS256'
|
||||||
|
*/
|
||||||
|
algorithm?: 'HS256' | 'HS384' | 'HS512' | 'RS256' | 'RS384' | 'RS512';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 令牌过期时间(秒)
|
||||||
|
* @en Token expiration in seconds
|
||||||
|
* @defaultValue 3600
|
||||||
|
*/
|
||||||
|
expiresIn?: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 从载荷获取用户信息
|
||||||
|
* @en Get user from payload
|
||||||
|
*/
|
||||||
|
getUser?: (payload: JwtPayload) => TUser | Promise<TUser | null> | null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 签发者
|
||||||
|
* @en Issuer
|
||||||
|
*/
|
||||||
|
issuer?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 受众
|
||||||
|
* @en Audience
|
||||||
|
*/
|
||||||
|
audience?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh JWT 认证提供者
|
||||||
|
* @en JWT authentication provider
|
||||||
|
*
|
||||||
|
* @zh 使用 jsonwebtoken 库实现 JWT 认证
|
||||||
|
* @en Uses jsonwebtoken library for JWT authentication
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* const jwtProvider = createJwtAuthProvider({
|
||||||
|
* secret: 'your-secret-key',
|
||||||
|
* expiresIn: 3600,
|
||||||
|
* getUser: async (payload) => {
|
||||||
|
* return await db.users.findById(payload.sub);
|
||||||
|
* }
|
||||||
|
* });
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export class JwtAuthProvider<TUser = unknown> implements IAuthProvider<TUser, string> {
|
||||||
|
readonly name = 'jwt';
|
||||||
|
|
||||||
|
private _config: Required<Pick<JwtAuthConfig<TUser>, 'secret' | 'algorithm' | 'expiresIn'>> & JwtAuthConfig<TUser>;
|
||||||
|
|
||||||
|
constructor(config: JwtAuthConfig<TUser>) {
|
||||||
|
this._config = {
|
||||||
|
algorithm: 'HS256',
|
||||||
|
expiresIn: 3600,
|
||||||
|
...config
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 验证令牌
|
||||||
|
* @en Verify token
|
||||||
|
*/
|
||||||
|
async verify(token: string): Promise<AuthResult<TUser>> {
|
||||||
|
if (!token) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: 'Token is required',
|
||||||
|
errorCode: 'INVALID_TOKEN'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const verifyOptions: jwt.VerifyOptions = {
|
||||||
|
algorithms: [this._config.algorithm]
|
||||||
|
};
|
||||||
|
if (this._config.issuer) {
|
||||||
|
verifyOptions.issuer = this._config.issuer;
|
||||||
|
}
|
||||||
|
if (this._config.audience) {
|
||||||
|
verifyOptions.audience = this._config.audience;
|
||||||
|
}
|
||||||
|
const payload = jwt.verify(token, this._config.secret, verifyOptions) as JwtPayload;
|
||||||
|
|
||||||
|
let user: TUser | null = null;
|
||||||
|
|
||||||
|
if (this._config.getUser) {
|
||||||
|
user = await this._config.getUser(payload);
|
||||||
|
if (!user) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: 'User not found',
|
||||||
|
errorCode: 'USER_NOT_FOUND'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
user = payload as unknown as TUser;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
user,
|
||||||
|
token,
|
||||||
|
expiresAt: payload.exp ? payload.exp * 1000 : undefined
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
const err = error as Error;
|
||||||
|
|
||||||
|
if (err.name === 'TokenExpiredError') {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: 'Token has expired',
|
||||||
|
errorCode: 'EXPIRED_TOKEN'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: err.message || 'Invalid token',
|
||||||
|
errorCode: 'INVALID_TOKEN'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 刷新令牌
|
||||||
|
* @en Refresh token
|
||||||
|
*/
|
||||||
|
async refresh(token: string): Promise<AuthResult<TUser>> {
|
||||||
|
const result = await this.verify(token);
|
||||||
|
|
||||||
|
if (!result.success || !result.user) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = jwt.decode(token) as JwtPayload;
|
||||||
|
|
||||||
|
// Remove JWT standard claims that will be regenerated
|
||||||
|
const { iat, exp, nbf, ...restPayload } = payload;
|
||||||
|
|
||||||
|
const newToken = this.sign(restPayload);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
user: result.user,
|
||||||
|
token: newToken,
|
||||||
|
expiresAt: Date.now() + this._config.expiresIn * 1000
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 生成令牌
|
||||||
|
* @en Generate token
|
||||||
|
*/
|
||||||
|
sign(payload: Record<string, unknown>): string {
|
||||||
|
const signOptions: jwt.SignOptions = {
|
||||||
|
algorithm: this._config.algorithm,
|
||||||
|
expiresIn: this._config.expiresIn
|
||||||
|
};
|
||||||
|
if (this._config.issuer) {
|
||||||
|
signOptions.issuer = this._config.issuer;
|
||||||
|
}
|
||||||
|
if (this._config.audience) {
|
||||||
|
signOptions.audience = this._config.audience;
|
||||||
|
}
|
||||||
|
return jwt.sign(payload, this._config.secret, signOptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 解码令牌(不验证)
|
||||||
|
* @en Decode token (without verification)
|
||||||
|
*/
|
||||||
|
decode(token: string): JwtPayload | null {
|
||||||
|
return jwt.decode(token) as JwtPayload | null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 创建 JWT 认证提供者
|
||||||
|
* @en Create JWT authentication provider
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* import { createJwtAuthProvider } from '@esengine/server/auth';
|
||||||
|
*
|
||||||
|
* const jwtProvider = createJwtAuthProvider({
|
||||||
|
* secret: process.env.JWT_SECRET!,
|
||||||
|
* expiresIn: 3600,
|
||||||
|
* getUser: async (payload) => {
|
||||||
|
* return { id: payload.sub, name: payload.name };
|
||||||
|
* }
|
||||||
|
* });
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function createJwtAuthProvider<TUser = unknown>(
|
||||||
|
config: JwtAuthConfig<TUser>
|
||||||
|
): JwtAuthProvider<TUser> {
|
||||||
|
return new JwtAuthProvider<TUser>(config);
|
||||||
|
}
|
||||||
@@ -0,0 +1,292 @@
|
|||||||
|
/**
|
||||||
|
* @zh Session 认证提供者
|
||||||
|
* @en Session authentication provider
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { randomBytes } from 'crypto';
|
||||||
|
import type { IAuthProvider, AuthResult } from '../types.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh Session 存储接口(兼容 ITransactionStorage)
|
||||||
|
* @en Session storage interface (compatible with ITransactionStorage)
|
||||||
|
*/
|
||||||
|
export interface ISessionStorage {
|
||||||
|
get<T>(key: string): Promise<T | null>;
|
||||||
|
set<T>(key: string, value: T, ttl?: number): Promise<void>;
|
||||||
|
delete(key: string): Promise<boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh Session 数据
|
||||||
|
* @en Session data
|
||||||
|
*/
|
||||||
|
export interface SessionData<TUser = unknown> {
|
||||||
|
/**
|
||||||
|
* @zh 用户信息
|
||||||
|
* @en User information
|
||||||
|
*/
|
||||||
|
user: TUser;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 创建时间
|
||||||
|
* @en Created at
|
||||||
|
*/
|
||||||
|
createdAt: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 最后活跃时间
|
||||||
|
* @en Last active at
|
||||||
|
*/
|
||||||
|
lastActiveAt: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 自定义数据
|
||||||
|
* @en Custom data
|
||||||
|
*/
|
||||||
|
data?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh Session 认证配置
|
||||||
|
* @en Session authentication configuration
|
||||||
|
*/
|
||||||
|
export interface SessionAuthConfig<TUser = unknown> {
|
||||||
|
/**
|
||||||
|
* @zh Session 存储
|
||||||
|
* @en Session storage
|
||||||
|
*/
|
||||||
|
storage: ISessionStorage;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh Session 过期时间(毫秒)
|
||||||
|
* @en Session expiration in milliseconds
|
||||||
|
* @defaultValue 86400000 (24h)
|
||||||
|
*/
|
||||||
|
sessionTTL?: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh Session 键前缀
|
||||||
|
* @en Session key prefix
|
||||||
|
* @defaultValue 'session:'
|
||||||
|
*/
|
||||||
|
prefix?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 验证用户(可选额外验证)
|
||||||
|
* @en Validate user (optional extra validation)
|
||||||
|
*/
|
||||||
|
validateUser?: (user: TUser) => boolean | Promise<boolean>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 是否自动续期
|
||||||
|
* @en Auto renew session
|
||||||
|
* @defaultValue true
|
||||||
|
*/
|
||||||
|
autoRenew?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh Session 认证提供者
|
||||||
|
* @en Session authentication provider
|
||||||
|
*
|
||||||
|
* @zh 基于存储的会话认证,支持 Redis、MongoDB 等后端
|
||||||
|
* @en Storage-based session authentication, supports Redis, MongoDB, etc.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* import { createSessionAuthProvider } from '@esengine/server/auth';
|
||||||
|
* import { createRedisStorage } from '@esengine/transaction';
|
||||||
|
*
|
||||||
|
* const sessionProvider = createSessionAuthProvider({
|
||||||
|
* storage: createRedisStorage({ factory: () => new Redis() }),
|
||||||
|
* sessionTTL: 86400000, // 24 hours
|
||||||
|
* });
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export class SessionAuthProvider<TUser = unknown> implements IAuthProvider<TUser, string> {
|
||||||
|
readonly name = 'session';
|
||||||
|
|
||||||
|
private _config: Required<Pick<SessionAuthConfig<TUser>, 'sessionTTL' | 'prefix' | 'autoRenew'>> & SessionAuthConfig<TUser>;
|
||||||
|
|
||||||
|
constructor(config: SessionAuthConfig<TUser>) {
|
||||||
|
this._config = {
|
||||||
|
sessionTTL: 86400000,
|
||||||
|
prefix: 'session:',
|
||||||
|
autoRenew: true,
|
||||||
|
...config
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 获取存储键
|
||||||
|
* @en Get storage key
|
||||||
|
*/
|
||||||
|
private _getKey(sessionId: string): string {
|
||||||
|
return `${this._config.prefix}${sessionId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 验证 Session
|
||||||
|
* @en Verify session
|
||||||
|
*/
|
||||||
|
async verify(sessionId: string): Promise<AuthResult<TUser>> {
|
||||||
|
if (!sessionId) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: 'Session ID is required',
|
||||||
|
errorCode: 'INVALID_TOKEN'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = this._getKey(sessionId);
|
||||||
|
const session = await this._config.storage.get<SessionData<TUser>>(key);
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: 'Session not found or expired',
|
||||||
|
errorCode: 'EXPIRED_TOKEN'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this._config.validateUser) {
|
||||||
|
const isValid = await this._config.validateUser(session.user);
|
||||||
|
if (!isValid) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: 'User validation failed',
|
||||||
|
errorCode: 'ACCOUNT_DISABLED'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this._config.autoRenew) {
|
||||||
|
session.lastActiveAt = Date.now();
|
||||||
|
await this._config.storage.set(key, session, this._config.sessionTTL);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
user: session.user,
|
||||||
|
token: sessionId,
|
||||||
|
expiresAt: session.createdAt + this._config.sessionTTL
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 刷新 Session
|
||||||
|
* @en Refresh session
|
||||||
|
*/
|
||||||
|
async refresh(sessionId: string): Promise<AuthResult<TUser>> {
|
||||||
|
const result = await this.verify(sessionId);
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = this._getKey(sessionId);
|
||||||
|
const session = await this._config.storage.get<SessionData<TUser>>(key);
|
||||||
|
|
||||||
|
if (session) {
|
||||||
|
session.lastActiveAt = Date.now();
|
||||||
|
await this._config.storage.set(key, session, this._config.sessionTTL);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...result,
|
||||||
|
expiresAt: Date.now() + this._config.sessionTTL
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 撤销 Session
|
||||||
|
* @en Revoke session
|
||||||
|
*/
|
||||||
|
async revoke(sessionId: string): Promise<boolean> {
|
||||||
|
const key = this._getKey(sessionId);
|
||||||
|
return await this._config.storage.delete(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 创建 Session
|
||||||
|
* @en Create session
|
||||||
|
*/
|
||||||
|
async createSession(user: TUser, data?: Record<string, unknown>): Promise<string> {
|
||||||
|
const sessionId = this._generateSessionId();
|
||||||
|
const key = this._getKey(sessionId);
|
||||||
|
|
||||||
|
const session: SessionData<TUser> = {
|
||||||
|
user,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
lastActiveAt: Date.now(),
|
||||||
|
data
|
||||||
|
};
|
||||||
|
|
||||||
|
await this._config.storage.set(key, session, this._config.sessionTTL);
|
||||||
|
|
||||||
|
return sessionId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 获取 Session 数据
|
||||||
|
* @en Get session data
|
||||||
|
*/
|
||||||
|
async getSession(sessionId: string): Promise<SessionData<TUser> | null> {
|
||||||
|
const key = this._getKey(sessionId);
|
||||||
|
return await this._config.storage.get<SessionData<TUser>>(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 更新 Session 数据
|
||||||
|
* @en Update session data
|
||||||
|
*/
|
||||||
|
async updateSession(sessionId: string, data: Record<string, unknown>): Promise<boolean> {
|
||||||
|
const key = this._getKey(sessionId);
|
||||||
|
const session = await this._config.storage.get<SessionData<TUser>>(key);
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
session.data = { ...session.data, ...data };
|
||||||
|
session.lastActiveAt = Date.now();
|
||||||
|
await this._config.storage.set(key, session, this._config.sessionTTL);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 生成 Session ID(使用加密安全的随机数)
|
||||||
|
* @en Generate session ID (using cryptographically secure random)
|
||||||
|
*/
|
||||||
|
private _generateSessionId(): string {
|
||||||
|
return randomBytes(32).toString('hex');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 创建 Session 认证提供者
|
||||||
|
* @en Create session authentication provider
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* import { createSessionAuthProvider } from '@esengine/server/auth';
|
||||||
|
* import { MemoryStorage } from '@esengine/transaction';
|
||||||
|
*
|
||||||
|
* const sessionProvider = createSessionAuthProvider({
|
||||||
|
* storage: new MemoryStorage(),
|
||||||
|
* sessionTTL: 3600000, // 1 hour
|
||||||
|
* });
|
||||||
|
*
|
||||||
|
* // Create a session
|
||||||
|
* const sessionId = await sessionProvider.createSession({ id: '1', name: 'Alice' });
|
||||||
|
*
|
||||||
|
* // Verify session
|
||||||
|
* const result = await sessionProvider.verify(sessionId);
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function createSessionAuthProvider<TUser = unknown>(
|
||||||
|
config: SessionAuthConfig<TUser>
|
||||||
|
): SessionAuthProvider<TUser> {
|
||||||
|
return new SessionAuthProvider<TUser>(config);
|
||||||
|
}
|
||||||
8
packages/framework/server/src/auth/providers/index.ts
Normal file
8
packages/framework/server/src/auth/providers/index.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
/**
|
||||||
|
* @zh 认证提供者
|
||||||
|
* @en Authentication providers
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type { IAuthProvider, AuthResult, AuthErrorCode } from './IAuthProvider.js';
|
||||||
|
export { JwtAuthProvider, createJwtAuthProvider, type JwtAuthConfig, type JwtPayload } from './JwtAuthProvider.js';
|
||||||
|
export { SessionAuthProvider, createSessionAuthProvider, type SessionAuthConfig, type SessionData, type ISessionStorage } from './SessionAuthProvider.js';
|
||||||
278
packages/framework/server/src/auth/testing/MockAuthProvider.ts
Normal file
278
packages/framework/server/src/auth/testing/MockAuthProvider.ts
Normal file
@@ -0,0 +1,278 @@
|
|||||||
|
/**
|
||||||
|
* @zh 模拟认证提供者
|
||||||
|
* @en Mock authentication provider
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { IAuthProvider, AuthResult, AuthErrorCode } from '../types.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 模拟用户
|
||||||
|
* @en Mock user
|
||||||
|
*/
|
||||||
|
export interface MockUser {
|
||||||
|
id: string;
|
||||||
|
name?: string;
|
||||||
|
roles?: string[];
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 模拟认证配置
|
||||||
|
* @en Mock authentication configuration
|
||||||
|
*/
|
||||||
|
export interface MockAuthConfig {
|
||||||
|
/**
|
||||||
|
* @zh 预设用户列表
|
||||||
|
* @en Preset user list
|
||||||
|
*/
|
||||||
|
users?: MockUser[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 默认用户(无 token 时返回)
|
||||||
|
* @en Default user (returned when no token)
|
||||||
|
*/
|
||||||
|
defaultUser?: MockUser;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 模拟延迟(毫秒)
|
||||||
|
* @en Simulated delay (ms)
|
||||||
|
*/
|
||||||
|
delay?: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 是否自动创建用户
|
||||||
|
* @en Auto create users
|
||||||
|
*/
|
||||||
|
autoCreate?: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 验证令牌格式
|
||||||
|
* @en Token format validator
|
||||||
|
*/
|
||||||
|
validateToken?: (token: string) => boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 模拟认证提供者
|
||||||
|
* @en Mock authentication provider
|
||||||
|
*
|
||||||
|
* @zh 用于测试的认证提供者,不需要真实的 JWT 或数据库
|
||||||
|
* @en Authentication provider for testing, no real JWT or database required
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* import { createMockAuthProvider } from '@esengine/server/auth/testing';
|
||||||
|
*
|
||||||
|
* const mockProvider = createMockAuthProvider({
|
||||||
|
* users: [
|
||||||
|
* { id: '1', name: 'Alice', roles: ['player'] },
|
||||||
|
* { id: '2', name: 'Bob', roles: ['admin'] },
|
||||||
|
* ],
|
||||||
|
* autoCreate: true, // Unknown tokens create guest users
|
||||||
|
* });
|
||||||
|
*
|
||||||
|
* // Verify with user ID as token
|
||||||
|
* const result = await mockProvider.verify('1');
|
||||||
|
* // result.user = { id: '1', name: 'Alice', roles: ['player'] }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export class MockAuthProvider<TUser extends MockUser = MockUser>
|
||||||
|
implements IAuthProvider<TUser, string> {
|
||||||
|
|
||||||
|
readonly name = 'mock';
|
||||||
|
|
||||||
|
private _users: Map<string, TUser>;
|
||||||
|
private _config: MockAuthConfig;
|
||||||
|
private _revokedTokens: Set<string> = new Set();
|
||||||
|
|
||||||
|
constructor(config: MockAuthConfig = {}) {
|
||||||
|
this._config = config;
|
||||||
|
this._users = new Map();
|
||||||
|
|
||||||
|
if (config.users) {
|
||||||
|
for (const user of config.users) {
|
||||||
|
this._users.set(user.id, user as TUser);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 模拟延迟
|
||||||
|
* @en Simulate delay
|
||||||
|
*/
|
||||||
|
private async _delay(): Promise<void> {
|
||||||
|
if (this._config.delay && this._config.delay > 0) {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, this._config.delay));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 验证令牌
|
||||||
|
* @en Verify token
|
||||||
|
*/
|
||||||
|
async verify(token: string): Promise<AuthResult<TUser>> {
|
||||||
|
await this._delay();
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
if (this._config.defaultUser) {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
user: this._config.defaultUser as TUser,
|
||||||
|
token: 'default'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: 'Token is required',
|
||||||
|
errorCode: 'INVALID_TOKEN'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this._revokedTokens.has(token)) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: 'Token has been revoked',
|
||||||
|
errorCode: 'INVALID_TOKEN'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this._config.validateToken && !this._config.validateToken(token)) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: 'Invalid token format',
|
||||||
|
errorCode: 'INVALID_TOKEN'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = this._users.get(token);
|
||||||
|
|
||||||
|
if (user) {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
user,
|
||||||
|
token,
|
||||||
|
expiresAt: Date.now() + 3600000
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this._config.autoCreate) {
|
||||||
|
const newUser = {
|
||||||
|
id: token,
|
||||||
|
name: `User_${token}`,
|
||||||
|
roles: ['guest']
|
||||||
|
} as TUser;
|
||||||
|
|
||||||
|
this._users.set(token, newUser);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
user: newUser,
|
||||||
|
token,
|
||||||
|
expiresAt: Date.now() + 3600000
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: 'User not found',
|
||||||
|
errorCode: 'USER_NOT_FOUND'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 刷新令牌
|
||||||
|
* @en Refresh token
|
||||||
|
*/
|
||||||
|
async refresh(token: string): Promise<AuthResult<TUser>> {
|
||||||
|
return this.verify(token);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 撤销令牌
|
||||||
|
* @en Revoke token
|
||||||
|
*/
|
||||||
|
async revoke(token: string): Promise<boolean> {
|
||||||
|
this._revokedTokens.add(token);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 添加用户
|
||||||
|
* @en Add user
|
||||||
|
*/
|
||||||
|
addUser(user: TUser): void {
|
||||||
|
this._users.set(user.id, user);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 移除用户
|
||||||
|
* @en Remove user
|
||||||
|
*/
|
||||||
|
removeUser(id: string): boolean {
|
||||||
|
return this._users.delete(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 获取用户
|
||||||
|
* @en Get user
|
||||||
|
*/
|
||||||
|
getUser(id: string): TUser | undefined {
|
||||||
|
return this._users.get(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 获取所有用户
|
||||||
|
* @en Get all users
|
||||||
|
*/
|
||||||
|
getUsers(): TUser[] {
|
||||||
|
return Array.from(this._users.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 清空所有状态
|
||||||
|
* @en Clear all state
|
||||||
|
*/
|
||||||
|
clear(): void {
|
||||||
|
this._users.clear();
|
||||||
|
this._revokedTokens.clear();
|
||||||
|
|
||||||
|
if (this._config.users) {
|
||||||
|
for (const user of this._config.users) {
|
||||||
|
this._users.set(user.id, user as TUser);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 生成测试令牌
|
||||||
|
* @en Generate test token
|
||||||
|
*/
|
||||||
|
generateToken(userId: string): string {
|
||||||
|
return userId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 创建模拟认证提供者
|
||||||
|
* @en Create mock authentication provider
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* const provider = createMockAuthProvider({
|
||||||
|
* users: [
|
||||||
|
* { id: 'admin', name: 'Admin', roles: ['admin'] }
|
||||||
|
* ]
|
||||||
|
* });
|
||||||
|
*
|
||||||
|
* // Use in tests
|
||||||
|
* const server = withAuth(await createServer({ port: 0 }), {
|
||||||
|
* provider,
|
||||||
|
* extractCredentials: (req) => req.headers['x-token']
|
||||||
|
* });
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function createMockAuthProvider<TUser extends MockUser = MockUser>(
|
||||||
|
config?: MockAuthConfig
|
||||||
|
): MockAuthProvider<TUser> {
|
||||||
|
return new MockAuthProvider<TUser>(config);
|
||||||
|
}
|
||||||
11
packages/framework/server/src/auth/testing/index.ts
Normal file
11
packages/framework/server/src/auth/testing/index.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
/**
|
||||||
|
* @zh 认证测试工具
|
||||||
|
* @en Authentication testing utilities
|
||||||
|
*/
|
||||||
|
|
||||||
|
export {
|
||||||
|
MockAuthProvider,
|
||||||
|
createMockAuthProvider,
|
||||||
|
type MockUser,
|
||||||
|
type MockAuthConfig
|
||||||
|
} from './MockAuthProvider.js';
|
||||||
400
packages/framework/server/src/auth/types.ts
Normal file
400
packages/framework/server/src/auth/types.ts
Normal file
@@ -0,0 +1,400 @@
|
|||||||
|
/**
|
||||||
|
* @zh 认证系统类型定义
|
||||||
|
* @en Authentication system type definitions
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { ServerConnection, ApiContext, MsgContext, GameServer } from '../types/index.js';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Error Codes
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 认证错误代码
|
||||||
|
* @en Authentication error codes
|
||||||
|
*/
|
||||||
|
export type AuthErrorCode =
|
||||||
|
| 'INVALID_CREDENTIALS'
|
||||||
|
| 'EXPIRED_TOKEN'
|
||||||
|
| 'INVALID_TOKEN'
|
||||||
|
| 'USER_NOT_FOUND'
|
||||||
|
| 'ACCOUNT_DISABLED'
|
||||||
|
| 'RATE_LIMITED'
|
||||||
|
| 'INSUFFICIENT_PERMISSIONS';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Auth Result
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 认证结果
|
||||||
|
* @en Authentication result
|
||||||
|
*/
|
||||||
|
export interface AuthResult<TUser = unknown> {
|
||||||
|
/**
|
||||||
|
* @zh 是否成功
|
||||||
|
* @en Whether succeeded
|
||||||
|
*/
|
||||||
|
success: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 用户信息
|
||||||
|
* @en User information
|
||||||
|
*/
|
||||||
|
user?: TUser;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 错误信息
|
||||||
|
* @en Error message
|
||||||
|
*/
|
||||||
|
error?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 错误代码
|
||||||
|
* @en Error code
|
||||||
|
*/
|
||||||
|
errorCode?: AuthErrorCode;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 令牌
|
||||||
|
* @en Token
|
||||||
|
*/
|
||||||
|
token?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 令牌过期时间(时间戳)
|
||||||
|
* @en Token expiration time (timestamp)
|
||||||
|
*/
|
||||||
|
expiresAt?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Auth Provider
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 认证提供者接口
|
||||||
|
* @en Authentication provider interface
|
||||||
|
*/
|
||||||
|
export interface IAuthProvider<TUser = unknown, TCredentials = unknown> {
|
||||||
|
/**
|
||||||
|
* @zh 提供者名称
|
||||||
|
* @en Provider name
|
||||||
|
*/
|
||||||
|
readonly name: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 验证凭证
|
||||||
|
* @en Verify credentials
|
||||||
|
*
|
||||||
|
* @param credentials - @zh 凭证数据 @en Credential data
|
||||||
|
* @returns @zh 验证结果 @en Verification result
|
||||||
|
*/
|
||||||
|
verify(credentials: TCredentials): Promise<AuthResult<TUser>>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 刷新令牌(可选)
|
||||||
|
* @en Refresh token (optional)
|
||||||
|
*/
|
||||||
|
refresh?(token: string): Promise<AuthResult<TUser>>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 撤销令牌(可选)
|
||||||
|
* @en Revoke token (optional)
|
||||||
|
*/
|
||||||
|
revoke?(token: string): Promise<boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Auth Context
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 认证上下文接口
|
||||||
|
* @en Authentication context interface
|
||||||
|
*/
|
||||||
|
export interface IAuthContext<TUser = unknown> {
|
||||||
|
/**
|
||||||
|
* @zh 是否已认证
|
||||||
|
* @en Whether authenticated
|
||||||
|
*/
|
||||||
|
readonly isAuthenticated: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 用户信息
|
||||||
|
* @en User information
|
||||||
|
*/
|
||||||
|
readonly user: TUser | null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 用户 ID
|
||||||
|
* @en User ID
|
||||||
|
*/
|
||||||
|
readonly userId: string | null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 用户角色
|
||||||
|
* @en User roles
|
||||||
|
*/
|
||||||
|
readonly roles: ReadonlyArray<string>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 认证时间
|
||||||
|
* @en Authentication timestamp
|
||||||
|
*/
|
||||||
|
readonly authenticatedAt: number | null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 令牌过期时间
|
||||||
|
* @en Token expiration time
|
||||||
|
*/
|
||||||
|
readonly expiresAt: number | null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 检查是否有指定角色
|
||||||
|
* @en Check if has specified role
|
||||||
|
*/
|
||||||
|
hasRole(role: string): boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 检查是否有任一指定角色
|
||||||
|
* @en Check if has any of specified roles
|
||||||
|
*/
|
||||||
|
hasAnyRole(roles: string[]): boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 检查是否有所有指定角色
|
||||||
|
* @en Check if has all specified roles
|
||||||
|
*/
|
||||||
|
hasAllRoles(roles: string[]): boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Auth Connection
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 认证连接数据
|
||||||
|
* @en Authentication connection data
|
||||||
|
*/
|
||||||
|
export interface AuthConnectionData<TUser = unknown> {
|
||||||
|
/**
|
||||||
|
* @zh 认证上下文
|
||||||
|
* @en Authentication context
|
||||||
|
*/
|
||||||
|
auth: IAuthContext<TUser>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 带认证的连接
|
||||||
|
* @en Connection with authentication
|
||||||
|
*/
|
||||||
|
export interface AuthConnection<TUser = unknown, TData extends AuthConnectionData<TUser> = AuthConnectionData<TUser>>
|
||||||
|
extends ServerConnection<TData> {
|
||||||
|
/**
|
||||||
|
* @zh 认证上下文(快捷访问)
|
||||||
|
* @en Authentication context (shortcut)
|
||||||
|
*/
|
||||||
|
readonly auth: IAuthContext<TUser>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 带认证的 API 上下文
|
||||||
|
* @en API context with authentication
|
||||||
|
*/
|
||||||
|
export interface AuthApiContext<TUser = unknown, TData extends AuthConnectionData<TUser> = AuthConnectionData<TUser>>
|
||||||
|
extends ApiContext<TData> {
|
||||||
|
/**
|
||||||
|
* @zh 当前连接(带认证)
|
||||||
|
* @en Current connection (with auth)
|
||||||
|
*/
|
||||||
|
conn: AuthConnection<TUser, TData>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 带认证的消息上下文
|
||||||
|
* @en Message context with authentication
|
||||||
|
*/
|
||||||
|
export interface AuthMsgContext<TUser = unknown, TData extends AuthConnectionData<TUser> = AuthConnectionData<TUser>>
|
||||||
|
extends MsgContext<TData> {
|
||||||
|
/**
|
||||||
|
* @zh 当前连接(带认证)
|
||||||
|
* @en Current connection (with auth)
|
||||||
|
*/
|
||||||
|
conn: AuthConnection<TUser, TData>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Auth Server Config
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 连接请求信息
|
||||||
|
* @en Connection request info
|
||||||
|
*/
|
||||||
|
export interface ConnectionRequest {
|
||||||
|
/**
|
||||||
|
* @zh 请求 URL
|
||||||
|
* @en Request URL
|
||||||
|
*/
|
||||||
|
url: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 请求头
|
||||||
|
* @en Request headers
|
||||||
|
*/
|
||||||
|
headers: Record<string, string | string[] | undefined>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 客户端 IP
|
||||||
|
* @en Client IP
|
||||||
|
*/
|
||||||
|
ip: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 认证服务器配置
|
||||||
|
* @en Authentication server configuration
|
||||||
|
*/
|
||||||
|
export interface AuthServerConfig<TUser = unknown> {
|
||||||
|
/**
|
||||||
|
* @zh 认证提供者
|
||||||
|
* @en Authentication provider
|
||||||
|
*/
|
||||||
|
provider: IAuthProvider<TUser>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 从连接请求提取凭证
|
||||||
|
* @en Extract credentials from connection request
|
||||||
|
*/
|
||||||
|
extractCredentials?: (req: ConnectionRequest) => unknown | Promise<unknown>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 连接时自动认证
|
||||||
|
* @en Auto authenticate on connection
|
||||||
|
* @defaultValue true
|
||||||
|
*/
|
||||||
|
autoAuthOnConnect?: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 未认证连接的宽限期(毫秒)
|
||||||
|
* @en Grace period for unauthenticated connections (ms)
|
||||||
|
* @defaultValue 30000
|
||||||
|
*/
|
||||||
|
authGracePeriod?: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 认证失败是否断开连接
|
||||||
|
* @en Disconnect on auth failure
|
||||||
|
* @defaultValue false
|
||||||
|
*/
|
||||||
|
disconnectOnAuthFailure?: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 认证成功回调
|
||||||
|
* @en Authentication success callback
|
||||||
|
*/
|
||||||
|
onAuthSuccess?: (conn: ServerConnection, user: TUser) => void | Promise<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 认证失败回调
|
||||||
|
* @en Authentication failure callback
|
||||||
|
*/
|
||||||
|
onAuthFailure?: (conn: ServerConnection, error: AuthResult<TUser>) => void | Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Auth Game Server
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 带认证的游戏服务器
|
||||||
|
* @en Game server with authentication
|
||||||
|
*/
|
||||||
|
export interface AuthGameServer<TUser = unknown> extends GameServer {
|
||||||
|
/**
|
||||||
|
* @zh 认证提供者
|
||||||
|
* @en Authentication provider
|
||||||
|
*/
|
||||||
|
readonly authProvider: IAuthProvider<TUser>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 手动认证连接
|
||||||
|
* @en Manually authenticate connection
|
||||||
|
*/
|
||||||
|
authenticate(
|
||||||
|
conn: ServerConnection,
|
||||||
|
credentials: unknown
|
||||||
|
): Promise<AuthResult<TUser>>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 获取连接的认证上下文
|
||||||
|
* @en Get auth context for connection
|
||||||
|
*/
|
||||||
|
getAuthContext(conn: ServerConnection): IAuthContext<TUser> | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Auth Room Config
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 带认证的房间配置
|
||||||
|
* @en Auth room configuration
|
||||||
|
*/
|
||||||
|
export interface AuthRoomConfig {
|
||||||
|
/**
|
||||||
|
* @zh 是否要求认证才能加入
|
||||||
|
* @en Require authentication to join
|
||||||
|
* @defaultValue true
|
||||||
|
*/
|
||||||
|
requireAuth?: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 允许的角色(空数组表示任意角色)
|
||||||
|
* @en Allowed roles (empty array means any role)
|
||||||
|
*/
|
||||||
|
allowedRoles?: string[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 角色检查模式
|
||||||
|
* @en Role check mode
|
||||||
|
* @defaultValue 'any'
|
||||||
|
*/
|
||||||
|
roleCheckMode?: 'any' | 'all';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Decorator Options
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh requireAuth 装饰器选项
|
||||||
|
* @en requireAuth decorator options
|
||||||
|
*/
|
||||||
|
export interface RequireAuthOptions {
|
||||||
|
/**
|
||||||
|
* @zh 认证失败时的错误消息
|
||||||
|
* @en Error message on auth failure
|
||||||
|
*/
|
||||||
|
errorMessage?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 是否允许访客
|
||||||
|
* @en Allow guest access
|
||||||
|
*/
|
||||||
|
allowGuest?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh requireRole 装饰器选项
|
||||||
|
* @en requireRole decorator options
|
||||||
|
*/
|
||||||
|
export interface RequireRoleOptions extends RequireAuthOptions {
|
||||||
|
/**
|
||||||
|
* @zh 角色检查模式
|
||||||
|
* @en Role check mode
|
||||||
|
* @defaultValue 'any'
|
||||||
|
*/
|
||||||
|
mode?: 'any' | 'all';
|
||||||
|
}
|
||||||
253
packages/framework/server/src/core/server.ts
Normal file
253
packages/framework/server/src/core/server.ts
Normal file
@@ -0,0 +1,253 @@
|
|||||||
|
/**
|
||||||
|
* @zh 游戏服务器核心
|
||||||
|
* @en Game server core
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as path from 'node:path'
|
||||||
|
import { serve, type RpcServer } from '@esengine/rpc/server'
|
||||||
|
import { rpc } from '@esengine/rpc'
|
||||||
|
import type {
|
||||||
|
ServerConfig,
|
||||||
|
ServerConnection,
|
||||||
|
GameServer,
|
||||||
|
ApiContext,
|
||||||
|
MsgContext,
|
||||||
|
LoadedApiHandler,
|
||||||
|
LoadedMsgHandler,
|
||||||
|
} from '../types/index.js'
|
||||||
|
import { loadApiHandlers, loadMsgHandlers } from '../router/loader.js'
|
||||||
|
import { RoomManager, type RoomClass, type Room } from '../room/index.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 默认配置
|
||||||
|
* @en Default configuration
|
||||||
|
*/
|
||||||
|
const DEFAULT_CONFIG: Required<Omit<ServerConfig, 'onStart' | 'onConnect' | 'onDisconnect'>> = {
|
||||||
|
port: 3000,
|
||||||
|
apiDir: 'src/api',
|
||||||
|
msgDir: 'src/msg',
|
||||||
|
tickRate: 20,
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 创建游戏服务器
|
||||||
|
* @en Create game server
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* import { createServer, Room, onMessage } from '@esengine/server'
|
||||||
|
*
|
||||||
|
* class GameRoom extends Room {
|
||||||
|
* onJoin(player) {
|
||||||
|
* this.broadcast('Joined', { id: player.id })
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* const server = await createServer({ port: 3000 })
|
||||||
|
* server.define('game', GameRoom)
|
||||||
|
* await server.start()
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export async function createServer(config: ServerConfig = {}): Promise<GameServer> {
|
||||||
|
const opts = { ...DEFAULT_CONFIG, ...config }
|
||||||
|
const cwd = process.cwd()
|
||||||
|
|
||||||
|
// 加载文件路由处理器
|
||||||
|
const apiHandlers = await loadApiHandlers(path.resolve(cwd, opts.apiDir))
|
||||||
|
const msgHandlers = await loadMsgHandlers(path.resolve(cwd, opts.msgDir))
|
||||||
|
|
||||||
|
if (apiHandlers.length > 0) {
|
||||||
|
console.log(`[Server] Loaded ${apiHandlers.length} API handlers`)
|
||||||
|
}
|
||||||
|
if (msgHandlers.length > 0) {
|
||||||
|
console.log(`[Server] Loaded ${msgHandlers.length} message handlers`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 动态构建协议
|
||||||
|
const apiDefs: Record<string, ReturnType<typeof rpc.api>> = {
|
||||||
|
// 内置 API
|
||||||
|
JoinRoom: rpc.api(),
|
||||||
|
LeaveRoom: rpc.api(),
|
||||||
|
}
|
||||||
|
const msgDefs: Record<string, ReturnType<typeof rpc.msg>> = {
|
||||||
|
// 内置消息(房间消息透传)
|
||||||
|
RoomMessage: rpc.msg(),
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const handler of apiHandlers) {
|
||||||
|
apiDefs[handler.name] = rpc.api()
|
||||||
|
}
|
||||||
|
for (const handler of msgHandlers) {
|
||||||
|
msgDefs[handler.name] = rpc.msg()
|
||||||
|
}
|
||||||
|
|
||||||
|
const protocol = rpc.define({
|
||||||
|
api: apiDefs,
|
||||||
|
msg: msgDefs,
|
||||||
|
})
|
||||||
|
|
||||||
|
// 服务器状态
|
||||||
|
let currentTick = 0
|
||||||
|
let tickInterval: ReturnType<typeof setInterval> | null = null
|
||||||
|
let rpcServer: RpcServer<typeof protocol, Record<string, unknown>> | null = null
|
||||||
|
|
||||||
|
// 房间管理器(立即初始化,以便 define() 可在 start() 前调用)
|
||||||
|
const roomManager = new RoomManager((conn, type, data) => {
|
||||||
|
rpcServer?.send(conn, 'RoomMessage' as any, { type, data } as any)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 构建 API 处理器映射
|
||||||
|
const apiMap: Record<string, LoadedApiHandler> = {}
|
||||||
|
for (const handler of apiHandlers) {
|
||||||
|
apiMap[handler.name] = handler
|
||||||
|
}
|
||||||
|
|
||||||
|
// 构建消息处理器映射
|
||||||
|
const msgMap: Record<string, LoadedMsgHandler> = {}
|
||||||
|
for (const handler of msgHandlers) {
|
||||||
|
msgMap[handler.name] = handler
|
||||||
|
}
|
||||||
|
|
||||||
|
// 游戏服务器实例
|
||||||
|
const gameServer: GameServer & {
|
||||||
|
rooms: RoomManager
|
||||||
|
} = {
|
||||||
|
get connections() {
|
||||||
|
return (rpcServer?.connections ?? []) as ReadonlyArray<ServerConnection>
|
||||||
|
},
|
||||||
|
|
||||||
|
get tick() {
|
||||||
|
return currentTick
|
||||||
|
},
|
||||||
|
|
||||||
|
get rooms() {
|
||||||
|
return roomManager
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 注册房间类型
|
||||||
|
* @en Define room type
|
||||||
|
*/
|
||||||
|
define(name: string, roomClass: new () => unknown): void {
|
||||||
|
roomManager.define(name, roomClass as RoomClass)
|
||||||
|
},
|
||||||
|
|
||||||
|
async start() {
|
||||||
|
// 构建 API handlers
|
||||||
|
const apiHandlersObj: Record<string, (input: unknown, conn: any) => Promise<unknown>> = {}
|
||||||
|
|
||||||
|
// 内置 JoinRoom API
|
||||||
|
apiHandlersObj['JoinRoom'] = async (input: any, conn) => {
|
||||||
|
const { roomType, roomId, options } = input as {
|
||||||
|
roomType?: string
|
||||||
|
roomId?: string
|
||||||
|
options?: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
if (roomId) {
|
||||||
|
const result = await roomManager.joinById(roomId, conn.id, conn)
|
||||||
|
if (!result) {
|
||||||
|
throw new Error('Failed to join room')
|
||||||
|
}
|
||||||
|
return { roomId: result.room.id, playerId: result.player.id }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (roomType) {
|
||||||
|
const result = await roomManager.joinOrCreate(roomType, conn.id, conn, options)
|
||||||
|
if (!result) {
|
||||||
|
throw new Error('Failed to join or create room')
|
||||||
|
}
|
||||||
|
return { roomId: result.room.id, playerId: result.player.id }
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('roomType or roomId required')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 内置 LeaveRoom API
|
||||||
|
apiHandlersObj['LeaveRoom'] = async (_input, conn) => {
|
||||||
|
await roomManager.leave(conn.id)
|
||||||
|
return { success: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 文件路由 API
|
||||||
|
for (const [name, handler] of Object.entries(apiMap)) {
|
||||||
|
apiHandlersObj[name] = async (input, conn) => {
|
||||||
|
const ctx: ApiContext = {
|
||||||
|
conn: conn as ServerConnection,
|
||||||
|
server: gameServer,
|
||||||
|
}
|
||||||
|
return handler.definition.handler(input, ctx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 构建消息 handlers
|
||||||
|
const msgHandlersObj: Record<string, (data: unknown, conn: any) => void | Promise<void>> = {}
|
||||||
|
|
||||||
|
// 内置 RoomMessage 处理
|
||||||
|
msgHandlersObj['RoomMessage'] = async (data: any, conn) => {
|
||||||
|
const { type, data: payload } = data as { type: string; data: unknown }
|
||||||
|
roomManager.handleMessage(conn.id, type, payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 文件路由消息
|
||||||
|
for (const [name, handler] of Object.entries(msgMap)) {
|
||||||
|
msgHandlersObj[name] = async (data, conn) => {
|
||||||
|
const ctx: MsgContext = {
|
||||||
|
conn: conn as ServerConnection,
|
||||||
|
server: gameServer,
|
||||||
|
}
|
||||||
|
await handler.definition.handler(data, ctx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rpcServer = serve(protocol, {
|
||||||
|
port: opts.port,
|
||||||
|
createConnData: () => ({}),
|
||||||
|
onStart: (p) => {
|
||||||
|
console.log(`[Server] Started on ws://localhost:${p}`)
|
||||||
|
opts.onStart?.(p)
|
||||||
|
},
|
||||||
|
onConnect: async (conn) => {
|
||||||
|
await config.onConnect?.(conn as ServerConnection)
|
||||||
|
},
|
||||||
|
onDisconnect: async (conn) => {
|
||||||
|
// 玩家断线时自动离开房间
|
||||||
|
await roomManager?.leave(conn.id, 'disconnected')
|
||||||
|
await config.onDisconnect?.(conn as ServerConnection)
|
||||||
|
},
|
||||||
|
api: apiHandlersObj as any,
|
||||||
|
msg: msgHandlersObj as any,
|
||||||
|
})
|
||||||
|
|
||||||
|
await rpcServer.start()
|
||||||
|
|
||||||
|
// 启动 tick 循环
|
||||||
|
if (opts.tickRate > 0) {
|
||||||
|
tickInterval = setInterval(() => {
|
||||||
|
currentTick++
|
||||||
|
}, 1000 / opts.tickRate)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async stop() {
|
||||||
|
if (tickInterval) {
|
||||||
|
clearInterval(tickInterval)
|
||||||
|
tickInterval = null
|
||||||
|
}
|
||||||
|
if (rpcServer) {
|
||||||
|
await rpcServer.stop()
|
||||||
|
rpcServer = null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
broadcast(name, data) {
|
||||||
|
rpcServer?.broadcast(name as any, data as any)
|
||||||
|
},
|
||||||
|
|
||||||
|
send(conn, name, data) {
|
||||||
|
rpcServer?.send(conn as any, name as any, data as any)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return gameServer as GameServer
|
||||||
|
}
|
||||||
51
packages/framework/server/src/helpers/define.ts
Normal file
51
packages/framework/server/src/helpers/define.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
/**
|
||||||
|
* @zh API 和消息定义助手
|
||||||
|
* @en API and message definition helpers
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { ApiDefinition, MsgDefinition } from '../types/index.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 定义 API 处理器
|
||||||
|
* @en Define API handler
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* // src/api/join.ts
|
||||||
|
* import { defineApi } from '@esengine/server'
|
||||||
|
*
|
||||||
|
* export default defineApi<ReqJoin, ResJoin>({
|
||||||
|
* handler(req, ctx) {
|
||||||
|
* ctx.conn.data.playerId = generateId()
|
||||||
|
* return { playerId: ctx.conn.data.playerId }
|
||||||
|
* }
|
||||||
|
* })
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function defineApi<TReq, TRes, TData = Record<string, unknown>>(
|
||||||
|
definition: ApiDefinition<TReq, TRes, TData>
|
||||||
|
): ApiDefinition<TReq, TRes, TData> {
|
||||||
|
return definition
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 定义消息处理器
|
||||||
|
* @en Define message handler
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* // src/msg/input.ts
|
||||||
|
* import { defineMsg } from '@esengine/server'
|
||||||
|
*
|
||||||
|
* export default defineMsg<MsgInput>({
|
||||||
|
* handler(msg, ctx) {
|
||||||
|
* console.log('Input from', ctx.conn.id, msg)
|
||||||
|
* }
|
||||||
|
* })
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function defineMsg<TMsg, TData = Record<string, unknown>>(
|
||||||
|
definition: MsgDefinition<TMsg, TData>
|
||||||
|
): MsgDefinition<TMsg, TData> {
|
||||||
|
return definition
|
||||||
|
}
|
||||||
52
packages/framework/server/src/index.ts
Normal file
52
packages/framework/server/src/index.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
/**
|
||||||
|
* @zh ESEngine 游戏服务器框架
|
||||||
|
* @en ESEngine Game Server Framework
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* import { createServer, Room, onMessage } from '@esengine/server'
|
||||||
|
*
|
||||||
|
* class GameRoom extends Room {
|
||||||
|
* maxPlayers = 4
|
||||||
|
* tickRate = 20
|
||||||
|
*
|
||||||
|
* onJoin(player) {
|
||||||
|
* this.broadcast('Joined', { id: player.id })
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* @onMessage('Move')
|
||||||
|
* handleMove(data, player) {
|
||||||
|
* // handle move
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* const server = await createServer({ port: 3000 })
|
||||||
|
* server.define('game', GameRoom)
|
||||||
|
* await server.start()
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Core
|
||||||
|
export { createServer } from './core/server.js'
|
||||||
|
|
||||||
|
// Helpers
|
||||||
|
export { defineApi, defineMsg } from './helpers/define.js'
|
||||||
|
|
||||||
|
// Room System
|
||||||
|
export { Room, type RoomOptions } from './room/Room.js'
|
||||||
|
export { Player, type IPlayer } from './room/Player.js'
|
||||||
|
export { onMessage } from './room/decorators.js'
|
||||||
|
|
||||||
|
// Types
|
||||||
|
export type {
|
||||||
|
ServerConfig,
|
||||||
|
ServerConnection,
|
||||||
|
GameServer,
|
||||||
|
ApiContext,
|
||||||
|
MsgContext,
|
||||||
|
ApiDefinition,
|
||||||
|
MsgDefinition,
|
||||||
|
} from './types/index.js'
|
||||||
|
|
||||||
|
// Re-export useful types from @esengine/rpc
|
||||||
|
export { RpcError, ErrorCode } from '@esengine/rpc'
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import { RateLimitContext } from '../context';
|
||||||
|
import { TokenBucketStrategy } from '../strategies/TokenBucket';
|
||||||
|
import { FixedWindowStrategy } from '../strategies/FixedWindow';
|
||||||
|
|
||||||
|
describe('RateLimitContext', () => {
|
||||||
|
let globalStrategy: TokenBucketStrategy;
|
||||||
|
let context: RateLimitContext;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
globalStrategy = new TokenBucketStrategy({
|
||||||
|
rate: 10,
|
||||||
|
capacity: 20
|
||||||
|
});
|
||||||
|
context = new RateLimitContext('player-123', globalStrategy);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('check', () => {
|
||||||
|
it('should check without consuming', () => {
|
||||||
|
const result1 = context.check();
|
||||||
|
const result2 = context.check();
|
||||||
|
|
||||||
|
expect(result1.remaining).toBe(result2.remaining);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use global strategy by default', () => {
|
||||||
|
const result = context.check();
|
||||||
|
expect(result.allowed).toBe(true);
|
||||||
|
expect(result.remaining).toBe(20);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('consume', () => {
|
||||||
|
it('should consume from global strategy', () => {
|
||||||
|
const result = context.consume();
|
||||||
|
expect(result.allowed).toBe(true);
|
||||||
|
expect(result.remaining).toBe(19);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should track consecutive limits', () => {
|
||||||
|
for (let i = 0; i < 25; i++) {
|
||||||
|
context.consume();
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(context.consecutiveLimitCount).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reset consecutive count on success', () => {
|
||||||
|
// Consume all 20 tokens plus some more to trigger rate limiting
|
||||||
|
for (let i = 0; i < 25; i++) {
|
||||||
|
context.consume();
|
||||||
|
}
|
||||||
|
|
||||||
|
// After consuming 25 tokens (20 capacity), 5 should be rate limited
|
||||||
|
expect(context.consecutiveLimitCount).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
context.reset();
|
||||||
|
const result = context.consume();
|
||||||
|
|
||||||
|
expect(result.allowed).toBe(true);
|
||||||
|
expect(context.consecutiveLimitCount).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('reset', () => {
|
||||||
|
it('should reset global strategy', () => {
|
||||||
|
for (let i = 0; i < 15; i++) {
|
||||||
|
context.consume();
|
||||||
|
}
|
||||||
|
|
||||||
|
context.reset();
|
||||||
|
|
||||||
|
const status = context.check();
|
||||||
|
expect(status.remaining).toBe(20);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reset specific message type', () => {
|
||||||
|
const msgStrategy = new FixedWindowStrategy({ rate: 5, capacity: 5 });
|
||||||
|
context.setMessageStrategy('Trade', msgStrategy);
|
||||||
|
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
context.consume('Trade');
|
||||||
|
}
|
||||||
|
|
||||||
|
context.reset('Trade');
|
||||||
|
|
||||||
|
const status = context.check('Trade');
|
||||||
|
expect(status.remaining).toBe(5);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('message strategies', () => {
|
||||||
|
it('should use message-specific strategy', () => {
|
||||||
|
const tradeStrategy = new FixedWindowStrategy({ rate: 1, capacity: 1 });
|
||||||
|
context.setMessageStrategy('Trade', tradeStrategy);
|
||||||
|
|
||||||
|
const result1 = context.consume('Trade');
|
||||||
|
expect(result1.allowed).toBe(true);
|
||||||
|
|
||||||
|
const result2 = context.consume('Trade');
|
||||||
|
expect(result2.allowed).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fall back to global strategy for unknown types', () => {
|
||||||
|
const result = context.consume('UnknownType');
|
||||||
|
expect(result.allowed).toBe(true);
|
||||||
|
expect(result.remaining).toBe(19);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should check if message strategy exists', () => {
|
||||||
|
expect(context.hasMessageStrategy('Trade')).toBe(false);
|
||||||
|
|
||||||
|
const tradeStrategy = new FixedWindowStrategy({ rate: 1, capacity: 1 });
|
||||||
|
context.setMessageStrategy('Trade', tradeStrategy);
|
||||||
|
|
||||||
|
expect(context.hasMessageStrategy('Trade')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should remove message strategy', () => {
|
||||||
|
const tradeStrategy = new FixedWindowStrategy({ rate: 1, capacity: 1 });
|
||||||
|
context.setMessageStrategy('Trade', tradeStrategy);
|
||||||
|
|
||||||
|
context.removeMessageStrategy('Trade');
|
||||||
|
|
||||||
|
expect(context.hasMessageStrategy('Trade')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('resetConsecutiveCount', () => {
|
||||||
|
it('should reset consecutive limit count', () => {
|
||||||
|
for (let i = 0; i < 25; i++) {
|
||||||
|
context.consume();
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(context.consecutiveLimitCount).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
context.resetConsecutiveCount();
|
||||||
|
|
||||||
|
expect(context.consecutiveLimitCount).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import {
|
||||||
|
rateLimit,
|
||||||
|
noRateLimit,
|
||||||
|
rateLimitMessage,
|
||||||
|
noRateLimitMessage,
|
||||||
|
getRateLimitMetadata,
|
||||||
|
RATE_LIMIT_METADATA_KEY
|
||||||
|
} from '../decorators/rateLimit';
|
||||||
|
|
||||||
|
describe('rateLimitMessage decorator', () => {
|
||||||
|
class TestClass {
|
||||||
|
@rateLimitMessage('Trade', { messagesPerSecond: 1, burstSize: 2 })
|
||||||
|
handleTrade() {
|
||||||
|
return 'trade';
|
||||||
|
}
|
||||||
|
|
||||||
|
@rateLimitMessage('Move', { cost: 2 })
|
||||||
|
handleMove() {
|
||||||
|
return 'move';
|
||||||
|
}
|
||||||
|
|
||||||
|
undecorated() {
|
||||||
|
return 'undecorated';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('metadata storage', () => {
|
||||||
|
it('should store rate limit metadata on target', () => {
|
||||||
|
const metadata = getRateLimitMetadata(TestClass.prototype, 'Trade');
|
||||||
|
expect(metadata).toBeDefined();
|
||||||
|
expect(metadata?.enabled).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should store config in metadata', () => {
|
||||||
|
const metadata = getRateLimitMetadata(TestClass.prototype, 'Trade');
|
||||||
|
expect(metadata?.config?.messagesPerSecond).toBe(1);
|
||||||
|
expect(metadata?.config?.burstSize).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should store cost in metadata', () => {
|
||||||
|
const metadata = getRateLimitMetadata(TestClass.prototype, 'Move');
|
||||||
|
expect(metadata?.config?.cost).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return undefined for unregistered message types', () => {
|
||||||
|
const metadata = getRateLimitMetadata(TestClass.prototype, 'Unknown');
|
||||||
|
expect(metadata).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('method behavior', () => {
|
||||||
|
it('should not alter method behavior', () => {
|
||||||
|
const instance = new TestClass();
|
||||||
|
expect(instance.handleTrade()).toBe('trade');
|
||||||
|
expect(instance.handleMove()).toBe('move');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('noRateLimitMessage decorator', () => {
|
||||||
|
class TestClass {
|
||||||
|
@noRateLimitMessage('Heartbeat')
|
||||||
|
handleHeartbeat() {
|
||||||
|
return 'heartbeat';
|
||||||
|
}
|
||||||
|
|
||||||
|
@noRateLimitMessage('Ping')
|
||||||
|
handlePing() {
|
||||||
|
return 'ping';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('metadata storage', () => {
|
||||||
|
it('should mark message as exempt', () => {
|
||||||
|
const metadata = getRateLimitMetadata(TestClass.prototype, 'Heartbeat');
|
||||||
|
expect(metadata?.exempt).toBe(true);
|
||||||
|
expect(metadata?.enabled).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should store for multiple messages', () => {
|
||||||
|
const heartbeatMeta = getRateLimitMetadata(TestClass.prototype, 'Heartbeat');
|
||||||
|
const pingMeta = getRateLimitMetadata(TestClass.prototype, 'Ping');
|
||||||
|
|
||||||
|
expect(heartbeatMeta?.exempt).toBe(true);
|
||||||
|
expect(pingMeta?.exempt).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('method behavior', () => {
|
||||||
|
it('should not alter method behavior', () => {
|
||||||
|
const instance = new TestClass();
|
||||||
|
expect(instance.handleHeartbeat()).toBe('heartbeat');
|
||||||
|
expect(instance.handlePing()).toBe('ping');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('combined decorators', () => {
|
||||||
|
class CombinedTestClass {
|
||||||
|
@rateLimitMessage('SlowAction', { messagesPerSecond: 1 })
|
||||||
|
handleSlow() {
|
||||||
|
return 'slow';
|
||||||
|
}
|
||||||
|
|
||||||
|
@noRateLimitMessage('FastAction')
|
||||||
|
handleFast() {
|
||||||
|
return 'fast';
|
||||||
|
}
|
||||||
|
|
||||||
|
@rateLimitMessage('ExpensiveAction', { cost: 10 })
|
||||||
|
handleExpensive() {
|
||||||
|
return 'expensive';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
it('should handle multiple different decorators', () => {
|
||||||
|
const slowMeta = getRateLimitMetadata(CombinedTestClass.prototype, 'SlowAction');
|
||||||
|
const fastMeta = getRateLimitMetadata(CombinedTestClass.prototype, 'FastAction');
|
||||||
|
const expensiveMeta = getRateLimitMetadata(CombinedTestClass.prototype, 'ExpensiveAction');
|
||||||
|
|
||||||
|
expect(slowMeta?.enabled).toBe(true);
|
||||||
|
expect(slowMeta?.config?.messagesPerSecond).toBe(1);
|
||||||
|
|
||||||
|
expect(fastMeta?.exempt).toBe(true);
|
||||||
|
expect(fastMeta?.enabled).toBe(false);
|
||||||
|
|
||||||
|
expect(expensiveMeta?.enabled).toBe(true);
|
||||||
|
expect(expensiveMeta?.config?.cost).toBe(10);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('RATE_LIMIT_METADATA_KEY', () => {
|
||||||
|
it('should be a symbol', () => {
|
||||||
|
expect(typeof RATE_LIMIT_METADATA_KEY).toBe('symbol');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be used for metadata storage', () => {
|
||||||
|
class TestClass {
|
||||||
|
@rateLimitMessage('Test', {})
|
||||||
|
handleTest() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const metadataMap = (TestClass.prototype as any)[RATE_LIMIT_METADATA_KEY];
|
||||||
|
expect(metadataMap).toBeInstanceOf(Map);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('rateLimit decorator (auto-detect)', () => {
|
||||||
|
it('should be a decorator function', () => {
|
||||||
|
expect(typeof rateLimit).toBe('function');
|
||||||
|
expect(typeof rateLimit()).toBe('function');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should accept config', () => {
|
||||||
|
class TestClass {
|
||||||
|
@rateLimit({ messagesPerSecond: 5 })
|
||||||
|
someMethod() {
|
||||||
|
return 'test';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const instance = new TestClass();
|
||||||
|
expect(instance.someMethod()).toBe('test');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('noRateLimit decorator (auto-detect)', () => {
|
||||||
|
it('should be a decorator function', () => {
|
||||||
|
expect(typeof noRateLimit).toBe('function');
|
||||||
|
expect(typeof noRateLimit()).toBe('function');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should work as decorator', () => {
|
||||||
|
class TestClass {
|
||||||
|
@noRateLimit()
|
||||||
|
someMethod() {
|
||||||
|
return 'test';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const instance = new TestClass();
|
||||||
|
expect(instance.someMethod()).toBe('test');
|
||||||
|
});
|
||||||
|
});
|
||||||
157
packages/framework/server/src/ratelimit/__tests__/mixin.test.ts
Normal file
157
packages/framework/server/src/ratelimit/__tests__/mixin.test.ts
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
|
import { Room } from '../../room/Room';
|
||||||
|
import { Player } from '../../room/Player';
|
||||||
|
import { withRateLimit, getPlayerRateLimitContext } from '../mixin/withRateLimit';
|
||||||
|
import { noRateLimitMessage, rateLimitMessage } from '../decorators/rateLimit';
|
||||||
|
import { onMessage } from '../../room/decorators';
|
||||||
|
|
||||||
|
describe('withRateLimit mixin', () => {
|
||||||
|
let RateLimitedRoom: ReturnType<typeof withRateLimit>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
RateLimitedRoom = withRateLimit(Room, {
|
||||||
|
messagesPerSecond: 10,
|
||||||
|
burstSize: 20
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('basic functionality', () => {
|
||||||
|
it('should create a rate limited room class', () => {
|
||||||
|
expect(RateLimitedRoom).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have rateLimitStrategy property', () => {
|
||||||
|
class TestRoom extends RateLimitedRoom {
|
||||||
|
onCreate() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const room = new TestRoom();
|
||||||
|
expect(room.rateLimitStrategy).toBeDefined();
|
||||||
|
expect(room.rateLimitStrategy.name).toBe('token-bucket');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('strategy selection', () => {
|
||||||
|
it('should use token-bucket by default', () => {
|
||||||
|
class TestRoom extends withRateLimit(Room) {
|
||||||
|
onCreate() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const room = new TestRoom();
|
||||||
|
expect(room.rateLimitStrategy.name).toBe('token-bucket');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use sliding-window when specified', () => {
|
||||||
|
class TestRoom extends withRateLimit(Room, { strategy: 'sliding-window' }) {
|
||||||
|
onCreate() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const room = new TestRoom();
|
||||||
|
expect(room.rateLimitStrategy.name).toBe('sliding-window');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use fixed-window when specified', () => {
|
||||||
|
class TestRoom extends withRateLimit(Room, { strategy: 'fixed-window' }) {
|
||||||
|
onCreate() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const room = new TestRoom();
|
||||||
|
expect(room.rateLimitStrategy.name).toBe('fixed-window');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('configuration', () => {
|
||||||
|
it('should use default values', () => {
|
||||||
|
class TestRoom extends withRateLimit(Room) {
|
||||||
|
onCreate() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const room = new TestRoom();
|
||||||
|
expect(room.rateLimitStrategy).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should accept custom messagesPerSecond', () => {
|
||||||
|
class TestRoom extends withRateLimit(Room, { messagesPerSecond: 5 }) {
|
||||||
|
onCreate() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const room = new TestRoom();
|
||||||
|
expect(room.rateLimitStrategy).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should accept custom burstSize', () => {
|
||||||
|
class TestRoom extends withRateLimit(Room, { burstSize: 50 }) {
|
||||||
|
onCreate() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const room = new TestRoom();
|
||||||
|
expect(room.rateLimitStrategy).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('dispose', () => {
|
||||||
|
it('should clean up on dispose', () => {
|
||||||
|
class TestRoom extends RateLimitedRoom {
|
||||||
|
onCreate() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const room = new TestRoom();
|
||||||
|
room._init({
|
||||||
|
id: 'test-room',
|
||||||
|
sendFn: vi.fn(),
|
||||||
|
broadcastFn: vi.fn(),
|
||||||
|
disposeFn: vi.fn()
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(() => room.dispose()).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('withRateLimit with auth', () => {
|
||||||
|
it('should be composable with other mixins', () => {
|
||||||
|
class TestRoom extends withRateLimit(Room, { messagesPerSecond: 10 }) {
|
||||||
|
onCreate() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const room = new TestRoom();
|
||||||
|
expect(room.rateLimitStrategy).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getPlayerRateLimitContext', () => {
|
||||||
|
it('should return null for player without context', () => {
|
||||||
|
const mockPlayer = {
|
||||||
|
id: 'player-1',
|
||||||
|
roomId: 'room-1',
|
||||||
|
data: {},
|
||||||
|
send: vi.fn(),
|
||||||
|
leave: vi.fn()
|
||||||
|
} as unknown as Player;
|
||||||
|
|
||||||
|
const context = getPlayerRateLimitContext(mockPlayer);
|
||||||
|
expect(context).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('decorator metadata', () => {
|
||||||
|
it('rateLimitMessage should set metadata', () => {
|
||||||
|
class TestRoom extends withRateLimit(Room) {
|
||||||
|
@rateLimitMessage('Trade', { messagesPerSecond: 1 })
|
||||||
|
@onMessage('Trade')
|
||||||
|
handleTrade() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(TestRoom).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('noRateLimitMessage should set exempt metadata', () => {
|
||||||
|
class TestRoom extends withRateLimit(Room) {
|
||||||
|
@noRateLimitMessage('Heartbeat')
|
||||||
|
@onMessage('Heartbeat')
|
||||||
|
handleHeartbeat() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(TestRoom).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,249 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
||||||
|
import { TokenBucketStrategy, createTokenBucketStrategy } from '../strategies/TokenBucket';
|
||||||
|
import { SlidingWindowStrategy, createSlidingWindowStrategy } from '../strategies/SlidingWindow';
|
||||||
|
import { FixedWindowStrategy, createFixedWindowStrategy } from '../strategies/FixedWindow';
|
||||||
|
|
||||||
|
describe('TokenBucketStrategy', () => {
|
||||||
|
let strategy: TokenBucketStrategy;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
strategy = createTokenBucketStrategy({
|
||||||
|
rate: 10,
|
||||||
|
capacity: 20
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('consume', () => {
|
||||||
|
it('should allow requests when tokens available', () => {
|
||||||
|
const result = strategy.consume('user-1');
|
||||||
|
expect(result.allowed).toBe(true);
|
||||||
|
expect(result.remaining).toBe(19);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should consume multiple tokens', () => {
|
||||||
|
const result = strategy.consume('user-1', 5);
|
||||||
|
expect(result.allowed).toBe(true);
|
||||||
|
expect(result.remaining).toBe(15);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should deny when not enough tokens', () => {
|
||||||
|
for (let i = 0; i < 20; i++) {
|
||||||
|
strategy.consume('user-1');
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = strategy.consume('user-1');
|
||||||
|
expect(result.allowed).toBe(false);
|
||||||
|
expect(result.remaining).toBe(0);
|
||||||
|
expect(result.retryAfter).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should refill tokens over time', async () => {
|
||||||
|
for (let i = 0; i < 20; i++) {
|
||||||
|
strategy.consume('user-1');
|
||||||
|
}
|
||||||
|
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 150));
|
||||||
|
|
||||||
|
const result = strategy.consume('user-1');
|
||||||
|
expect(result.allowed).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle different keys independently', () => {
|
||||||
|
for (let i = 0; i < 20; i++) {
|
||||||
|
strategy.consume('user-1');
|
||||||
|
}
|
||||||
|
|
||||||
|
const result1 = strategy.consume('user-1');
|
||||||
|
const result2 = strategy.consume('user-2');
|
||||||
|
|
||||||
|
expect(result1.allowed).toBe(false);
|
||||||
|
expect(result2.allowed).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getStatus', () => {
|
||||||
|
it('should return full capacity for new key', () => {
|
||||||
|
const status = strategy.getStatus('new-user');
|
||||||
|
expect(status.remaining).toBe(20);
|
||||||
|
expect(status.allowed).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not consume tokens', () => {
|
||||||
|
strategy.getStatus('user-1');
|
||||||
|
const status = strategy.getStatus('user-1');
|
||||||
|
expect(status.remaining).toBe(20);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('reset', () => {
|
||||||
|
it('should reset key to full capacity', () => {
|
||||||
|
for (let i = 0; i < 15; i++) {
|
||||||
|
strategy.consume('user-1');
|
||||||
|
}
|
||||||
|
|
||||||
|
strategy.reset('user-1');
|
||||||
|
|
||||||
|
const status = strategy.getStatus('user-1');
|
||||||
|
expect(status.remaining).toBe(20);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('cleanup', () => {
|
||||||
|
it('should clean up full buckets', async () => {
|
||||||
|
strategy.consume('user-1');
|
||||||
|
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
|
|
||||||
|
strategy.cleanup();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('SlidingWindowStrategy', () => {
|
||||||
|
let strategy: SlidingWindowStrategy;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
strategy = createSlidingWindowStrategy({
|
||||||
|
rate: 10,
|
||||||
|
capacity: 10
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('consume', () => {
|
||||||
|
it('should allow requests within capacity', () => {
|
||||||
|
const result = strategy.consume('user-1');
|
||||||
|
expect(result.allowed).toBe(true);
|
||||||
|
expect(result.remaining).toBe(9);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should deny when capacity exceeded', () => {
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
strategy.consume('user-1');
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = strategy.consume('user-1');
|
||||||
|
expect(result.allowed).toBe(false);
|
||||||
|
expect(result.remaining).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow after window expires', async () => {
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
strategy.consume('user-1');
|
||||||
|
}
|
||||||
|
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 1100));
|
||||||
|
|
||||||
|
const result = strategy.consume('user-1');
|
||||||
|
expect(result.allowed).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getStatus', () => {
|
||||||
|
it('should return full capacity for new key', () => {
|
||||||
|
const status = strategy.getStatus('new-user');
|
||||||
|
expect(status.remaining).toBe(10);
|
||||||
|
expect(status.allowed).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('reset', () => {
|
||||||
|
it('should clear timestamps', () => {
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
strategy.consume('user-1');
|
||||||
|
}
|
||||||
|
|
||||||
|
strategy.reset('user-1');
|
||||||
|
|
||||||
|
const status = strategy.getStatus('user-1');
|
||||||
|
expect(status.remaining).toBe(10);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('FixedWindowStrategy', () => {
|
||||||
|
let strategy: FixedWindowStrategy;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
strategy = createFixedWindowStrategy({
|
||||||
|
rate: 10,
|
||||||
|
capacity: 10
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('consume', () => {
|
||||||
|
it('should allow requests within capacity', () => {
|
||||||
|
const result = strategy.consume('user-1');
|
||||||
|
expect(result.allowed).toBe(true);
|
||||||
|
expect(result.remaining).toBe(9);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should deny when capacity exceeded', () => {
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
strategy.consume('user-1');
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = strategy.consume('user-1');
|
||||||
|
expect(result.allowed).toBe(false);
|
||||||
|
expect(result.retryAfter).toBeGreaterThanOrEqual(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reset at window boundary', async () => {
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
strategy.consume('user-1');
|
||||||
|
}
|
||||||
|
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 1100));
|
||||||
|
|
||||||
|
const result = strategy.consume('user-1');
|
||||||
|
expect(result.allowed).toBe(true);
|
||||||
|
expect(result.remaining).toBe(9);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getStatus', () => {
|
||||||
|
it('should return full capacity for new key', () => {
|
||||||
|
const status = strategy.getStatus('new-user');
|
||||||
|
expect(status.remaining).toBe(10);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('reset', () => {
|
||||||
|
it('should reset count', () => {
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
strategy.consume('user-1');
|
||||||
|
}
|
||||||
|
|
||||||
|
strategy.reset('user-1');
|
||||||
|
|
||||||
|
const status = strategy.getStatus('user-1');
|
||||||
|
expect(status.remaining).toBe(10);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('cleanup', () => {
|
||||||
|
it('should clean up old windows', async () => {
|
||||||
|
strategy.consume('user-1');
|
||||||
|
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 2100));
|
||||||
|
|
||||||
|
strategy.cleanup();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Factory functions', () => {
|
||||||
|
it('createTokenBucketStrategy should create TokenBucketStrategy', () => {
|
||||||
|
const strategy = createTokenBucketStrategy({ rate: 5, capacity: 10 });
|
||||||
|
expect(strategy.name).toBe('token-bucket');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('createSlidingWindowStrategy should create SlidingWindowStrategy', () => {
|
||||||
|
const strategy = createSlidingWindowStrategy({ rate: 5, capacity: 5 });
|
||||||
|
expect(strategy.name).toBe('sliding-window');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('createFixedWindowStrategy should create FixedWindowStrategy', () => {
|
||||||
|
const strategy = createFixedWindowStrategy({ rate: 5, capacity: 5 });
|
||||||
|
expect(strategy.name).toBe('fixed-window');
|
||||||
|
});
|
||||||
|
});
|
||||||
146
packages/framework/server/src/ratelimit/context.ts
Normal file
146
packages/framework/server/src/ratelimit/context.ts
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
/**
|
||||||
|
* @zh 速率限制上下文
|
||||||
|
* @en Rate limit context
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type {
|
||||||
|
IRateLimitContext,
|
||||||
|
IRateLimitStrategy,
|
||||||
|
RateLimitResult,
|
||||||
|
MessageRateLimitConfig
|
||||||
|
} from './types.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 速率限制上下文
|
||||||
|
* @en Rate limit context
|
||||||
|
*
|
||||||
|
* @zh 管理单个玩家的速率限制状态,支持全局限制和按消息类型限制
|
||||||
|
* @en Manages rate limit status for a single player, supports global and per-message-type limits
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* const context = new RateLimitContext('player-123', globalStrategy);
|
||||||
|
*
|
||||||
|
* // Check global rate limit
|
||||||
|
* const result = context.consume();
|
||||||
|
*
|
||||||
|
* // Check per-message rate limit
|
||||||
|
* const tradeResult = context.consume('Trade', 1);
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export class RateLimitContext implements IRateLimitContext {
|
||||||
|
private _key: string;
|
||||||
|
private _globalStrategy: IRateLimitStrategy;
|
||||||
|
private _messageStrategies: Map<string, IRateLimitStrategy> = new Map();
|
||||||
|
private _consecutiveLimitCount: number = 0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 创建速率限制上下文
|
||||||
|
* @en Create rate limit context
|
||||||
|
*
|
||||||
|
* @param key - @zh 限流键(通常是玩家ID)@en Rate limit key (usually player ID)
|
||||||
|
* @param globalStrategy - @zh 全局限流策略 @en Global rate limit strategy
|
||||||
|
*/
|
||||||
|
constructor(key: string, globalStrategy: IRateLimitStrategy) {
|
||||||
|
this._key = key;
|
||||||
|
this._globalStrategy = globalStrategy;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 获取连续被限流次数
|
||||||
|
* @en Get consecutive limit count
|
||||||
|
*/
|
||||||
|
get consecutiveLimitCount(): number {
|
||||||
|
return this._consecutiveLimitCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 检查是否允许(不消费)
|
||||||
|
* @en Check if allowed (without consuming)
|
||||||
|
*/
|
||||||
|
check(messageType?: string): RateLimitResult {
|
||||||
|
if (messageType && this._messageStrategies.has(messageType)) {
|
||||||
|
return this._messageStrategies.get(messageType)!.getStatus(this._key);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this._globalStrategy.getStatus(this._key);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 消费配额
|
||||||
|
* @en Consume quota
|
||||||
|
*/
|
||||||
|
consume(messageType?: string, cost: number = 1): RateLimitResult {
|
||||||
|
let result: RateLimitResult;
|
||||||
|
|
||||||
|
if (messageType && this._messageStrategies.has(messageType)) {
|
||||||
|
result = this._messageStrategies.get(messageType)!.consume(this._key, cost);
|
||||||
|
} else {
|
||||||
|
result = this._globalStrategy.consume(this._key, cost);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.allowed) {
|
||||||
|
this._consecutiveLimitCount = 0;
|
||||||
|
} else {
|
||||||
|
this._consecutiveLimitCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 重置限流状态
|
||||||
|
* @en Reset rate limit status
|
||||||
|
*/
|
||||||
|
reset(messageType?: string): void {
|
||||||
|
if (messageType) {
|
||||||
|
if (this._messageStrategies.has(messageType)) {
|
||||||
|
this._messageStrategies.get(messageType)!.reset(this._key);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this._globalStrategy.reset(this._key);
|
||||||
|
for (const strategy of this._messageStrategies.values()) {
|
||||||
|
strategy.reset(this._key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 重置连续限流计数
|
||||||
|
* @en Reset consecutive limit count
|
||||||
|
*/
|
||||||
|
resetConsecutiveCount(): void {
|
||||||
|
this._consecutiveLimitCount = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 为特定消息类型设置独立的限流策略
|
||||||
|
* @en Set independent rate limit strategy for specific message type
|
||||||
|
*
|
||||||
|
* @param messageType - @zh 消息类型 @en Message type
|
||||||
|
* @param strategy - @zh 限流策略 @en Rate limit strategy
|
||||||
|
*/
|
||||||
|
setMessageStrategy(messageType: string, strategy: IRateLimitStrategy): void {
|
||||||
|
this._messageStrategies.set(messageType, strategy);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 移除特定消息类型的限流策略
|
||||||
|
* @en Remove rate limit strategy for specific message type
|
||||||
|
*
|
||||||
|
* @param messageType - @zh 消息类型 @en Message type
|
||||||
|
*/
|
||||||
|
removeMessageStrategy(messageType: string): void {
|
||||||
|
this._messageStrategies.delete(messageType);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @zh 检查是否有特定消息类型的限流策略
|
||||||
|
* @en Check if has rate limit strategy for specific message type
|
||||||
|
*
|
||||||
|
* @param messageType - @zh 消息类型 @en Message type
|
||||||
|
*/
|
||||||
|
hasMessageStrategy(messageType: string): boolean {
|
||||||
|
return this._messageStrategies.has(messageType);
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user