Compare commits

...

23 Commits

Author SHA1 Message Date
github-actions[bot]
764ce67742 chore: release packages (#387)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-12-29 16:12:03 +08:00
YHH
61a13baca2 feat(server): 添加可插拔认证系统 | add pluggable authentication system (#386)
* feat(server): 添加可插拔认证系统 | add pluggable authentication system

- 新增 JWT 认证提供者 (createJwtAuthProvider)
- 新增 Session 认证提供者 (createSessionAuthProvider)
- 新增服务器认证 mixin (withAuth)
- 新增房间认证 mixin (withRoomAuth)
- 新增认证装饰器 (@requireAuth, @requireRole)
- 新增测试工具 (MockAuthProvider)
- 新增中英文文档
- 导出路径: @esengine/server/auth, @esengine/server/auth/testing

* fix(server): 使用加密安全的随机数生成 session ID | use crypto-secure random for session ID
2025-12-29 16:10:09 +08:00
github-actions[bot]
1cfa64aa0f chore: release packages (#385)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-12-29 15:04:07 +08:00
YHH
3b978384c7 feat(framework): server testing utils, transaction storage simplify, pathfinding tests (#384)
## Server Testing Utils
- Add TestServer, TestClient, MockRoom for unit testing
- Export testing utilities from @esengine/server/testing

## Transaction Storage (BREAKING)
- Simplify RedisStorage/MongoStorage to factory pattern only
- Remove DI client injection option
- Add lazy connection and Symbol.asyncDispose support
- Add 161 unit tests with full coverage

## Pathfinding Tests
- Add 150 unit tests covering all components
- BinaryHeap, Heuristics, AStarPathfinder, GridMap, NavMesh, PathSmoother

## Docs
- Update storage.md for new factory pattern API
2025-12-29 15:02:13 +08:00
YHH
10c3891abd docs: 添加缺失的侧边栏导航项 | add missing sidebar items (#383)
- RPC: 添加 server, client, codec 页面
- Network: 添加 prediction, aoi, delta 页面
- Transaction: 添加完整模块导航
- Changelog: 添加 transaction, rpc 链接
2025-12-29 11:29:42 +08:00
github-actions[bot]
18af48a0fc chore: release packages (#382)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-12-29 11:08:46 +08:00
YHH
d4cef828e1 feat(transaction): 添加游戏事务系统 | add game transaction system (#381)
- TransactionManager/TransactionContext 事务管理
- MemoryStorage/RedisStorage/MongoStorage 存储实现
- CurrencyOperation/InventoryOperation/TradeOperation 内置操作
- SagaOrchestrator 分布式 Saga 编排
- withTransactions() Room 集成
- 完整中英文文档
2025-12-29 10:54:00 +08:00
github-actions[bot]
2d46ccf896 chore: release packages (#380)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-12-29 10:44:48 +08:00
YHH
fb8bde6485 feat(network): 网络模块增强 - 预测、AOI、增量压缩 (#379)
- 添加 NetworkPredictionSystem 客户端预测系统
- 添加 NetworkAOISystem 兴趣区域管理
- 添加 StateDeltaCompressor 状态增量压缩
- 添加断线重连和状态恢复
- 增强协议支持时间戳、序列号、速度
- 添加中英文文档
2025-12-29 10:42:48 +08:00
yhh
30437dc5d5 docs: add world-streaming to sidebar navigation 2025-12-28 19:50:44 +08:00
YHH
9f84c2f870 chore: bump pathfinding and world-streaming to 1.1.0 (#378) 2025-12-28 19:36:34 +08:00
github-actions[bot]
e9ea52d9b3 chore: release packages (#377)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-12-28 19:26:41 +08:00
YHH
0662b07445 chore: update pathfinding, add rpc/world-streaming docs, refactor world-streaming location (#376) 2025-12-28 19:18:28 +08:00
github-actions[bot]
838cda91aa chore: release packages (#375)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-12-28 14:11:27 +08:00
YHH
a000cc07d7 feat(rpc): export RpcClient from main entry point (#374) 2025-12-28 14:09:16 +08:00
github-actions[bot]
1316d7de49 chore: release packages (#373)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-12-28 13:49:34 +08:00
YHH
9c41181875 fix(server): expose id property on ServerConnection type (#372) 2025-12-28 13:47:27 +08:00
github-actions[bot]
9f3f9a547a chore: release packages (#371)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-12-28 13:32:44 +08:00
YHH
18df9d1cda fix(server): allow define() to be called before start() (#370) 2025-12-28 13:29:17 +08:00
github-actions[bot]
9a4b3388e0 chore: release packages (#369)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-12-28 12:51:20 +08:00
YHH
66d5dc27f7 fix(server): 修复发布缺少 dist | fix missing dist (#368)
* fix(server): 修复发布缺少 dist | fix missing dist in publish

* ci: 添加 server 和 create-esengine-server 构建 | add server packages to build

* fix: 添加 create-esengine-server 到 changeset
2025-12-28 12:46:50 +08:00
github-actions[bot]
8a3e54cb45 chore: release packages (#367)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-12-28 12:25:51 +08:00
YHH
b6f1235239 feat(server): 添加游戏服务器框架 | add game server framework (#366)
**@esengine/server** - 游戏服务器框架 | Game server framework
- 文件路由系统 | File-based routing
- Room 生命周期 (onCreate, onJoin, onLeave, onTick, onDispose)
- @onMessage 装饰器 | Message handler decorator
- 玩家管理与断线处理 | Player management with auto-disconnect
- 内置 JoinRoom/LeaveRoom API | Built-in room APIs
- defineApi/defineMsg 类型安全辅助函数 | Type-safe helpers

**create-esengine-server** - CLI 脚手架工具 | CLI scaffolding
- 生成 shared/server/client 项目结构 | Project structure
- 类型安全的协议定义 | Type-safe protocol definitions
- 包含 GameRoom 示例 | Example implementation

**其他 | Other**
- 删除旧的 network-server 包 | Remove old network-server
- 更新服务器文档 | Update server documentation
2025-12-28 12:23:55 +08:00
177 changed files with 28851 additions and 1049 deletions

View File

@@ -56,7 +56,9 @@ jobs:
pnpm --filter "@esengine/network-protocols" build
pnpm --filter "@esengine/rpc" build
pnpm --filter "@esengine/network" build
pnpm --filter "@esengine/server" build
pnpm --filter "@esengine/cli" build
pnpm --filter "create-esengine-server" build
- name: Create Release Pull Request or Publish
id: changesets

View File

@@ -255,6 +255,9 @@ export default defineConfig({
translations: { en: 'RPC' },
items: [
{ 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,36 @@ export default defineConfig({
{ label: '概述', slug: 'modules/network', translations: { en: 'Overview' } },
{ label: '客户端', slug: 'modules/network/client', translations: { en: 'Client' } },
{ label: '服务器', slug: 'modules/network/server', translations: { en: 'Server' } },
{ label: '认证系统', slug: 'modules/network/auth', translations: { en: 'Authentication' } },
{ 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: '事务系统',
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 +321,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/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/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' } },
],
},

View File

@@ -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 |
| [Spatial](/en/modules/spatial/) | `@esengine/spatial` | Spatial queries, AOI management |
| [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
@@ -33,6 +34,7 @@ ESEngine provides a rich set of modules that can be imported as needed.
| Module | Package | Description |
|--------|---------|-------------|
| [Network](/en/modules/network/) | `@esengine/network` | Multiplayer game networking |
| [Transaction](/en/modules/transaction/) | `@esengine/transaction` | Game transactions with distributed support |
## Installation

View 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);
}
```

View 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.

View 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),
});
}
```

View File

@@ -147,7 +147,10 @@ service.on('chat', (data) => {
- [Client Usage](/en/modules/network/client/) - NetworkPlugin, components and systems
- [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
## Service Tokens
@@ -159,10 +162,14 @@ import {
NetworkServiceToken,
NetworkSyncSystemToken,
NetworkSpawnSystemToken,
NetworkInputSystemToken
NetworkInputSystemToken,
NetworkPredictionSystemToken,
NetworkAOISystemToken,
} from '@esengine/network';
const networkService = services.get(NetworkServiceToken);
const predictionSystem = services.get(NetworkPredictionSystemToken);
const aoiSystem = services.get(NetworkAOISystemToken);
```
## Blueprint Nodes

View 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);
}
```

View File

@@ -1,76 +1,335 @@
---
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
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,
roomConfig: {
maxPlayers: 16,
tickRate: 20
onConnect(conn) {
console.log('Client connected:', conn.id)
},
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();
await server.stop();
```
// Lifecycle
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 |
|----------|------|-------------|
| `port` | `number` | WebSocket port |
| `roomConfig.maxPlayers` | `number` | Max players per room |
| `roomConfig.tickRate` | `number` | Sync rate (Hz) |
this.broadcast('Joined', {
playerId: player.id,
playerName: player.data.name,
})
}
## Room
onLeave(player: Player<PlayerData>) {
this.broadcast('Left', { playerId: player.id })
}
```typescript
class Room {
readonly id: string;
readonly playerCount: number;
readonly isFull: boolean;
onTick(dt: number) {
// State synchronization
this.broadcast('Sync', { players: this.state.players })
}
addPlayer(name: string, connection: Connection): IPlayer | null;
removePlayer(clientId: number): void;
getPlayer(clientId: number): IPlayer | undefined;
handleInput(clientId: number, input: IPlayerInput): void;
destroy(): void;
onDispose() {
console.log(`Room ${this.id} disposed`)
}
// 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
interface MsgSync {
time: number;
entities: IEntityState[];
class Room<TState, TPlayerData> {
readonly id: string // Room ID
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 {
netId: number;
ownerId: number;
prefab: string;
pos: Vec2;
rot: number;
export interface JoinRoomRes {
roomId: string
playerId: string
}
interface MsgDespawn {
netId: number;
// Game messages
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
1. **Set appropriate tick rate**: Choose based on game type (20-60 Hz for action games)
2. **Room size control**: Set reasonable `maxPlayers` based on server capacity
3. **State validation**: Server should validate client inputs to prevent cheating
1. **Set Appropriate Tick Rate**
- Turn-based games: 5-10 Hz
- 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`

View 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 });
}
}
```

View 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'
```

View 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();
```

View 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}`);
}
```

View 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 });
}
```

View 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) });
}
```

View 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,
}));
```

View 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
}
```

View File

@@ -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();
```

View 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;
}
}
```

View 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

View File

@@ -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;
}
}
```

View File

@@ -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
}
});
```

View File

@@ -28,12 +28,14 @@ ESEngine 提供了丰富的功能模块,可以按需引入到你的项目中
|------|------|------|
| [可视化脚本](/modules/blueprint/) | `@esengine/blueprint` | 蓝图可视化脚本系统 |
| [程序化生成](/modules/procgen/) | `@esengine/procgen` | 噪声函数、随机工具 |
| [世界流式加载](/modules/world-streaming/) | `@esengine/world-streaming` | 开放世界区块流式加载 |
### 网络模块
| 模块 | 包名 | 描述 |
|------|------|------|
| [网络同步](/modules/network/) | `@esengine/network` | 多人游戏网络同步 |
| [事务系统](/modules/transaction/) | `@esengine/transaction` | 游戏事务处理,支持分布式事务 |
## 安装

View File

@@ -0,0 +1,283 @@
---
title: "兴趣区域管理 (AOI)"
description: "基于视野范围的网络实体过滤"
---
AOIArea 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);
}
```

View 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` 测试认证场景。

View 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),
});
}
```

View File

@@ -147,7 +147,10 @@ service.on('chat', (data) => {
- [客户端使用](/modules/network/client/) - NetworkPlugin、组件和系统
- [服务器端](/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 文档
## 服务令牌
@@ -159,10 +162,14 @@ import {
NetworkServiceToken,
NetworkSyncSystemToken,
NetworkSpawnSystemToken,
NetworkInputSystemToken
NetworkInputSystemToken,
NetworkPredictionSystemToken,
NetworkAOISystemToken,
} from '@esengine/network';
const networkService = services.get(NetworkServiceToken);
const predictionSystem = services.get(NetworkPredictionSystemToken);
const aoiSystem = services.get(NetworkAOISystemToken);
```
## 蓝图节点

View 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);
}
```

View File

@@ -1,164 +1,21 @@
---
title: "服务器端"
description: "GameServer 和 Room 管理"
description: "使用 @esengine/server 构建游戏服务器"
---
## GameServer
## 快速开始
GameServer 是服务器端的核心类,管理 WebSocket 连接和房间。
### 基本用法
```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 快速创建完整的游戏服务端:
使用 CLI 创建新的游戏服务器项目:
```bash
mkdir my-game-server && cd my-game-server
npm init -y
npx @esengine/cli init -p nodejs
# 使用 npm
npm create esengine-server my-game-server
# 使用 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/
├── src/
│ ├── index.ts # 入口文件
│ ├── server/
│ │ └── GameServer.ts # 网络服务器配置
── game/
├── Game.ts # ECS 游戏主类
── scenes/
└── MainScene.ts # 主场景
├── components/ # ECS 组件
│ ├── PositionComponent.ts
│ │ └── VelocityComponent.ts
│ └── systems/ # ECS 系统
│ └── MovementSystem.ts
├── tsconfig.json
│ ├── shared/ # 共享协议(客户端服务端通用)
│ ├── protocol.ts # 类型定义
│ │ └── index.ts
── server/ # 服务端代码
├── main.ts # 入口
── rooms/
└── GameRoom.ts # 游戏房间
└── client/ # 客户端示例
└── index.ts
├── package.json
└── README.md
└── tsconfig.json
```
启动服务
启动服务
```bash
# 开发模式(热重载)
@@ -193,15 +46,290 @@ npm run dev
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
- 休闲游戏10-20 Hz
- 动作游戏20-60 Hz
2. **房间大小控制**:根据服务器性能设置合理的 `maxPlayers`
2. **使用共享协议**
-`shared/` 目录定义所有类型
- 客户端和服务端都从这里导入
3. **连接管理**:监听玩家连接/断开事件,处理异常情况
3. **状态验证**
- 服务器应验证客户端输入
- 不信任客户端发送的任何数据
4. **状态验证**:服务器应验证客户端输入,防止作弊
4. **断线处理**
- 实现断线重连逻辑
- 使用 `onLeave` 保存玩家状态
5. **房间生命周期**
- 使用 `autoDispose` 自动清理空房间
-`onDispose` 中保存重要数据

View 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 });
}
}
```

View 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'
```

View 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();
```

View 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}`);
}
```

View 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 });
}
```

View 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) });
}
```

View 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,
}));
```

View 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> {
// 保存事务日志
}
// ... 实现其他方法
}
```

View 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();
```

View 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;
}
}
```

View 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 区块

View 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;
}
}
```

View File

@@ -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) => {
// 清理 - 保存状态,释放资源
}
});
```

View File

@@ -1,5 +1,47 @@
# @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
### Minor Changes

View File

@@ -1,6 +1,6 @@
{
"name": "@esengine/network",
"version": "2.1.0",
"version": "2.2.0",
"description": "Network synchronization for multiplayer games",
"esengine": {
"plugin": true,

View File

@@ -1,13 +1,126 @@
/**
* @zh 网络插件
* @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 { GameNetworkService, type NetworkServiceOptions } from './services/NetworkService'
import { NetworkSyncSystem } from './systems/NetworkSyncSystem'
import {
GameNetworkService,
type NetworkServiceOptions,
NetworkState,
} from './services/NetworkService'
import { NetworkSyncSystem, type NetworkSyncConfig } from './systems/NetworkSyncSystem'
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 网络插件
@@ -21,7 +134,10 @@ import { NetworkInputSystem } from './systems/NetworkInputSystem'
* import { Core } from '@esengine/ecs-framework'
* import { NetworkPlugin } from '@esengine/network'
*
* const networkPlugin = new NetworkPlugin()
* const networkPlugin = new NetworkPlugin({
* enablePrediction: true,
* enableAutoReconnect: true
* })
* await Core.installPlugin(networkPlugin)
*
* // 连接到服务器
@@ -36,13 +152,28 @@ import { NetworkInputSystem } from './systems/NetworkInputSystem'
*/
export class NetworkPlugin implements IPlugin {
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 _syncSystem!: NetworkSyncSystem
private _spawnSystem!: NetworkSpawnSystem
private _inputSystem!: NetworkInputSystem
private _predictionSystem: NetworkPredictionSystem | null = null
private _aoiSystem: NetworkAOISystem | null = null
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 网络服务
@@ -76,6 +207,22 @@ export class NetworkPlugin implements IPlugin {
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
* @en Local player ID
@@ -92,6 +239,34 @@ export class NetworkPlugin implements IPlugin {
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 安装插件
* @en Install plugin
@@ -110,13 +285,28 @@ export class NetworkPlugin implements IPlugin {
* @en Uninstall plugin
*/
uninstall(): void {
this._clearReconnectTimer()
this._networkService?.disconnect()
}
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._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._spawnSystem)
@@ -127,8 +317,14 @@ export class NetworkPlugin implements IPlugin {
private _setupMessageHandlers(): void {
this._networkService
.onSync((data) => {
this._syncSystem.handleSync({ entities: data.entities })
.onSync((data: SyncData) => {
// Use new sync handler with timestamps
this._syncSystem.handleSyncData(data)
// Reconcile prediction if enabled
if (this._predictionSystem) {
this._predictionSystem.reconcileWithServer(data)
}
})
.onSpawn((data) => {
this._spawnSystem.handleSpawn(data)
@@ -136,14 +332,32 @@ export class NetworkPlugin implements IPlugin {
.onDespawn((data) => {
this._spawnSystem.handleDespawn(data)
})
// Handle full state for reconnection
this._networkService.on('fullState', (data: FullStateData) => {
this._handleFullState(data)
})
}
// =========================================================================
// Connection | 连接管理
// =========================================================================
/**
* @zh 连接到服务器
* @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 {
// Setup disconnect handler for auto-reconnect
const originalOnDisconnect = options.onDisconnect
options.onDisconnect = (reason) => {
originalOnDisconnect?.(reason)
this._handleDisconnect(reason)
}
await this._networkService.connect(options)
const result = await this._networkService.call('join', {
@@ -154,8 +368,25 @@ export class NetworkPlugin implements IPlugin {
this._localPlayerId = result.playerId
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
} catch (err) {
console.error('[NetworkPlugin] Connection failed:', err)
return false
}
}
@@ -165,14 +396,114 @@ export class NetworkPlugin implements IPlugin {
* @en Disconnect
*/
public async disconnect(): Promise<void> {
this._clearReconnectTimer()
this._reconnectState = null
try {
await this._networkService.call('leave', undefined)
} catch {
// ignore
}
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 注册预制体工厂
* @en Register prefab factory
@@ -196,4 +527,78 @@ export class NetworkPlugin implements IPlugin {
public sendActionInput(action: string): void {
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
}
}
}

View File

@@ -35,8 +35,11 @@ export {
type SyncData,
type SpawnData,
type DespawnData,
type FullStateData,
type JoinRequest,
type JoinResponse,
type ReconnectRequest,
type ReconnectResponse,
} from './protocol'
// ============================================================================
@@ -48,6 +51,8 @@ export {
NetworkSyncSystemToken,
NetworkSpawnSystemToken,
NetworkInputSystemToken,
NetworkPredictionSystemToken,
NetworkAOISystemToken,
} from './tokens'
// ============================================================================
@@ -81,10 +86,30 @@ export { NetworkTransform } from './components/NetworkTransform'
// ============================================================================
export { NetworkSyncSystem } from './systems/NetworkSyncSystem'
export type { SyncMessage } from './systems/NetworkSyncSystem'
export type { SyncMessage, NetworkSyncConfig } from './systems/NetworkSyncSystem'
export { NetworkSpawnSystem } 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 | 状态同步
@@ -105,6 +130,9 @@ export type {
IPredictedState,
IPredictor,
ClientPredictionConfig,
EntityDeltaState,
DeltaSyncData,
DeltaCompressionConfig,
} from './sync'
export {
@@ -119,6 +147,9 @@ export {
createHermiteTransformInterpolator,
ClientPrediction,
createClientPrediction,
DeltaFlags,
StateDeltaCompressor,
createStateDeltaCompressor,
} from './sync'
// ============================================================================

View File

@@ -17,12 +17,24 @@ import { rpc } from '@esengine/rpc'
* @en Player input
*/
export interface PlayerInput {
/**
* @zh 输入序列号(用于客户端预测)
* @en Input sequence number (for client prediction)
*/
seq: number
/**
* @zh 帧序号
* @en Frame number
*/
frame: number
/**
* @zh 客户端时间戳
* @en Client timestamp
*/
timestamp: number
/**
* @zh 移动方向
* @en Move direction
@@ -41,9 +53,41 @@ export interface PlayerInput {
* @en Entity sync state
*/
export interface EntitySyncState {
/**
* @zh 网络实体 ID
* @en Network entity ID
*/
netId: number
/**
* @zh 位置
* @en Position
*/
pos?: { x: number; y: number }
/**
* @zh 旋转角度
* @en Rotation angle
*/
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
/**
* @zh 服务器时间戳(用于插值)
* @en Server timestamp (for interpolation)
*/
timestamp: number
/**
* @zh 已确认的输入序列号(用于客户端预测校正)
* @en Acknowledged input sequence (for client prediction reconciliation)
*/
ackSeq?: number
/**
* @zh 实体状态列表
* @en Entity state list
@@ -84,6 +140,30 @@ export interface DespawnData {
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 类型
// ============================================================================
@@ -106,6 +186,54 @@ export interface JoinResponse {
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 | 协议定义
// ============================================================================
@@ -145,6 +273,12 @@ export const gameProtocol = rpc.define({
* @en Leave room
*/
leave: rpc.api<void, void>(),
/**
* @zh 重连
* @en Reconnect
*/
reconnect: rpc.api<ReconnectRequest, ReconnectResponse>(),
},
msg: {
/**
@@ -170,6 +304,12 @@ export const gameProtocol = rpc.define({
* @en Entity despawn
*/
despawn: rpc.msg<DespawnData>(),
/**
* @zh 完整状态快照
* @en Full state snapshot
*/
fullState: rpc.msg<FullStateData>(),
},
})

View 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)
}

View File

@@ -46,3 +46,19 @@ export type {
} from './ClientPrediction';
export { ClientPrediction, createClientPrediction } from './ClientPrediction';
// =============================================================================
// 状态增量压缩 | State Delta Compression
// =============================================================================
export type {
EntityDeltaState,
DeltaSyncData,
DeltaCompressionConfig
} from './StateDelta';
export {
DeltaFlags,
StateDeltaCompressor,
createStateDeltaCompressor
} from './StateDelta';

View 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)
}

View File

@@ -1,11 +1,63 @@
/**
* @zh 网络输入系统
* @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 type { PlayerInput } from '../protocol'
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 网络输入系统
@@ -15,13 +67,52 @@ import type { NetworkService } from '../services/NetworkService'
* @en Collects local player input and sends to server
*/
export class NetworkInputSystem extends EntitySystem {
private _networkService: NetworkService
private _frame: number = 0
private _inputQueue: PlayerInput[] = []
private readonly _networkService: NetworkService
private readonly _config: NetworkInputConfig
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())
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
this._frame++
const now = Date.now()
while (this._inputQueue.length > 0) {
const input = this._inputQueue.shift()!
input.frame = this._frame
this._networkService.sendInput(input)
// Rate limiting
if (now - this._lastSendTime < this._config.sendInterval) return
// 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
*/
public addMoveInput(x: number, y: number): void {
this._inputQueue.push({
frame: 0,
moveDir: { x, y },
})
// Skip if same as last input
if (
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
*/
public addActionInput(action: string): void {
// Try to add to last input in queue
const lastInput = this._inputQueue[this._inputQueue.length - 1]
if (lastInput) {
lastInput.actions = lastInput.actions || []
lastInput.actions.push(action)
} else {
this._inputQueue.push({
frame: 0,
actions: [action],
})
this._addToQueue({ actions: [action], timestamp: Date.now() })
}
// 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 {
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)
}

View File

@@ -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)
}

View File

@@ -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 { NetworkIdentity } from '../components/NetworkIdentity'
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 同步消息接口
* @en Sync message interface
* @zh 同步消息接口(兼容旧版)
* @en Sync message interface (for backwards compatibility)
*/
export interface SyncMessage {
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 网络同步系统
* @en Network sync system
*
* @zh 处理网络实体的状态同步和插值
* @en Handles state synchronization and interpolation for networked entities
* @zh 处理网络实体的状态同步和插值,支持快照缓冲、平滑插值和外推
* @en Handles state synchronization and interpolation for networked entities,
* supports snapshot buffering, smooth interpolation, and extrapolation
*/
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))
this._config = { ...DEFAULT_CONFIG, ...config }
this._interpolator = createTransformInterpolator()
}
/**
* @zh 处理同步消息
* @en Handle sync message
* @zh 获取配置
* @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 {
const now = Date.now()
for (const state of msg.entities) {
const entityId = this._netIdToEntity.get(state.netId)
if (entityId === undefined) continue
@@ -44,22 +175,133 @@ export class NetworkSyncSystem extends EntitySystem {
if (transform && state.pos) {
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 {
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) {
const transform = this.requireComponent(entity, NetworkTransform)
const identity = this.requireComponent(entity, NetworkIdentity)
if (!identity.bHasAuthority && transform.bInterpolate) {
this._interpolate(transform, deltaTime)
// Skip entities with authority (local player handles their own movement)
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 注册网络实体
* @en Register network entity
@@ -74,6 +316,7 @@ export class NetworkSyncSystem extends EntitySystem {
*/
unregisterEntity(netId: number): void {
this._netIdToEntity.delete(netId)
this._entitySnapshots.delete(netId)
}
/**
@@ -84,19 +327,26 @@ export class NetworkSyncSystem extends EntitySystem {
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
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 清空所有快照缓冲
* @en Clear all snapshot buffers
*/
clearSnapshots(): void {
for (const data of this._entitySnapshots.values()) {
data.buffer.clear()
}
}
protected override onDestroy(): void {
this._netIdToEntity.clear()
this._entitySnapshots.clear()
}
}

View File

@@ -8,6 +8,8 @@ import type { NetworkService } from './services/NetworkService';
import type { NetworkSyncSystem } from './systems/NetworkSyncSystem';
import type { NetworkSpawnSystem } from './systems/NetworkSpawnSystem';
import type { NetworkInputSystem } from './systems/NetworkInputSystem';
import type { NetworkPredictionSystem } from './systems/NetworkPredictionSystem';
import type { NetworkAOISystem } from './systems/NetworkAOISystem';
// ============================================================================
// Network 模块导出的令牌 | Tokens exported by Network module
@@ -36,3 +38,15 @@ export const NetworkSpawnSystemToken = createServiceToken<NetworkSpawnSystem>('n
* Network input system token
*/
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');

View File

@@ -1,5 +1,11 @@
# @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
### Patch Changes

View File

@@ -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);
});
});
});

View 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);
}
});
});
});

View 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);
});
});
});

View 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);
});
});

View 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);
});
});
});

View File

@@ -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);
});
});

View File

@@ -1,6 +1,6 @@
{
"name": "@esengine/pathfinding",
"version": "1.0.3",
"version": "1.1.0",
"description": "寻路系统 | Pathfinding System - A*, Grid, NavMesh",
"type": "module",
"main": "./dist/index.js",
@@ -20,6 +20,8 @@
"build": "tsup",
"build:watch": "tsup --watch",
"type-check": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest",
"clean": "rimraf dist"
},
"devDependencies": {
@@ -27,7 +29,8 @@
"@esengine/ecs-framework-math": "workspace:*",
"@esengine/blueprint": "workspace:*",
"tsup": "^8.0.0",
"typescript": "^5.8.0"
"typescript": "^5.8.0",
"vitest": "^2.1.9"
},
"peerDependencies": {
"@esengine/ecs-framework": "workspace:*",

View File

@@ -0,0 +1,7 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
include: ['__tests__/**/*.test.ts'],
},
});

View File

@@ -1,5 +1,21 @@
# @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
### Minor Changes

View File

@@ -1,6 +1,6 @@
{
"name": "@esengine/rpc",
"version": "1.1.0",
"version": "1.1.1",
"description": "Elegant type-safe RPC library for ESEngine",
"type": "module",
"main": "./dist/index.js",

View File

@@ -40,3 +40,7 @@
export { rpc } from './define'
export * from './types'
// Re-export client for browser/bundler compatibility
export { RpcClient, connect } from './client/index'
export type { RpcClientOptions, WebSocketAdapter, WebSocketFactory } from './client/index'

View File

@@ -0,0 +1,68 @@
# @esengine/server
## 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

View 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"
}

View File

@@ -0,0 +1,73 @@
{
"name": "@esengine/server",
"version": "1.2.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"
},
"./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"
]
}

View File

@@ -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');
});
});

View 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);
});
});

View 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']);
});
});

View 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');
});
});

View 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;
}

View 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';

View 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;
};
}

View 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;
};
}

View 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';

View 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';

View 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;
}

View 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;
}
}

View File

@@ -0,0 +1,6 @@
/**
* @zh 认证提供者接口
* @en Authentication provider interface
*/
export type { IAuthProvider, AuthResult, AuthErrorCode } from '../types.js';

View 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);
}

View File

@@ -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);
}

View 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';

View 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);
}

View File

@@ -0,0 +1,11 @@
/**
* @zh 认证测试工具
* @en Authentication testing utilities
*/
export {
MockAuthProvider,
createMockAuthProvider,
type MockUser,
type MockAuthConfig
} from './MockAuthProvider.js';

View 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';
}

View 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
}

View 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
}

View 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'

View File

@@ -0,0 +1,72 @@
/**
* @zh 玩家类
* @en Player class
*/
import type { Connection } from '@esengine/rpc'
/**
* @zh 玩家接口
* @en Player interface
*/
export interface IPlayer<TData = Record<string, unknown>> {
readonly id: string
readonly roomId: string
data: TData
send<T>(type: string, data: T): void
leave(reason?: string): void
}
/**
* @zh 玩家实现
* @en Player implementation
*/
export class Player<TData = Record<string, unknown>> implements IPlayer<TData> {
readonly id: string
readonly roomId: string
data: TData
private _conn: Connection<any>
private _sendFn: (conn: Connection<any>, type: string, data: unknown) => void
private _leaveFn: (player: Player<TData>, reason?: string) => void
constructor(options: {
id: string
roomId: string
conn: Connection<any>
sendFn: (conn: Connection<any>, type: string, data: unknown) => void
leaveFn: (player: Player<TData>, reason?: string) => void
initialData?: TData
}) {
this.id = options.id
this.roomId = options.roomId
this._conn = options.conn
this._sendFn = options.sendFn
this._leaveFn = options.leaveFn
this.data = options.initialData ?? ({} as TData)
}
/**
* @zh 获取底层连接
* @en Get underlying connection
*/
get connection(): Connection<any> {
return this._conn
}
/**
* @zh 发送消息给玩家
* @en Send message to player
*/
send<T>(type: string, data: T): void {
this._sendFn(this._conn, type, data)
}
/**
* @zh 让玩家离开房间
* @en Make player leave the room
*/
leave(reason?: string): void {
this._leaveFn(this, reason)
}
}

View File

@@ -0,0 +1,383 @@
/**
* @zh 房间基类
* @en Room base class
*/
import { Player } from './Player.js'
/**
* @zh 房间配置
* @en Room options
*/
export interface RoomOptions {
[key: string]: unknown
}
/**
* @zh 消息处理器元数据
* @en Message handler metadata
*/
interface MessageHandlerMeta {
type: string
method: string
}
/**
* @zh 消息处理器存储 key
* @en Message handler storage key
*/
const MESSAGE_HANDLERS = Symbol('messageHandlers')
/**
* @zh 房间基类
* @en Room base class
*
* @example
* ```typescript
* class GameRoom extends Room {
* maxPlayers = 4
* tickRate = 20
*
* onJoin(player: Player) {
* this.broadcast('Joined', { id: player.id })
* }
*
* @onMessage('Move')
* handleMove(data: { x: number, y: number }, player: Player) {
* // handle move
* }
* }
* ```
*/
export abstract class Room<TState = any, TPlayerData = Record<string, unknown>> {
// ========================================================================
// 配置 | Configuration
// ========================================================================
/**
* @zh 最大玩家数
* @en Maximum players
*/
maxPlayers = 16
/**
* @zh Tick 速率每秒0 = 不自动 tick
* @en Tick rate (per second), 0 = no auto tick
*/
tickRate = 0
/**
* @zh 空房间自动销毁
* @en Auto dispose when empty
*/
autoDispose = true
// ========================================================================
// 状态 | State
// ========================================================================
/**
* @zh 房间状态
* @en Room state
*/
state: TState = {} as TState
// ========================================================================
// 内部属性 | Internal properties
// ========================================================================
private _id: string = ''
private _players: Map<string, Player<TPlayerData>> = new Map()
private _locked = false
private _disposed = false
private _tickInterval: ReturnType<typeof setInterval> | null = null
private _lastTickTime = 0
private _broadcastFn: ((type: string, data: unknown) => void) | null = null
private _sendFn: ((conn: any, type: string, data: unknown) => void) | null = null
private _disposeFn: (() => void) | null = null
// ========================================================================
// 只读属性 | Readonly properties
// ========================================================================
/**
* @zh 房间 ID
* @en Room ID
*/
get id(): string {
return this._id
}
/**
* @zh 所有玩家
* @en All players
*/
get players(): ReadonlyArray<Player<TPlayerData>> {
return Array.from(this._players.values())
}
/**
* @zh 玩家数量
* @en Player count
*/
get playerCount(): number {
return this._players.size
}
/**
* @zh 是否已满
* @en Is full
*/
get isFull(): boolean {
return this._players.size >= this.maxPlayers
}
/**
* @zh 是否已锁定
* @en Is locked
*/
get isLocked(): boolean {
return this._locked
}
/**
* @zh 是否已销毁
* @en Is disposed
*/
get isDisposed(): boolean {
return this._disposed
}
// ========================================================================
// 生命周期 | Lifecycle
// ========================================================================
/**
* @zh 房间创建时调用
* @en Called when room is created
*/
onCreate(options?: RoomOptions): void | Promise<void> {}
/**
* @zh 玩家加入时调用
* @en Called when player joins
*/
onJoin(player: Player<TPlayerData>): void | Promise<void> {}
/**
* @zh 玩家离开时调用
* @en Called when player leaves
*/
onLeave(player: Player<TPlayerData>, reason?: string): void | Promise<void> {}
/**
* @zh 游戏循环
* @en Game tick
*/
onTick(dt: number): void {}
/**
* @zh 房间销毁时调用
* @en Called when room is disposed
*/
onDispose(): void | Promise<void> {}
// ========================================================================
// 公共方法 | Public methods
// ========================================================================
/**
* @zh 广播消息给所有玩家
* @en Broadcast message to all players
*/
broadcast<T>(type: string, data: T): void {
for (const player of this._players.values()) {
player.send(type, data)
}
}
/**
* @zh 广播消息给除指定玩家外的所有玩家
* @en Broadcast message to all players except one
*/
broadcastExcept<T>(except: Player<TPlayerData>, type: string, data: T): void {
for (const player of this._players.values()) {
if (player.id !== except.id) {
player.send(type, data)
}
}
}
/**
* @zh 获取玩家
* @en Get player by id
*/
getPlayer(id: string): Player<TPlayerData> | undefined {
return this._players.get(id)
}
/**
* @zh 踢出玩家
* @en Kick player
*/
kick(player: Player<TPlayerData>, reason?: string): void {
player.leave(reason ?? 'kicked')
}
/**
* @zh 锁定房间
* @en Lock room
*/
lock(): void {
this._locked = true
}
/**
* @zh 解锁房间
* @en Unlock room
*/
unlock(): void {
this._locked = false
}
/**
* @zh 手动销毁房间
* @en Manually dispose room
*/
dispose(): void {
if (this._disposed) return
this._disposed = true
this._stopTick()
for (const player of this._players.values()) {
player.leave('room_disposed')
}
this._players.clear()
this.onDispose()
this._disposeFn?.()
}
// ========================================================================
// 内部方法 | Internal methods
// ========================================================================
/**
* @internal
*/
_init(options: {
id: string
sendFn: (conn: any, type: string, data: unknown) => void
broadcastFn: (type: string, data: unknown) => void
disposeFn: () => void
}): void {
this._id = options.id
this._sendFn = options.sendFn
this._broadcastFn = options.broadcastFn
this._disposeFn = options.disposeFn
}
/**
* @internal
*/
async _create(options?: RoomOptions): Promise<void> {
await this.onCreate(options)
this._startTick()
}
/**
* @internal
*/
async _addPlayer(id: string, conn: any): Promise<Player<TPlayerData> | null> {
if (this._locked || this.isFull || this._disposed) {
return null
}
const player = new Player<TPlayerData>({
id,
roomId: this._id,
conn,
sendFn: this._sendFn!,
leaveFn: (p, reason) => this._removePlayer(p.id, reason),
})
this._players.set(id, player)
await this.onJoin(player)
return player
}
/**
* @internal
*/
async _removePlayer(id: string, reason?: string): Promise<void> {
const player = this._players.get(id)
if (!player) return
this._players.delete(id)
await this.onLeave(player, reason)
if (this.autoDispose && this._players.size === 0) {
this.dispose()
}
}
/**
* @internal
*/
_handleMessage(type: string, data: unknown, playerId: string): void {
const player = this._players.get(playerId)
if (!player) return
const handlers = (this.constructor as any)[MESSAGE_HANDLERS] as MessageHandlerMeta[] | undefined
if (handlers) {
for (const handler of handlers) {
if (handler.type === type) {
const method = (this as any)[handler.method]
if (typeof method === 'function') {
method.call(this, data, player)
}
}
}
}
}
private _startTick(): void {
if (this.tickRate <= 0) return
this._lastTickTime = performance.now()
this._tickInterval = setInterval(() => {
const now = performance.now()
const dt = (now - this._lastTickTime) / 1000
this._lastTickTime = now
this.onTick(dt)
}, 1000 / this.tickRate)
}
private _stopTick(): void {
if (this._tickInterval) {
clearInterval(this._tickInterval)
this._tickInterval = null
}
}
}
/**
* @zh 获取消息处理器元数据
* @en Get message handler metadata
*/
export function getMessageHandlers(target: any): MessageHandlerMeta[] {
return target[MESSAGE_HANDLERS] || []
}
/**
* @zh 注册消息处理器元数据
* @en Register message handler metadata
*/
export function registerMessageHandler(target: any, type: string, method: string): void {
if (!target[MESSAGE_HANDLERS]) {
target[MESSAGE_HANDLERS] = []
}
target[MESSAGE_HANDLERS].push({ type, method })
}

View File

@@ -0,0 +1,221 @@
/**
* @zh 房间管理器
* @en Room manager
*/
import { Room, type RoomOptions } from './Room.js'
import type { Player } from './Player.js'
/**
* @zh 房间类型
* @en Room class type
*/
export type RoomClass<T extends Room = Room> = new () => T
/**
* @zh 房间定义
* @en Room definition
*/
interface RoomDefinition {
roomClass: RoomClass
}
/**
* @zh 房间管理器
* @en Room manager
*/
export class RoomManager {
private _definitions: Map<string, RoomDefinition> = new Map()
private _rooms: Map<string, Room> = new Map()
private _playerToRoom: Map<string, string> = new Map()
private _nextRoomId = 1
private _sendFn: (conn: any, type: string, data: unknown) => void
constructor(sendFn: (conn: any, type: string, data: unknown) => void) {
this._sendFn = sendFn
}
/**
* @zh 注册房间类型
* @en Define room type
*/
define<T extends Room>(name: string, roomClass: RoomClass<T>): void {
this._definitions.set(name, { roomClass })
}
/**
* @zh 创建房间
* @en Create room
*/
async create(name: string, options?: RoomOptions): Promise<Room | null> {
const def = this._definitions.get(name)
if (!def) {
console.warn(`[RoomManager] Room type not found: ${name}`)
return null
}
const roomId = this._generateRoomId()
const room = new def.roomClass()
room._init({
id: roomId,
sendFn: this._sendFn,
broadcastFn: (type, data) => {
for (const player of room.players) {
player.send(type, data)
}
},
disposeFn: () => {
this._rooms.delete(roomId)
},
})
this._rooms.set(roomId, room)
await room._create(options)
console.log(`[Room] Created: ${name} (${roomId})`)
return room
}
/**
* @zh 加入或创建房间
* @en Join or create room
*/
async joinOrCreate(
name: string,
playerId: string,
conn: any,
options?: RoomOptions
): Promise<{ room: Room; player: Player } | null> {
// 查找可加入的房间
let room = this._findAvailableRoom(name)
// 没有则创建
if (!room) {
room = await this.create(name, options)
if (!room) return null
}
// 加入房间
const player = await room._addPlayer(playerId, conn)
if (!player) return null
this._playerToRoom.set(playerId, room.id)
console.log(`[Room] Player ${playerId} joined ${room.id}`)
return { room, player }
}
/**
* @zh 加入指定房间
* @en Join specific room
*/
async joinById(
roomId: string,
playerId: string,
conn: any
): Promise<{ room: Room; player: Player } | null> {
const room = this._rooms.get(roomId)
if (!room) return null
const player = await room._addPlayer(playerId, conn)
if (!player) return null
this._playerToRoom.set(playerId, room.id)
console.log(`[Room] Player ${playerId} joined ${room.id}`)
return { room, player }
}
/**
* @zh 玩家离开
* @en Player leave
*/
async leave(playerId: string, reason?: string): Promise<void> {
const roomId = this._playerToRoom.get(playerId)
if (!roomId) return
const room = this._rooms.get(roomId)
if (room) {
await room._removePlayer(playerId, reason)
}
this._playerToRoom.delete(playerId)
console.log(`[Room] Player ${playerId} left ${roomId}`)
}
/**
* @zh 处理消息
* @en Handle message
*/
handleMessage(playerId: string, type: string, data: unknown): void {
const roomId = this._playerToRoom.get(playerId)
if (!roomId) return
const room = this._rooms.get(roomId)
if (room) {
room._handleMessage(type, data, playerId)
}
}
/**
* @zh 获取房间
* @en Get room
*/
getRoom(roomId: string): Room | undefined {
return this._rooms.get(roomId)
}
/**
* @zh 获取玩家所在房间
* @en Get player's room
*/
getPlayerRoom(playerId: string): Room | undefined {
const roomId = this._playerToRoom.get(playerId)
return roomId ? this._rooms.get(roomId) : undefined
}
/**
* @zh 获取所有房间
* @en Get all rooms
*/
getRooms(): ReadonlyArray<Room> {
return Array.from(this._rooms.values())
}
/**
* @zh 获取指定类型的所有房间
* @en Get all rooms of a type
*/
getRoomsByType(name: string): Room[] {
const def = this._definitions.get(name)
if (!def) return []
return Array.from(this._rooms.values()).filter(
room => room instanceof def.roomClass
)
}
private _findAvailableRoom(name: string): Room | null {
const def = this._definitions.get(name)
if (!def) return null
for (const room of this._rooms.values()) {
if (
room instanceof def.roomClass &&
!room.isFull &&
!room.isLocked &&
!room.isDisposed
) {
return room
}
}
return null
}
private _generateRoomId(): string {
return `room_${this._nextRoomId++}`
}
}

View File

@@ -0,0 +1,35 @@
/**
* @zh 房间装饰器
* @en Room decorators
*/
import { registerMessageHandler } from './Room.js'
/**
* @zh 消息处理器装饰器
* @en Message handler decorator
*
* @example
* ```typescript
* class GameRoom extends Room {
* @onMessage('Move')
* handleMove(data: { x: number, y: number }, player: Player) {
* // handle move
* }
*
* @onMessage('Chat')
* handleChat(data: { text: string }, player: Player) {
* this.broadcast('Chat', { from: player.id, text: data.text })
* }
* }
* ```
*/
export function onMessage(type: string): MethodDecorator {
return function (
target: any,
propertyKey: string | symbol,
_descriptor: PropertyDescriptor
) {
registerMessageHandler(target.constructor, type, propertyKey as string)
}
}

View File

@@ -0,0 +1,9 @@
/**
* @zh 房间系统
* @en Room system
*/
export { Room, type RoomOptions } from './Room.js'
export { Player, type IPlayer } from './Player.js'
export { RoomManager, type RoomClass } from './RoomManager.js'
export { onMessage } from './decorators.js'

View File

@@ -0,0 +1,112 @@
/**
* @zh 文件路由加载器
* @en File-based router loader
*/
import * as fs from 'node:fs'
import * as path from 'node:path'
import { pathToFileURL } from 'node:url'
import type { ApiDefinition, MsgDefinition, LoadedApiHandler, LoadedMsgHandler } from '../types/index.js'
/**
* @zh 将文件名转换为 API/消息名称
* @en Convert filename to API/message name
*
* @example
* 'join.ts' -> 'Join'
* 'spawn-agent.ts' -> 'SpawnAgent'
* 'save_blueprint.ts' -> 'SaveBlueprint'
*/
function fileNameToHandlerName(fileName: string): string {
const baseName = fileName.replace(/\.(ts|js|mts|mjs)$/, '')
return baseName
.split(/[-_]/)
.map(part => part.charAt(0).toUpperCase() + part.slice(1))
.join('')
}
/**
* @zh 扫描目录获取所有处理器文件
* @en Scan directory for all handler files
*/
function scanDirectory(dir: string): string[] {
if (!fs.existsSync(dir)) {
return []
}
const files: string[] = []
const entries = fs.readdirSync(dir, { withFileTypes: true })
for (const entry of entries) {
if (entry.isFile() && /\.(ts|js|mts|mjs)$/.test(entry.name)) {
// 跳过 index 和下划线开头的文件
if (entry.name.startsWith('_') || entry.name.startsWith('index.')) {
continue
}
files.push(path.join(dir, entry.name))
}
}
return files
}
/**
* @zh 加载 API 处理器
* @en Load API handlers
*/
export async function loadApiHandlers(apiDir: string): Promise<LoadedApiHandler[]> {
const files = scanDirectory(apiDir)
const handlers: LoadedApiHandler[] = []
for (const filePath of files) {
try {
const fileUrl = pathToFileURL(filePath).href
const module = await import(fileUrl)
const definition = module.default as ApiDefinition<unknown, unknown, unknown>
if (definition && typeof definition.handler === 'function') {
const name = fileNameToHandlerName(path.basename(filePath))
handlers.push({
name,
path: filePath,
definition,
})
}
} catch (err) {
console.warn(`[Server] Failed to load API handler: ${filePath}`, err)
}
}
return handlers
}
/**
* @zh 加载消息处理器
* @en Load message handlers
*/
export async function loadMsgHandlers(msgDir: string): Promise<LoadedMsgHandler[]> {
const files = scanDirectory(msgDir)
const handlers: LoadedMsgHandler[] = []
for (const filePath of files) {
try {
const fileUrl = pathToFileURL(filePath).href
const module = await import(fileUrl)
const definition = module.default as MsgDefinition<unknown, unknown>
if (definition && typeof definition.handler === 'function') {
const name = fileNameToHandlerName(path.basename(filePath))
handlers.push({
name,
path: filePath,
definition,
})
}
} catch (err) {
console.warn(`[Server] Failed to load msg handler: ${filePath}`, err)
}
}
return handlers
}

View File

@@ -0,0 +1,134 @@
/**
* @zh 模拟房间
* @en Mock room for testing
*/
import { Room, onMessage, type Player } from '../room/index.js'
/**
* @zh 模拟房间状态
* @en Mock room state
*/
export interface MockRoomState {
messages: Array<{ type: string; data: unknown; playerId: string }>
joinCount: number
leaveCount: number
}
/**
* @zh 模拟房间
* @en Mock room for testing
*
* @zh 记录所有事件和消息,用于测试断言
* @en Records all events and messages for test assertions
*
* @example
* ```typescript
* const env = await createTestEnv()
* env.server.define('mock', MockRoom)
*
* const client = await env.createClient()
* await client.joinRoom('mock')
*
* client.sendToRoom('Test', { value: 123 })
* await wait(50)
*
* // MockRoom 会广播收到的消息
* const msg = client.getLastMessage('RoomMessage')
* ```
*/
export class MockRoom extends Room<MockRoomState> {
state: MockRoomState = {
messages: [],
joinCount: 0,
leaveCount: 0,
}
onCreate(): void {
// 房间创建
}
onJoin(player: Player): void {
this.state.joinCount++
this.broadcast('PlayerJoined', {
playerId: player.id,
joinCount: this.state.joinCount,
})
}
onLeave(player: Player): void {
this.state.leaveCount++
this.broadcast('PlayerLeft', {
playerId: player.id,
leaveCount: this.state.leaveCount,
})
}
@onMessage('*')
handleAnyMessage(data: unknown, player: Player, type: string): void {
this.state.messages.push({
type,
data,
playerId: player.id,
})
// 回显消息给所有玩家
this.broadcast('MessageReceived', {
type,
data,
from: player.id,
})
}
@onMessage('Echo')
handleEcho(data: unknown, player: Player): void {
// 只回复给发送者
player.send('EchoReply', data)
}
@onMessage('Broadcast')
handleBroadcast(data: unknown, _player: Player): void {
this.broadcast('BroadcastMessage', data)
}
@onMessage('Ping')
handlePing(_data: unknown, player: Player): void {
player.send('Pong', { timestamp: Date.now() })
}
}
/**
* @zh 简单回显房间
* @en Simple echo room
*
* @zh 将收到的任何消息回显给发送者
* @en Echoes any received message back to sender
*/
export class EchoRoom extends Room {
@onMessage('*')
handleAnyMessage(data: unknown, player: Player, type: string): void {
player.send(type, data)
}
}
/**
* @zh 广播房间
* @en Broadcast room
*
* @zh 将收到的任何消息广播给所有玩家
* @en Broadcasts any received message to all players
*/
export class BroadcastRoom extends Room {
onJoin(player: Player): void {
this.broadcast('PlayerJoined', { id: player.id })
}
onLeave(player: Player): void {
this.broadcast('PlayerLeft', { id: player.id })
}
@onMessage('*')
handleAnyMessage(data: unknown, player: Player, type: string): void {
this.broadcast(type, { from: player.id, data })
}
}

View File

@@ -0,0 +1,371 @@
/**
* @zh 房间测试示例
* @en Room test examples
*
* @zh 这个文件展示了如何使用测试工具进行服务器测试
* @en This file demonstrates how to use testing utilities for server testing
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { createTestEnv, type TestEnvironment, wait } from './TestServer.js'
import { MockRoom, BroadcastRoom } from './MockRoom.js'
import { Room, onMessage, type Player } from '../room/index.js'
// ============================================================================
// Custom Room for Testing | 自定义测试房间
// ============================================================================
interface GameState {
players: Map<string, { x: number; y: number }>
scores: Map<string, number>
}
class GameRoom extends Room<GameState> {
maxPlayers = 4
state: GameState = {
players: new Map(),
scores: new Map(),
}
onJoin(player: Player): void {
this.state.players.set(player.id, { x: 0, y: 0 })
this.state.scores.set(player.id, 0)
this.broadcast('PlayerJoined', {
playerId: player.id,
playerCount: this.state.players.size,
})
}
onLeave(player: Player): void {
this.state.players.delete(player.id)
this.state.scores.delete(player.id)
this.broadcast('PlayerLeft', {
playerId: player.id,
playerCount: this.state.players.size,
})
}
@onMessage('Move')
handleMove(data: { x: number; y: number }, player: Player): void {
const pos = this.state.players.get(player.id)
if (pos) {
pos.x = data.x
pos.y = data.y
this.broadcast('PlayerMoved', {
playerId: player.id,
x: data.x,
y: data.y,
})
}
}
@onMessage('Score')
handleScore(data: { points: number }, player: Player): void {
const current = this.state.scores.get(player.id) ?? 0
this.state.scores.set(player.id, current + data.points)
player.send('ScoreUpdated', {
score: this.state.scores.get(player.id),
})
}
}
// ============================================================================
// Test Suites | 测试套件
// ============================================================================
describe('Room Integration Tests', () => {
let env: TestEnvironment
beforeEach(async () => {
env = await createTestEnv()
})
afterEach(async () => {
await env.cleanup()
})
// ========================================================================
// Basic Tests | 基础测试
// ========================================================================
describe('Basic Room Operations', () => {
it('should create and join room', async () => {
env.server.define('game', GameRoom)
const client = await env.createClient()
const result = await client.joinRoom('game')
expect(result.roomId).toBeDefined()
expect(result.playerId).toBeDefined()
expect(client.roomId).toBe(result.roomId)
})
it('should leave room', async () => {
env.server.define('game', GameRoom)
const client = await env.createClient()
await client.joinRoom('game')
await client.leaveRoom()
expect(client.roomId).toBeNull()
})
it('should join existing room by id', async () => {
env.server.define('game', GameRoom)
const client1 = await env.createClient()
const { roomId } = await client1.joinRoom('game')
const client2 = await env.createClient()
const result = await client2.joinRoomById(roomId)
expect(result.roomId).toBe(roomId)
})
})
// ========================================================================
// Message Tests | 消息测试
// ========================================================================
describe('Room Messages', () => {
it('should receive room messages', async () => {
env.server.define('game', GameRoom)
const client = await env.createClient()
await client.joinRoom('game')
const movePromise = client.waitForRoomMessage('PlayerMoved')
client.sendToRoom('Move', { x: 100, y: 200 })
const msg = await movePromise
expect(msg).toEqual({
playerId: client.playerId,
x: 100,
y: 200,
})
})
it('should receive broadcast messages', async () => {
env.server.define('game', GameRoom)
const [client1, client2] = await env.createClients(2)
const { roomId } = await client1.joinRoom('game')
await client2.joinRoomById(roomId)
// client1 等待收到 client2 的移动消息
const movePromise = client1.waitForRoomMessage('PlayerMoved')
client2.sendToRoom('Move', { x: 50, y: 75 })
const msg = await movePromise
expect(msg).toMatchObject({
playerId: client2.playerId,
x: 50,
y: 75,
})
})
it('should handle player join/leave broadcasts', async () => {
env.server.define('broadcast', BroadcastRoom)
const client1 = await env.createClient()
const { roomId } = await client1.joinRoom('broadcast')
// 等待 client2 加入的广播
const joinPromise = client1.waitForRoomMessage<{ id: string }>('PlayerJoined')
const client2 = await env.createClient()
const client2Result = await client2.joinRoomById(roomId)
const joinMsg = await joinPromise
expect(joinMsg).toMatchObject({ id: client2Result.playerId })
// 等待 client2 离开的广播
const leavePromise = client1.waitForRoomMessage<{ id: string }>('PlayerLeft')
const client2PlayerId = client2.playerId // 保存 playerId
await client2.leaveRoom()
const leaveMsg = await leavePromise
expect(leaveMsg).toMatchObject({ id: client2PlayerId })
})
})
// ========================================================================
// MockRoom Tests | 模拟房间测试
// ========================================================================
describe('MockRoom', () => {
it('should record messages', async () => {
env.server.define('mock', MockRoom)
const client = await env.createClient()
await client.joinRoom('mock')
// 使用 Echo 消息,因为它是明确定义的
const echoPromise = client.waitForRoomMessage('EchoReply')
client.sendToRoom('Echo', { value: 123 })
await echoPromise
expect(client.hasReceivedMessage('RoomMessage')).toBe(true)
})
it('should handle echo', async () => {
env.server.define('mock', MockRoom)
const client = await env.createClient()
await client.joinRoom('mock')
const echoPromise = client.waitForRoomMessage('EchoReply')
client.sendToRoom('Echo', { message: 'hello' })
const reply = await echoPromise
expect(reply).toEqual({ message: 'hello' })
})
it('should handle ping/pong', async () => {
env.server.define('mock', MockRoom)
const client = await env.createClient()
await client.joinRoom('mock')
const pongPromise = client.waitForRoomMessage<{ timestamp: number }>('Pong')
client.sendToRoom('Ping', {})
const pong = await pongPromise
expect(pong.timestamp).toBeGreaterThan(0)
})
})
// ========================================================================
// Multiple Clients Tests | 多客户端测试
// ========================================================================
describe('Multiple Clients', () => {
it('should handle multiple clients in same room', async () => {
env.server.define('game', GameRoom)
const clients = await env.createClients(3)
const { roomId } = await clients[0].joinRoom('game')
for (let i = 1; i < clients.length; i++) {
await clients[i].joinRoomById(roomId)
}
// 所有客户端都应该能收到消息
const promises = clients.map((c) => c.waitForRoomMessage('PlayerMoved'))
clients[0].sendToRoom('Move', { x: 1, y: 2 })
const results = await Promise.all(promises)
for (const result of results) {
expect(result).toMatchObject({ x: 1, y: 2 })
}
})
it('should handle concurrent room operations', async () => {
env.server.define('game', GameRoom)
const clients = await env.createClients(4) // maxPlayers = 4
// 顺序加入房间(避免并发创建多个房间)
const { roomId } = await clients[0].joinRoom('game')
// 其余客户端加入同一房间
const results = await Promise.all(
clients.slice(1).map((c) => c.joinRoomById(roomId))
)
// 验证所有客户端都在同一房间
for (const result of results) {
expect(result.roomId).toBe(roomId)
}
})
})
// ========================================================================
// Error Handling Tests | 错误处理测试
// ========================================================================
describe('Error Handling', () => {
it('should reject joining non-existent room type', async () => {
const client = await env.createClient()
await expect(client.joinRoom('nonexistent')).rejects.toThrow()
})
it('should handle client disconnect gracefully', async () => {
env.server.define('game', GameRoom)
const client1 = await env.createClient()
const { roomId } = await client1.joinRoom('game')
const client2 = await env.createClient()
await client2.joinRoomById(roomId)
// 等待 client2 离开的广播
const leavePromise = client1.waitForRoomMessage('PlayerLeft')
// 强制断开 client2
await client2.disconnect()
// client1 应该收到离开消息
const msg = await leavePromise
expect(msg).toBeDefined()
})
})
// ========================================================================
// Assertion Helpers Tests | 断言辅助测试
// ========================================================================
describe('TestClient Assertions', () => {
it('should track received messages', async () => {
env.server.define('mock', MockRoom)
const client = await env.createClient()
await client.joinRoom('mock')
// 发送多条消息
client.sendToRoom('Test', { n: 1 })
client.sendToRoom('Test', { n: 2 })
client.sendToRoom('Test', { n: 3 })
// 等待消息处理
await wait(100)
expect(client.getMessageCount()).toBeGreaterThan(0)
expect(client.hasReceivedMessage('RoomMessage')).toBe(true)
})
it('should get messages of specific type', async () => {
env.server.define('mock', MockRoom)
const client = await env.createClient()
await client.joinRoom('mock')
client.sendToRoom('Ping', {})
await client.waitForRoomMessage('Pong')
const pongs = client.getMessagesOfType('RoomMessage')
expect(pongs.length).toBeGreaterThan(0)
})
it('should clear message history', async () => {
env.server.define('mock', MockRoom)
const client = await env.createClient()
await client.joinRoom('mock')
client.sendToRoom('Test', {})
await wait(50)
expect(client.getMessageCount()).toBeGreaterThan(0)
client.clearMessages()
expect(client.getMessageCount()).toBe(0)
})
})
})

View File

@@ -0,0 +1,523 @@
/**
* @zh 测试客户端
* @en Test client for server testing
*/
import WebSocket from 'ws'
import { json } from '@esengine/rpc/codec'
import type { Codec } from '@esengine/rpc/codec'
// ============================================================================
// Types | 类型定义
// ============================================================================
/**
* @zh 测试客户端配置
* @en Test client options
*/
export interface TestClientOptions {
/**
* @zh 编解码器
* @en Codec
* @defaultValue json()
*/
codec?: Codec
/**
* @zh API 调用超时(毫秒)
* @en API call timeout in milliseconds
* @defaultValue 5000
*/
timeout?: number
/**
* @zh 连接超时(毫秒)
* @en Connection timeout in milliseconds
* @defaultValue 5000
*/
connectTimeout?: number
}
/**
* @zh 房间加入结果
* @en Room join result
*/
export interface JoinRoomResult {
roomId: string
playerId: string
}
/**
* @zh 收到的消息记录
* @en Received message record
*/
export interface ReceivedMessage {
type: string
data: unknown
timestamp: number
}
// ============================================================================
// Constants | 常量
// ============================================================================
const PacketType = {
ApiRequest: 0,
ApiResponse: 1,
ApiError: 2,
Message: 3,
} as const
// ============================================================================
// TestClient Class | 测试客户端类
// ============================================================================
interface PendingCall {
resolve: (value: unknown) => void
reject: (error: Error) => void
timer: ReturnType<typeof setTimeout>
}
/**
* @zh 测试客户端
* @en Test client for server integration testing
*
* @zh 专为测试设计的客户端,提供便捷的断言方法和消息记录功能
* @en Client designed for testing, with convenient assertion methods and message recording
*
* @example
* ```typescript
* const client = new TestClient(3000)
* await client.connect()
*
* // 加入房间
* const { roomId } = await client.joinRoom('game')
*
* // 发送消息
* client.sendToRoom('Move', { x: 10, y: 20 })
*
* // 等待收到特定消息
* const msg = await client.waitForMessage('PlayerMoved')
*
* // 断言收到消息
* expect(client.hasReceivedMessage('PlayerMoved')).toBe(true)
*
* await client.disconnect()
* ```
*/
export class TestClient {
private readonly _port: number
private readonly _codec: Codec
private readonly _timeout: number
private readonly _connectTimeout: number
private _ws: WebSocket | null = null
private _callIdCounter = 0
private _connected = false
private _currentRoomId: string | null = null
private _currentPlayerId: string | null = null
private readonly _pendingCalls = new Map<number, PendingCall>()
private readonly _msgHandlers = new Map<string, Set<(data: unknown) => void>>()
private readonly _receivedMessages: ReceivedMessage[] = []
constructor(port: number, options: TestClientOptions = {}) {
this._port = port
this._codec = options.codec ?? json()
this._timeout = options.timeout ?? 5000
this._connectTimeout = options.connectTimeout ?? 5000
}
// ========================================================================
// Properties | 属性
// ========================================================================
/**
* @zh 是否已连接
* @en Whether connected
*/
get isConnected(): boolean {
return this._connected
}
/**
* @zh 当前房间 ID
* @en Current room ID
*/
get roomId(): string | null {
return this._currentRoomId
}
/**
* @zh 当前玩家 ID
* @en Current player ID
*/
get playerId(): string | null {
return this._currentPlayerId
}
/**
* @zh 收到的所有消息
* @en All received messages
*/
get receivedMessages(): ReadonlyArray<ReceivedMessage> {
return this._receivedMessages
}
// ========================================================================
// Connection | 连接管理
// ========================================================================
/**
* @zh 连接到服务器
* @en Connect to server
*/
connect(): Promise<this> {
return new Promise((resolve, reject) => {
const url = `ws://localhost:${this._port}`
this._ws = new WebSocket(url)
const timeout = setTimeout(() => {
this._ws?.close()
reject(new Error(`Connection timeout after ${this._connectTimeout}ms`))
}, this._connectTimeout)
this._ws.on('open', () => {
clearTimeout(timeout)
this._connected = true
resolve(this)
})
this._ws.on('close', () => {
this._connected = false
this._rejectAllPending('Connection closed')
})
this._ws.on('error', (err) => {
clearTimeout(timeout)
if (!this._connected) {
reject(err)
}
})
this._ws.on('message', (data: Buffer) => {
this._handleMessage(data)
})
})
}
/**
* @zh 断开连接
* @en Disconnect from server
*/
async disconnect(): Promise<void> {
return new Promise((resolve) => {
if (!this._ws || this._ws.readyState === WebSocket.CLOSED) {
resolve()
return
}
this._ws.once('close', () => {
this._connected = false
this._ws = null
resolve()
})
this._ws.close()
})
}
// ========================================================================
// Room Operations | 房间操作
// ========================================================================
/**
* @zh 加入房间
* @en Join a room
*/
async joinRoom(roomType: string, options?: Record<string, unknown>): Promise<JoinRoomResult> {
const result = await this.call<JoinRoomResult>('JoinRoom', { roomType, options })
this._currentRoomId = result.roomId
this._currentPlayerId = result.playerId
return result
}
/**
* @zh 通过 ID 加入房间
* @en Join a room by ID
*/
async joinRoomById(roomId: string): Promise<JoinRoomResult> {
const result = await this.call<JoinRoomResult>('JoinRoom', { roomId })
this._currentRoomId = result.roomId
this._currentPlayerId = result.playerId
return result
}
/**
* @zh 离开房间
* @en Leave room
*/
async leaveRoom(): Promise<void> {
await this.call('LeaveRoom', {})
this._currentRoomId = null
this._currentPlayerId = null
}
/**
* @zh 发送消息到房间
* @en Send message to room
*/
sendToRoom(type: string, data: unknown): void {
this.send('RoomMessage', { type, data })
}
// ========================================================================
// API Calls | API 调用
// ========================================================================
/**
* @zh 调用 API
* @en Call API
*/
call<T = unknown>(name: string, input: unknown): Promise<T> {
return new Promise((resolve, reject) => {
if (!this._connected || !this._ws) {
reject(new Error('Not connected'))
return
}
const id = ++this._callIdCounter
const timer = setTimeout(() => {
this._pendingCalls.delete(id)
reject(new Error(`API call '${name}' timeout after ${this._timeout}ms`))
}, this._timeout)
this._pendingCalls.set(id, {
resolve: resolve as (v: unknown) => void,
reject,
timer,
})
const packet = [PacketType.ApiRequest, id, name, input]
// eslint-disable-next-line @typescript-eslint/no-explicit-any
this._ws.send(this._codec.encode(packet as any) as Buffer)
})
}
/**
* @zh 发送消息
* @en Send message
*/
send(name: string, data: unknown): void {
if (!this._connected || !this._ws) return
const packet = [PacketType.Message, name, data]
// eslint-disable-next-line @typescript-eslint/no-explicit-any
this._ws.send(this._codec.encode(packet as any) as Buffer)
}
// ========================================================================
// Message Handling | 消息处理
// ========================================================================
/**
* @zh 监听消息
* @en Listen for message
*/
on(name: string, handler: (data: unknown) => void): this {
let handlers = this._msgHandlers.get(name)
if (!handlers) {
handlers = new Set()
this._msgHandlers.set(name, handlers)
}
handlers.add(handler)
return this
}
/**
* @zh 取消监听消息
* @en Remove message listener
*/
off(name: string, handler?: (data: unknown) => void): this {
if (handler) {
this._msgHandlers.get(name)?.delete(handler)
} else {
this._msgHandlers.delete(name)
}
return this
}
/**
* @zh 等待收到指定消息
* @en Wait for a specific message
*/
waitForMessage<T = unknown>(type: string, timeout?: number): Promise<T> {
return new Promise((resolve, reject) => {
const timeoutMs = timeout ?? this._timeout
const timer = setTimeout(() => {
this.off(type, handler)
reject(new Error(`Timeout waiting for message '${type}' after ${timeoutMs}ms`))
}, timeoutMs)
const handler = (data: unknown) => {
clearTimeout(timer)
this.off(type, handler)
resolve(data as T)
}
this.on(type, handler)
})
}
/**
* @zh 等待收到指定房间消息
* @en Wait for a specific room message
*/
waitForRoomMessage<T = unknown>(type: string, timeout?: number): Promise<T> {
return new Promise((resolve, reject) => {
const timeoutMs = timeout ?? this._timeout
const timer = setTimeout(() => {
this.off('RoomMessage', handler)
reject(new Error(`Timeout waiting for room message '${type}' after ${timeoutMs}ms`))
}, timeoutMs)
const handler = (data: unknown) => {
const msg = data as { type: string; data: unknown }
if (msg.type === type) {
clearTimeout(timer)
this.off('RoomMessage', handler)
resolve(msg.data as T)
}
}
this.on('RoomMessage', handler)
})
}
// ========================================================================
// Assertions | 断言辅助
// ========================================================================
/**
* @zh 是否收到过指定消息
* @en Whether received a specific message
*/
hasReceivedMessage(type: string): boolean {
return this._receivedMessages.some((m) => m.type === type)
}
/**
* @zh 获取指定类型的所有消息
* @en Get all messages of a specific type
*/
getMessagesOfType<T = unknown>(type: string): T[] {
return this._receivedMessages
.filter((m) => m.type === type)
.map((m) => m.data as T)
}
/**
* @zh 获取最后收到的指定类型消息
* @en Get the last received message of a specific type
*/
getLastMessage<T = unknown>(type: string): T | undefined {
for (let i = this._receivedMessages.length - 1; i >= 0; i--) {
if (this._receivedMessages[i].type === type) {
return this._receivedMessages[i].data as T
}
}
return undefined
}
/**
* @zh 清空消息记录
* @en Clear message records
*/
clearMessages(): void {
this._receivedMessages.length = 0
}
/**
* @zh 获取收到的消息数量
* @en Get received message count
*/
getMessageCount(type?: string): number {
if (type) {
return this._receivedMessages.filter((m) => m.type === type).length
}
return this._receivedMessages.length
}
// ========================================================================
// Private Methods | 私有方法
// ========================================================================
private _handleMessage(raw: Buffer): void {
try {
const packet = this._codec.decode(raw) as unknown[]
const type = packet[0] as number
switch (type) {
case PacketType.ApiResponse:
this._handleApiResponse([packet[0], packet[1], packet[2]] as [number, number, unknown])
break
case PacketType.ApiError:
this._handleApiError([packet[0], packet[1], packet[2], packet[3]] as [number, number, string, string])
break
case PacketType.Message:
this._handleMsg([packet[0], packet[1], packet[2]] as [number, string, unknown])
break
}
} catch (err) {
console.error('[TestClient] Failed to handle message:', err)
}
}
private _handleApiResponse([, id, result]: [number, number, unknown]): void {
const pending = this._pendingCalls.get(id)
if (pending) {
clearTimeout(pending.timer)
this._pendingCalls.delete(id)
pending.resolve(result)
}
}
private _handleApiError([, id, code, message]: [number, number, string, string]): void {
const pending = this._pendingCalls.get(id)
if (pending) {
clearTimeout(pending.timer)
this._pendingCalls.delete(id)
pending.reject(new Error(`[${code}] ${message}`))
}
}
private _handleMsg([, name, data]: [number, string, unknown]): void {
// 记录消息
this._receivedMessages.push({
type: name,
data,
timestamp: Date.now(),
})
// 触发处理器
const handlers = this._msgHandlers.get(name)
if (handlers) {
for (const handler of handlers) {
try {
handler(data)
} catch (err) {
console.error('[TestClient] Handler error:', err)
}
}
}
}
private _rejectAllPending(reason: string): void {
for (const [, pending] of this._pendingCalls) {
clearTimeout(pending.timer)
pending.reject(new Error(reason))
}
this._pendingCalls.clear()
}
}

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